mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-26 01:26:07 +00:00
fix(stats): polish comparison details
This commit is contained in:
parent
233f069070
commit
5df822be11
4 changed files with 50 additions and 286 deletions
|
|
@ -95,12 +95,10 @@ type ComparisonDetailSection = {
|
|||
rows: ComparisonDetailRow[]
|
||||
usage?: ModelUsagePoint[][]
|
||||
}
|
||||
type ComparisonModelSelection = {
|
||||
type ComparisonModelRequest = {
|
||||
lab: string
|
||||
slug: string
|
||||
catalog: ModelCatalogEntry | null | undefined
|
||||
}
|
||||
type ComparisonModels = [ComparisonModel, ComparisonModel, ...ComparisonModel[]]
|
||||
|
||||
export type ModelCompareDetailPageProps = {
|
||||
first?: { lab: string; slug: string }
|
||||
|
|
@ -127,51 +125,41 @@ export default function ModelCompareDetailPage(props: ModelCompareDetailPageProp
|
|||
const catalog = createMemo(() => props.catalog ?? catalogData())
|
||||
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),
|
||||
})),
|
||||
const modelRequests = createMemo(() => {
|
||||
const selected: ComparisonModelRequest[] = [
|
||||
{ lab: firstLabParam(), slug: firstModelParam() },
|
||||
{ lab: secondLabParam(), slug: secondModelParam() },
|
||||
...parseAdditionalModels(searchParams.add),
|
||||
]
|
||||
return selected
|
||||
.filter(
|
||||
(model, index) =>
|
||||
index < 2 ||
|
||||
selected.findIndex(
|
||||
(candidate) => comparisonModelSelectionKey(candidate) === comparisonModelSelectionKey(model),
|
||||
(candidate) => comparisonModelRequestKey(candidate) === comparisonModelRequestKey(model),
|
||||
) === index,
|
||||
)
|
||||
.slice(0, comparisonModelLimit) as [
|
||||
ComparisonModelSelection,
|
||||
ComparisonModelSelection,
|
||||
...ComparisonModelSelection[],
|
||||
]
|
||||
})
|
||||
const stats = createAsync(() => {
|
||||
const selected = modelSelections()
|
||||
if (catalog() === undefined || selected.some((model) => model.catalog === undefined))
|
||||
return Promise.resolve(undefined)
|
||||
return getComparisonData(
|
||||
selected.map((model) => ({
|
||||
provider: model.catalog?.lab ?? model.lab,
|
||||
model: model.catalog?.slug ?? model.slug,
|
||||
})),
|
||||
)
|
||||
.slice(0, comparisonModelLimit)
|
||||
})
|
||||
const modelSelections = createMemo(() =>
|
||||
modelRequests().map((model) => ({
|
||||
...model,
|
||||
catalog: resolvedCatalogEntry(catalog(), model.lab, model.slug),
|
||||
})),
|
||||
)
|
||||
const stats = createAsync(() =>
|
||||
getComparisonData(modelRequests().map((model) => ({ provider: model.lab, model: model.slug }))),
|
||||
)
|
||||
const githubStars = createAsync(() => getGitHubStars())
|
||||
const [themePreference, setThemePreference] = createSignal<ThemePreference>("system")
|
||||
const [highlightBest, setHighlightBest] = createSignal(true)
|
||||
const [addingModel, setAddingModel] = createSignal(false)
|
||||
let comparisonHeadingScroll: HTMLDivElement | undefined
|
||||
let comparisonBodyScroll: HTMLDivElement | undefined
|
||||
const models = createMemo(
|
||||
() =>
|
||||
modelSelections().map((model, index) =>
|
||||
buildComparisonModel(model.lab, model.slug, model.catalog ?? null, stats()?.models[index] ?? null),
|
||||
) as ComparisonModels,
|
||||
const models = createMemo(() =>
|
||||
modelSelections().map((model, index) =>
|
||||
buildComparisonModel(model.lab, model.slug, model.catalog ?? null, stats()?.models[index] ?? null),
|
||||
),
|
||||
)
|
||||
const title = createMemo(() => {
|
||||
if (props.family)
|
||||
|
|
@ -298,23 +286,11 @@ export default function ModelCompareDetailPage(props: ModelCompareDetailPageProp
|
|||
ref={(element) => (comparisonBodyScroll = element)}
|
||||
onScroll={(event) => syncComparisonScroll(event.currentTarget, comparisonHeadingScroll)}
|
||||
>
|
||||
<Show
|
||||
when={stats() !== undefined}
|
||||
fallback={
|
||||
<section data-section="compare-detail-matrix">
|
||||
<div data-component="empty-state" data-compact="true">
|
||||
<strong>Loading comparison</strong>
|
||||
<p>Loading model stats.</p>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
>
|
||||
<ComparisonDetailMatrix
|
||||
highlightBest={highlightBest()}
|
||||
modelCount={models().length}
|
||||
sections={detailSections()}
|
||||
/>
|
||||
</Show>
|
||||
<ComparisonDetailMatrix
|
||||
highlightBest={highlightBest()}
|
||||
modelCount={models().length}
|
||||
sections={detailSections()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ComparisonCardsSection
|
||||
|
|
@ -837,8 +813,8 @@ function parseAdditionalModels(value: string | undefined) {
|
|||
.slice(0, comparisonModelLimit - 2)
|
||||
}
|
||||
|
||||
function comparisonModelSelectionKey(model: ComparisonModelSelection) {
|
||||
return model.catalog?.id ?? `${catalogSlug(model.lab)}/${catalogSlug(model.slug)}`
|
||||
function comparisonModelRequestKey(model: ComparisonModelRequest) {
|
||||
return `${catalogSlug(model.lab)}/${catalogSlug(model.slug)}`
|
||||
}
|
||||
|
||||
function comparisonModelsHref(models: ModelCatalogEntry[]) {
|
||||
|
|
@ -1008,12 +984,12 @@ function buildRelatedPairs(
|
|||
.slice(0, 4)
|
||||
.map(modelRefFromCatalog)
|
||||
|
||||
return uniqueComparisonPairs([
|
||||
...alternatives.slice(0, 3).flatMap((model, index) => [
|
||||
return uniqueComparisonPairs(
|
||||
alternatives.slice(0, 3).flatMap((model, index) => [
|
||||
{ first: current[0], second: model, detail: index === 0 ? "Nearby alternative" : "Related comparison" },
|
||||
{ first: current[1], second: model, detail: index === 0 ? "Nearby alternative" : "Related comparison" },
|
||||
]),
|
||||
]).slice(0, 6)
|
||||
).slice(0, 6)
|
||||
}
|
||||
|
||||
function comparisonRef(model: ComparisonModel): ComparisonModelRef {
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ function FeaturedComparisonCard(props: { pair: ComparisonPair }) {
|
|||
<strong>{props.pair.detail}</strong>
|
||||
<em>{props.pair.description ?? `${props.pair.first.name} vs ${props.pair.second.name}`}</em>
|
||||
</span>
|
||||
<b aria-hidden="true" />
|
||||
<ComparisonCardIcon />
|
||||
</span>
|
||||
<span data-slot="compare-home-card-divider" aria-hidden="true" />
|
||||
<span data-slot="compare-home-card-models">
|
||||
|
|
@ -109,6 +109,19 @@ function FeaturedComparisonCard(props: { pair: ComparisonPair }) {
|
|||
)
|
||||
}
|
||||
|
||||
function ComparisonCardIcon() {
|
||||
return (
|
||||
<b aria-hidden="true">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M12.9509 12.9884L14.4069 14.4444M2.44431 2.44434H6.44431V6.44434H2.44431V2.44434ZM2.44431 9.55542H6.44431V13.5554H2.44431V9.55542ZM9.55539 2.44434H13.5554V6.44434H9.55539V2.44434ZM13.5554 11.5554C13.5554 12.66 12.66 13.5554 11.5554 13.5554C10.4508 13.5554 9.55539 12.66 9.55539 11.5554C9.55539 10.4509 10.4508 9.55542 11.5554 9.55542C12.66 9.55542 13.5554 10.4509 13.5554 11.5554Z"
|
||||
stroke="#808080"
|
||||
/>
|
||||
</svg>
|
||||
</b>
|
||||
)
|
||||
}
|
||||
|
||||
function ComparisonPanelCard(props: { pair: ComparisonPair }) {
|
||||
return (
|
||||
<a data-component="comparison-card" href={canonicalComparisonHref(props.pair.first, props.pair.second)}>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { createMemo, createSignal, For, Show, type JSX } from "solid-js"
|
||||
import type { ModelCatalogBenchmark, ModelCatalogEntry } from "./model-catalog"
|
||||
|
||||
|
|
@ -25,20 +24,12 @@ type RadarAxis = {
|
|||
score: (model: ModelCatalogEntry) => number | undefined
|
||||
}
|
||||
|
||||
type RadarSeries = {
|
||||
name: string
|
||||
labName: string
|
||||
color: string
|
||||
scores: (number | undefined)[]
|
||||
}
|
||||
|
||||
type RadarPoint = {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export function ComparisonRadar(props: ComparisonRadarProps) {
|
||||
let section: HTMLElement | undefined
|
||||
const [activeAxis, setActiveAxis] = createSignal<number>()
|
||||
const axes = createMemo(() => buildRadarAxes(props.catalogModels))
|
||||
const series = createMemo(() =>
|
||||
|
|
@ -60,11 +51,9 @@ export function ComparisonRadar(props: ComparisonRadarProps) {
|
|||
.join(". "),
|
||||
)
|
||||
const clearActiveAxis = (index: number) => setActiveAxis((active) => (active === index ? undefined : active))
|
||||
const download = () => downloadRadarChart(axes(), series())
|
||||
const open = () => openRadarChart(section, axes(), series())
|
||||
|
||||
return (
|
||||
<section ref={(element) => (section = element)} data-section="compare-radar" aria-label="Model capabilities">
|
||||
<section data-section="compare-radar" aria-label="Model capabilities">
|
||||
<ol data-slot="compare-radar-legend">
|
||||
<For each={series()}>
|
||||
{(model) => (
|
||||
|
|
@ -168,14 +157,6 @@ export function ComparisonRadar(props: ComparisonRadarProps) {
|
|||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div data-slot="compare-radar-actions">
|
||||
<button type="button" aria-label="Download chart" title="Download chart" onClick={download}>
|
||||
<Icon name="download" size="small" />
|
||||
</button>
|
||||
<button type="button" aria-label="View chart full screen" title="View chart full screen" onClick={open}>
|
||||
<Icon name="square-arrow-top-right" size="small" />
|
||||
</button>
|
||||
</div>
|
||||
<div data-slot="compare-radar-data">
|
||||
<table>
|
||||
<caption>Normalized model capability scores</caption>
|
||||
|
|
@ -408,131 +389,3 @@ function roundRadarCoordinate(value: number) {
|
|||
function formatRadarScore(score: number | undefined) {
|
||||
return score === undefined ? "No data" : `${Math.round(score)}/100`
|
||||
}
|
||||
|
||||
function downloadRadarChart(axes: RadarAxis[], series: RadarSeries[]) {
|
||||
if (typeof document === "undefined") return
|
||||
const url = radarChartUrl(axes, series)
|
||||
const link = document.createElement("a")
|
||||
link.href = url
|
||||
link.download = `${series.map((model) => fileSlug(model.name)).join("-vs-") || "model-comparison"}.svg`
|
||||
document.body.append(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 0)
|
||||
}
|
||||
|
||||
function openRadarChart(section: HTMLElement | undefined, axes: RadarAxis[], series: RadarSeries[]) {
|
||||
if (typeof document === "undefined") return
|
||||
if (document.fullscreenElement) {
|
||||
void document.exitFullscreen()
|
||||
return
|
||||
}
|
||||
if (section?.requestFullscreen) {
|
||||
void section.requestFullscreen()
|
||||
return
|
||||
}
|
||||
const url = radarChartUrl(axes, series)
|
||||
window.open(url, "_blank", "noopener,noreferrer")
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 60_000)
|
||||
}
|
||||
|
||||
function radarChartUrl(axes: RadarAxis[], series: RadarSeries[]) {
|
||||
return URL.createObjectURL(new Blob([standaloneRadarChart(axes, series)], { type: "image/svg+xml" }))
|
||||
}
|
||||
|
||||
function standaloneRadarChart(axes: RadarAxis[], series: RadarSeries[]) {
|
||||
const grid = Array.from({ length: radarRingCount })
|
||||
.map((_, index) => standalonePolygon(axes.length, ((index + 1) / radarRingCount) * 100))
|
||||
.map((points) => `<polygon points="${points}"/>`)
|
||||
.join("")
|
||||
const spokes = axes
|
||||
.map((_, index) => standalonePoint(index, axes.length, 100))
|
||||
.map((point) => `<line x1="640" y1="400" x2="${point.x}" y2="${point.y}"/>`)
|
||||
.join("")
|
||||
const labels = axes
|
||||
.map((axis, index) => {
|
||||
const point = standalonePoint(index, axes.length, 129)
|
||||
const horizontal = point.x - 640
|
||||
const anchor = horizontal > 40 ? "start" : horizontal < -40 ? "end" : "middle"
|
||||
return `<text x="${point.x}" y="${point.y}" text-anchor="${anchor}" dominant-baseline="middle">${escapeXml(axis.label)}</text>`
|
||||
})
|
||||
.join("")
|
||||
const areas = series
|
||||
.map((model) => {
|
||||
const polygon = standaloneSeriesPolygon(model.scores)
|
||||
const lines = polygon
|
||||
? `<polygon points="${polygon}" fill="${model.color}" fill-opacity="0.09"/>`
|
||||
: standaloneSeriesConnections(model.scores)
|
||||
.map(
|
||||
(connection) =>
|
||||
`<line x1="${connection.start.x}" y1="${connection.start.y}" x2="${connection.end.x}" y2="${connection.end.y}"/>`,
|
||||
)
|
||||
.join("")
|
||||
const points = model.scores
|
||||
.flatMap((score, index) => (score === undefined ? [] : [standalonePoint(index, model.scores.length, score)]))
|
||||
.map((point) => `<circle cx="${point.x}" cy="${point.y}" r="5"/>`)
|
||||
.join("")
|
||||
return `<g stroke="${model.color}" fill="${model.color}" stroke-width="1.5" stroke-linejoin="round">${lines}${points}</g>`
|
||||
})
|
||||
.join("")
|
||||
const legend = series
|
||||
.map(
|
||||
(model, index) =>
|
||||
`<g transform="translate(45 ${50 + index * 52})"><rect width="6" height="6" y="-3" fill="${model.color}"/><text x="17" y="0" dominant-baseline="middle" fill="#161616" font-size="13" font-weight="500">${escapeXml(model.name)}</text><text x="17" y="21" dominant-baseline="middle" fill="#5c5c5c" font-size="13">${escapeXml(model.labName)}</text></g>`,
|
||||
)
|
||||
.join("")
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="800" viewBox="0 0 1280 800"><rect width="1280" height="800" fill="#ffffff"/><g fill="none" stroke="#0000001a" stroke-width="1">${grid}${spokes}</g><g fill="#161616" font-family="IBM Plex Mono, monospace" font-size="16">${labels}</g>${areas}<g font-family="IBM Plex Mono, monospace">${legend}</g></svg>`
|
||||
}
|
||||
|
||||
function standalonePoint(index: number, count: number, score: number) {
|
||||
const angle = -Math.PI / 2 + (index * Math.PI * 2) / count
|
||||
const radius = (Math.max(0, Math.min(129, score)) / 100) * 260
|
||||
return {
|
||||
x: roundRadarCoordinate(640 + Math.cos(angle) * radius),
|
||||
y: roundRadarCoordinate(400 + Math.sin(angle) * radius),
|
||||
}
|
||||
}
|
||||
|
||||
function standalonePolygon(count: number, score: number) {
|
||||
return Array.from({ length: count })
|
||||
.map((_, index) => standalonePoint(index, count, score))
|
||||
.map((point) => `${point.x},${point.y}`)
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
function standaloneSeriesPolygon(scores: (number | undefined)[]) {
|
||||
if (scores.some((score) => score === undefined)) return
|
||||
return scores
|
||||
.map((score, index) => standalonePoint(index, scores.length, score ?? 0))
|
||||
.map((point) => `${point.x},${point.y}`)
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
function standaloneSeriesConnections(scores: (number | undefined)[]) {
|
||||
return scores.flatMap((score, index) => {
|
||||
const nextIndex = (index + 1) % scores.length
|
||||
const next = scores[nextIndex]
|
||||
if (score === undefined || next === undefined) return []
|
||||
return [
|
||||
{
|
||||
start: standalonePoint(index, scores.length, score),
|
||||
end: standalonePoint(nextIndex, scores.length, next),
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function fileSlug(value: string) {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
}
|
||||
|
||||
function escapeXml(value: string) {
|
||||
return value.replace(/[&<>"']/g, (character) => {
|
||||
const entities: Record<string, string> = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }
|
||||
return entities[character] ?? character
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5857,7 +5857,6 @@
|
|||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-home-card-head"] b {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 32px;
|
||||
|
|
@ -5868,32 +5867,10 @@
|
|||
box-shadow: 0 1px 1.5px #0000000f;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-home-card-head"] b::before {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
content: "";
|
||||
background: currentColor;
|
||||
box-shadow:
|
||||
7px 0 currentColor,
|
||||
0 7px currentColor;
|
||||
color: var(--stats-muted);
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-home-card-head"] b::after {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
bottom: 5px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
box-sizing: border-box;
|
||||
content: "";
|
||||
background:
|
||||
radial-gradient(circle at 4px 4px, transparent 2.5px, var(--stats-muted) 2.75px 4px, transparent 4.25px),
|
||||
linear-gradient(var(--stats-muted) 0 0) 7px 8px / 5px 1.5px no-repeat;
|
||||
transform: rotate(45deg);
|
||||
[data-page="stats"] [data-slot="compare-home-card-head"] b svg {
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-home-card-divider"] {
|
||||
|
|
@ -6277,42 +6254,6 @@
|
|||
line-height: 16px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-radar-actions"] {
|
||||
display: flex;
|
||||
align-self: end;
|
||||
justify-self: end;
|
||||
gap: 8px;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-radar-actions"] button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
border: 1px solid var(--stats-line);
|
||||
border-radius: 0;
|
||||
color: var(--stats-text);
|
||||
background: var(--stats-bg);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-radar-actions"] button:hover,
|
||||
[data-page="stats"] [data-slot="compare-radar-actions"] button:focus-visible {
|
||||
border-color: var(--stats-line-strong);
|
||||
background: var(--stats-layer);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-radar-actions"] [data-component="icon"],
|
||||
[data-page="stats"] [data-slot="compare-radar-actions"] svg {
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-radar-data"] {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
|
|
@ -6324,13 +6265,6 @@
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-section="compare-radar"]:fullscreen {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
border: 0;
|
||||
background: var(--stats-bg);
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-component="compare-detail-table"] {
|
||||
--compare-detail-label-column: 292px;
|
||||
--compare-detail-model-column-min: 360px;
|
||||
|
|
@ -6719,13 +6653,6 @@
|
|||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-radar-actions"] {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-component="compare-detail-table"] {
|
||||
--compare-detail-label-column: 220px;
|
||||
--compare-detail-model-column-min: 320px;
|
||||
|
|
@ -6777,11 +6704,6 @@
|
|||
justify-self: center;
|
||||
width: min(100%, 720px);
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="compare-radar-actions"] {
|
||||
position: static;
|
||||
padding: 16px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue