From 60c88336b9430b8a516c6a44884e3fbf45dc2f62 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 10 Jul 2026 05:52:25 -0500 Subject: [PATCH] feat(stats): update model comparison page --- .../[secondLab]/[secondModel].tsx | 1282 +++++++++++++---- packages/stats/app/src/routes/index.css | 584 ++++++++ packages/stats/core/src/domain/home.ts | 38 +- 3 files changed, 1585 insertions(+), 319 deletions(-) diff --git a/packages/stats/app/src/routes/compare/[firstLab]/[firstModel]/[secondLab]/[secondModel].tsx b/packages/stats/app/src/routes/compare/[firstLab]/[firstModel]/[secondLab]/[secondModel].tsx index 88df35bf36..6ac3fd1d71 100644 --- a/packages/stats/app/src/routes/compare/[firstLab]/[firstModel]/[secondLab]/[secondModel].tsx +++ b/packages/stats/app/src/routes/compare/[firstLab]/[firstModel]/[secondLab]/[secondModel].tsx @@ -1,18 +1,23 @@ import "../../../../index.css" import { Link, Meta, Title } from "@solidjs/meta" -import { getStatsModelComparisonData, type StatsModelComparisonEntry } from "@opencode-ai/stats-core/domain/home" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { + getStatsModelsComparisonData, + type ModelUsagePoint, + type StatsModelComparisonInput, + type StatsModelComparisonEntry, +} from "@opencode-ai/stats-core/domain/home" import { runtime } from "@opencode-ai/stats-core/runtime" -import { createAsync, query, useParams } from "@solidjs/router" -import { createMemo, createSignal, For, onMount, Show } from "solid-js" +import { createAsync, query, useParams, useSearchParams } from "@solidjs/router" +import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js" import { getRequestEvent } from "solid-js/web" import { - ComparisonCardsSection, + comparisonHref, modelRefFromCatalog, uniqueComparisonPairs, type ComparisonModelRef, type ComparisonPair, } from "../../../../compare-cards" -import { ComparisonSelector } from "../../../../compare-selector" import { catalogSlug, findModelCatalogEntry, @@ -34,10 +39,11 @@ import { const compareFallbackUrl = "https://stats.opencode.ai" const compareHeaderLinks: readonly HeaderLink[] = [ - { href: "#overview", label: "Overview" }, - { href: "#comparison", label: "Comparison" }, - { href: "#compare-tool", label: "Compare" }, - { href: "#model-comparison", label: "Related" }, + { href: `${import.meta.env.BASE_URL}#top-models`, label: "Top Models" }, + { href: `${import.meta.env.BASE_URL}#leaderboard`, label: "Leaderboard" }, + { href: `${import.meta.env.BASE_URL}#market-share`, label: "Market Share" }, + { href: `${import.meta.env.BASE_URL}#token-cost`, label: "Token Cost" }, + { href: `${import.meta.env.BASE_URL}#session-cost`, label: "Session Cost" }, ] const compareFooterLinks: readonly HeaderLink[] = [ { href: import.meta.env.BASE_URL, label: "Data Home" }, @@ -45,6 +51,13 @@ const compareFooterLinks: readonly HeaderLink[] = [ { href: `${import.meta.env.BASE_URL}#top-models`, label: "Top Models" }, { href: `${import.meta.env.BASE_URL}#token-cost`, label: "Token Cost" }, ] +const heroLabs = [ + { lab: "deepseek", label: "DeepSeek" }, + { lab: "openai", label: "OpenAI" }, + { lab: "anthropic", label: "Anthropic" }, +] as const +const usageBarLimit = 60 +const comparisonModelLimit = 6 type ComparisonModel = { name: string @@ -55,26 +68,42 @@ type ComparisonModel = { stats: StatsModelComparisonEntry | null } type ComparisonDirection = "higher" | "lower" -type ComparisonCell = { value: string; detail?: string; score?: number } -type ComparisonRow = { - label: string - description: string - direction: ComparisonDirection - cells: [ComparisonCell, ComparisonCell] +type ComparisonDetailCell = { + value: string + unit?: string + href?: string + kind?: "boolean" + score?: number + trend?: number } +type ComparisonDetailRow = { + label: string + direction?: ComparisonDirection + cells: ComparisonDetailCell[] +} +type ComparisonDetailSection = { + title: string + badge?: string + rows: ComparisonDetailRow[] + usage?: ModelUsagePoint[][] +} +type ComparisonModelSelection = { + lab: string + slug: string + catalog: ModelCatalogEntry | null | undefined +} +type ComparisonModels = [ComparisonModel, ComparisonModel, ...ComparisonModel[]] -const getComparisonData = query( - async (firstLab: string, firstModel: string, secondLab: string, secondModel: string) => { - "use server" - return runtime.runPromise(getStatsModelComparisonData(firstLab, firstModel, secondLab, secondModel)) - }, - "getStatsModelComparisonData", -) +const getComparisonData = query(async (models: StatsModelComparisonInput[]) => { + "use server" + return runtime.runPromise(getStatsModelsComparisonData(models)) +}, "getStatsModelComparisonDetailData") export default function ModelComparePair() { const event = getRequestEvent() event?.response.headers.set("Cache-Control", "public, max-age=60, s-maxage=300, stale-while-revalidate=86400") const params = useParams() + const [searchParams] = useSearchParams<{ add?: string }>() const firstLabParam = createMemo(() => params.firstLab ?? "") const firstModelParam = createMemo(() => params.firstModel ?? "") const secondLabParam = createMemo(() => params.secondLab ?? "") @@ -82,24 +111,59 @@ export default function ModelComparePair() { const catalog = createAsync(() => getModelCatalog()) const firstCatalog = createMemo(() => resolvedCatalogEntry(catalog(), firstLabParam(), firstModelParam())) const secondCatalog = createMemo(() => resolvedCatalogEntry(catalog(), secondLabParam(), secondModelParam())) + const modelSelections = createMemo(() => { + const selected: ComparisonModelSelection[] = [ + { lab: firstLabParam(), slug: firstModelParam(), catalog: firstCatalog() }, + { lab: secondLabParam(), slug: secondModelParam(), catalog: secondCatalog() }, + ...parseAdditionalModels(searchParams.add).map((model) => ({ + ...model, + catalog: resolvedCatalogEntry(catalog(), model.lab, model.slug), + })), + ] + return selected + .filter( + (model, index) => + index < 2 || + selected.findIndex( + (candidate) => comparisonModelSelectionKey(candidate) === comparisonModelSelectionKey(model), + ) === index, + ) + .slice(0, comparisonModelLimit) as [ + ComparisonModelSelection, + ComparisonModelSelection, + ...ComparisonModelSelection[], + ] + }) const stats = createAsync(() => { - if (catalog() === undefined || firstCatalog() === undefined || secondCatalog() === undefined) + const selected = modelSelections() + if (catalog() === undefined || selected.some((model) => model.catalog === undefined)) return Promise.resolve(undefined) return getComparisonData( - firstCatalog()?.lab ?? firstLabParam(), - firstCatalog()?.slug ?? firstModelParam(), - secondCatalog()?.lab ?? secondLabParam(), - secondCatalog()?.slug ?? secondModelParam(), + selected.map((model) => ({ + provider: model.catalog?.lab ?? model.lab, + model: model.catalog?.slug ?? model.slug, + })), ) }) const githubStars = createAsync(() => getGitHubStars()) const [themePreference, setThemePreference] = createSignal("system") + const [highlightBest, setHighlightBest] = createSignal(true) + const [addingModel, setAddingModel] = createSignal(false) + let comparisonPage: HTMLElement | undefined + let comparisonHeadingScroll: HTMLDivElement | undefined + let comparisonBodyScroll: HTMLDivElement | undefined + // Trackpads emit noisy diagonal deltas, so keep an axis until a clear handoff or idle. + const comparisonWheel = { + axis: undefined as "horizontal" | "vertical" | undefined, + deltaX: 0, + deltaY: 0, + reset: undefined as number | undefined, + } const models = createMemo( () => - [ - buildComparisonModel(firstLabParam(), firstModelParam(), firstCatalog() ?? null, stats()?.models[0] ?? null), - buildComparisonModel(secondLabParam(), secondModelParam(), secondCatalog() ?? null, stats()?.models[1] ?? null), - ] as const, + modelSelections().map((model, index) => + buildComparisonModel(model.lab, model.slug, model.catalog ?? null, stats()?.models[index] ?? null), + ) as ComparisonModels, ) const title = createMemo(() => `${models()[0].name} vs ${models()[1].name} - Model Comparison`) const description = createMemo( @@ -118,15 +182,89 @@ export default function ModelComparePair() { event?.request.url ?? (typeof window === "undefined" ? compareFallbackUrl : window.location.href), ).toString(), ) - const rows = createMemo(() => buildComparisonRows(models()[0], models()[1])) + const detailSections = createMemo(() => buildComparisonDetailSections(models())) const relatedPairs = createMemo(() => buildRelatedPairs(catalog(), models()[0], models()[1])) const selectorModels = createMemo(() => - uniqueCatalogModels([ - comparisonCatalogEntry(models()[0]), - comparisonCatalogEntry(models()[1]), - ...(catalog()?.models ?? []), - ]), + uniqueCatalogModels([...models().map(comparisonCatalogEntry), ...(catalog()?.models ?? [])]), ) + const selectedCatalogModels = createMemo(() => models().map(comparisonCatalogEntry)) + const canAddModel = createMemo( + () => + models().length < comparisonModelLimit && + selectorModels().some((model) => !selectedCatalogModels().some((selected) => selected.id === model.id)), + ) + const navigateToModels = (next: ModelCatalogEntry[]) => { + if (typeof window === "undefined" || next.length < 2) return + window.location.href = comparisonModelsHref(next) + } + const syncComparisonScroll = (source: HTMLDivElement, target: HTMLDivElement | undefined) => { + if (target && target.scrollLeft !== source.scrollLeft) target.scrollLeft = source.scrollLeft + } + const resetComparisonWheel = () => { + comparisonWheel.axis = undefined + comparisonWheel.deltaX = 0 + comparisonWheel.deltaY = 0 + comparisonWheel.reset = undefined + } + const handleComparisonWheel = (event: WheelEvent) => { + if (event.ctrlKey) return + if (event.target instanceof Element && event.target.closest('[data-component="compare-model-modal"]')) return + if (!(event.currentTarget instanceof HTMLDivElement)) return + const scale = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? window.innerHeight : 1 + const deltaX = (event.shiftKey && event.deltaX === 0 ? event.deltaY : event.deltaX) * scale + const deltaY = (event.shiftKey ? 0 : event.deltaY) * scale + if (event.currentTarget.scrollWidth <= event.currentTarget.clientWidth + 1) { + resetComparisonWheel() + if (deltaY === 0) return + event.preventDefault() + window.scrollBy({ top: deltaY }) + return + } + + if ( + (comparisonWheel.axis === "horizontal" && Math.abs(deltaY) >= 8 && Math.abs(deltaY) > Math.abs(deltaX) * 1.5) || + (comparisonWheel.axis === "vertical" && Math.abs(deltaX) >= 8 && Math.abs(deltaX) > Math.abs(deltaY) * 1.5) + ) + resetComparisonWheel() + + event.preventDefault() + comparisonWheel.deltaX += deltaX + comparisonWheel.deltaY += deltaY + if (comparisonWheel.reset !== undefined) window.clearTimeout(comparisonWheel.reset) + comparisonWheel.reset = window.setTimeout(resetComparisonWheel, 80) + + if ( + comparisonWheel.axis === undefined && + Math.max(Math.abs(comparisonWheel.deltaX), Math.abs(comparisonWheel.deltaY)) < 6 + ) + return + comparisonWheel.axis ??= + Math.abs(comparisonWheel.deltaX) > Math.abs(comparisonWheel.deltaY) * 1.5 ? "horizontal" : "vertical" + + if (comparisonWheel.axis === "vertical") window.scrollBy({ top: comparisonWheel.deltaY }) + if (comparisonWheel.axis === "horizontal") { + event.currentTarget.scrollLeft += comparisonWheel.deltaX + syncComparisonScroll( + event.currentTarget, + event.currentTarget === comparisonHeadingScroll ? comparisonBodyScroll : comparisonHeadingScroll, + ) + } + comparisonWheel.deltaX = 0 + comparisonWheel.deltaY = 0 + } + const handleComparisonPageWheel = (event: WheelEvent) => { + if (event.ctrlKey || event.shiftKey || event.deltaY === 0) return + if ( + event.target instanceof Element && + event.target.closest( + '[data-component="compare-detail-heading-scroll"], [data-component="compare-detail-body-scroll"], [data-component="compare-model-modal"]', + ) + ) + return + event.preventDefault() + const scale = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? window.innerHeight : 1 + window.scrollBy({ top: event.deltaY * scale }) + } const structuredData = createMemo(() => JSON.stringify({ "@context": "https://schema.org", @@ -155,12 +293,27 @@ export default function ModelComparePair() { const nextPreference = isThemePreference(preference) ? preference : "system" applyThemePreference(nextPreference) setThemePreference(nextPreference) + comparisonPage?.addEventListener("wheel", handleComparisonPageWheel, { passive: false }) + comparisonHeadingScroll?.addEventListener("wheel", handleComparisonWheel, { passive: false }) + comparisonBodyScroll?.addEventListener("wheel", handleComparisonWheel, { passive: false }) + onCleanup(() => { + comparisonPage?.removeEventListener("wheel", handleComparisonPageWheel) + comparisonHeadingScroll?.removeEventListener("wheel", handleComparisonWheel) + comparisonBodyScroll?.removeEventListener("wheel", handleComparisonWheel) + if (comparisonWheel.reset !== undefined) window.clearTimeout(comparisonWheel.reset) + }) }) return ( -
+
(comparisonPage = element)} + data-page="stats" + data-layout="compare-detail" + data-theme={themePreference()} + > {title()} + 2 ? "noindex,follow" : "index,follow"} /> @@ -174,123 +327,635 @@ export default function ModelComparePair() {
- -
-

- Comparison Table. Compare usage, cost, limits, and features. -

- - Loading comparison -

Loading stats for both models.

-
- } - > - - - -
-

- Compare Another Pair. Choose two models to compare. -

- 1} - fallback={ -
- No models found -

The model list could not be loaded.

-
- } - > - -
-
- setAddingModel(true)} + onHighlightBestChange={() => setHighlightBest(!highlightBest())} /> + + model.id)} + label="Add model" + models={selectorModels()} + onClose={() => setAddingModel(false)} + onSelect={(model) => { + setAddingModel(false) + navigateToModels([...selectedCatalogModels(), model]) + }} + /> + +
+
(comparisonHeadingScroll = element)} + onScroll={(event) => syncComparisonScroll(event.currentTarget, comparisonBodyScroll)} + > + +
+
(comparisonBodyScroll = element)} + onScroll={(event) => syncComparisonScroll(event.currentTarget, comparisonHeadingScroll)} + > + +
+ Loading comparison +

Loading model stats.

+
+ + } + > + +
+
+
+
) } -function ComparisonHero(props: { models: readonly [ComparisonModel, ComparisonModel] }) { +function ComparisonHero(props: { + models: readonly ComparisonModel[] + canAddModel: boolean + highlightBest: boolean + onAddModel: () => void + onHighlightBestChange: () => void +}) { return ( -
- - Compare - -
-

- {props.models[0].name} vs {props.models[1].name} +
+ +
+

model.name).join(", ")}`}> + Compare + + AI models

-

Compare usage, cost, limits, and features for these two models.

+
+ + +
-
) } -function ComparisonTable(props: { models: readonly [ComparisonModel, ComparisonModel]; rows: ComparisonRow[] }) { +function HeroModelStack() { return ( -
- - - - - - {(model) => } - - - - - {(row) => { - const best = () => bestCellIndex(row) - return ( - - - - {(cell, index) => ( - - )} - - - ) + + ) +} + +function ComparisonPairSelector(props: { catalogModels: ModelCatalogEntry[]; models: readonly ComparisonModel[] }) { + const [activeIndex, setActiveIndex] = createSignal() + const selectedModels = createMemo(() => props.models.map(comparisonCatalogEntry)) + const activeSelected = createMemo(() => { + const index = activeIndex() + if (index === undefined) return undefined + return selectedModels()[index] + }) + const blockedIds = createMemo(() => + selectedModels() + .filter((_, index) => index !== activeIndex()) + .map((model) => model.id), + ) + const navigateToSelection = (index: number, model: ModelCatalogEntry) => { + if (typeof window === "undefined") return + window.location.href = comparisonModelsHref( + selectedModels().map((selected, selectedIndex) => (selectedIndex === index ? model : selected)), + ) + } + + return ( +
+
+ + + {(selected) => ( + setActiveIndex(undefined)} + onSelect={(model) => { + const index = activeIndex() + if (index === undefined) return + setActiveIndex(undefined) + navigateToSelection(index, model) }} - -
-
- {props.models[0].name} compared with {props.models[1].name} -
Metric{model.name}
- {row.label} - {row.description} - - {cell.value} - {(detail) => {detail()}} -
+ /> + )} + +

+ ) +} + +function CompareDetailSelectButton(props: { + model: ComparisonModel + label: string + column: number + last: boolean + expanded: boolean + onOpen: () => void +}) { + return ( + + ) +} + +function CompareModelSelectModal(props: { + models: ModelCatalogEntry[] + selected?: ModelCatalogEntry + blockedIds: string[] + label: string + onClose: () => void + onSelect: (model: ModelCatalogEntry) => void +}) { + let searchInput: HTMLInputElement | undefined + const [search, setSearch] = createSignal("") + const [previewId, setPreviewId] = createSignal(props.selected?.id ?? "") + const availableModels = createMemo(() => + uniqueCatalogModels([...(props.selected ? [props.selected] : []), ...props.models]).filter( + (model) => !props.blockedIds.includes(model.id), + ), + ) + const filteredModels = createMemo(() => { + const terms = search().trim().toLowerCase().split(/\s+/).filter(Boolean) + if (terms.length === 0) return availableModels() + return availableModels().filter((model) => terms.every((term) => modelSearchText(model).includes(term))) + }) + const preview = createMemo( + () => filteredModels().find((model) => model.id === previewId()) ?? filteredModels()[0] ?? availableModels()[0], + ) + + createEffect(() => { + const models = filteredModels() + if (models.some((model) => model.id === previewId())) return + const selected = + props.selected && models.some((model) => model.id === props.selected?.id) ? props.selected.id : undefined + setPreviewId(selected ?? models[0]?.id ?? "") + }) + + createEffect(() => { + if (typeof window === "undefined") return + window.requestAnimationFrame(() => searchInput?.focus()) + }) + + return ( +
{ + if (event.key !== "Escape") return + props.onClose() + }} + > +
event.stopPropagation()} + > +
+ +
+ 0} + fallback={ +
+ No models found + Try another search. +
+ } + > + + {(model) => ( + + )} + +
+
+
+
) } +function CompareModelDetail(props: { model: ModelCatalogEntry }) { + return ( + + ) +} + +function CompareModelFact(props: { label: string; value: string; href?: string }) { + return ( +
+
{props.label}
+
+ + {(href) => {props.value}} + +
+
+ ) +} + +function ComparisonDetailMatrix(props: { + sections: ComparisonDetailSection[] + highlightBest: boolean + modelCount: number +}) { + return ( +
+
+ + {(section) => ( +
+ +
+ {section.title} + {(badge) => {badge()}} +
+ + {(_, index) => ( +
+ )} +
+
+
+ ) +} + +function ComparisonDetailSpacer(props: { modelCount: number }) { + return ( + <> +