Просмотр исходного кода

feat(stats): add model comparison radar

Adam 1 месяц назад
Родитель
Сommit
4a1982f5c9

+ 538 - 0
packages/stats/app/src/routes/compare-radar.tsx

@@ -0,0 +1,538 @@
+import { Icon } from "@opencode-ai/ui/icon"
+import { createMemo, createSignal, For, Show, type JSX } from "solid-js"
+import type { ModelCatalogBenchmark, ModelCatalogEntry } from "./model-catalog"
+
+const radarRingCount = 5
+const radarColors = ["#294bdb", "#159447", "#d24a3b", "#8a4fd2", "#b47400", "#008c95"] as const
+const codingBenchmarkPattern = /(swe|aider|code|coding|nl2repo)/
+const reasoningBenchmarkPattern = /(gpqa|humanity|last exam|reasoning|aime|hmmt|math|mmlu|mrcr|charxiv|cti realm)/
+const toolUseBenchmarkPattern = /(terminal bench|claw eval|tau ?(?:bench|2|3))/
+
+export type ComparisonRadarModel = {
+  name: string
+  labName: string
+  catalog: ModelCatalogEntry | null
+}
+
+type ComparisonRadarProps = {
+  models: readonly ComparisonRadarModel[]
+  catalogModels: readonly ModelCatalogEntry[]
+}
+
+type RadarAxis = {
+  label: string
+  description: string
+  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(() =>
+    props.models.map((model, index) => ({
+      name: model.name,
+      labName: model.labName,
+      color: radarColors[index % radarColors.length],
+      scores: axes().map((axis) => (model.catalog ? axis.score(model.catalog) : undefined)),
+    })),
+  )
+  const accessibleDescription = createMemo(() =>
+    series()
+      .map(
+        (model) =>
+          `${model.name}: ${axes()
+            .map((axis, index) => `${axis.label} ${formatRadarScore(model.scores[index])}`)
+            .join(", ")}`,
+      )
+      .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">
+      <ol data-slot="compare-radar-legend">
+        <For each={series()}>
+          {(model) => (
+            <li>
+              <i style={{ background: model.color }} aria-hidden="true" />
+              <span>
+                <strong>{model.name}</strong>
+                <small>{model.labName}</small>
+              </span>
+            </li>
+          )}
+        </For>
+      </ol>
+      <div data-slot="compare-radar-chart" role="img" aria-label={accessibleDescription()}>
+        <div data-slot="compare-radar-plot" aria-hidden="true">
+          <svg viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet">
+            <g data-slot="compare-radar-grid">
+              <For each={Array.from({ length: radarRingCount })}>
+                {(_, index) => (
+                  <polygon points={radarPolygonPoints(axes().length, ((index() + 1) / radarRingCount) * 100)} />
+                )}
+              </For>
+              <For each={axes()}>
+                {(_, index) => {
+                  const point = () => radarPoint(index(), axes().length, 100)
+                  return <line x1="50" y1="50" x2={point().x} y2={point().y} />
+                }}
+              </For>
+            </g>
+            <For each={series()}>
+              {(model) => (
+                <g data-slot="compare-radar-series" style={{ color: model.color }}>
+                  <Show when={radarSeriesPolygon(model.scores)}>
+                    {(points) => <polygon data-slot="compare-radar-area" points={points()} />}
+                  </Show>
+                  <Show when={!radarSeriesPolygon(model.scores)}>
+                    <For each={radarSeriesConnections(model.scores)}>
+                      {(connection) => (
+                        <line
+                          data-slot="compare-radar-line"
+                          x1={connection.start.x}
+                          y1={connection.start.y}
+                          x2={connection.end.x}
+                          y2={connection.end.y}
+                        />
+                      )}
+                    </For>
+                  </Show>
+                  <For each={model.scores}>
+                    {(score, index) => {
+                      if (score === undefined) return null
+                      const point = () => radarPoint(index(), axes().length, score)
+                      return (
+                        <>
+                          <circle data-slot="compare-radar-point" cx={point().x} cy={point().y} r="0.95" />
+                          <circle
+                            data-slot="compare-radar-point-hit"
+                            cx={point().x}
+                            cy={point().y}
+                            r="3"
+                            onMouseEnter={() => setActiveAxis(index())}
+                            onMouseLeave={() => clearActiveAxis(index())}
+                          />
+                        </>
+                      )
+                    }}
+                  </For>
+                </g>
+              )}
+            </For>
+          </svg>
+        </div>
+        <For each={axes()}>
+          {(axis, index) => (
+            <span
+              data-slot="compare-radar-axis"
+              data-active={activeAxis() === index() ? "true" : undefined}
+              style={radarAxisStyle(index(), axes().length)}
+              tabIndex="0"
+              aria-label={`${axis.label}. ${axis.description}`}
+              onMouseEnter={() => setActiveAxis(index())}
+              onMouseLeave={() => clearActiveAxis(index())}
+              onFocus={() => setActiveAxis(index())}
+              onBlur={() => clearActiveAxis(index())}
+              onKeyDown={(event) => {
+                if (event.key === "Escape") event.currentTarget.blur()
+              }}
+            >
+              <span data-slot="compare-radar-axis-label">{axis.label}</span>
+            </span>
+          )}
+        </For>
+        <Show when={activeAxis() !== undefined}>
+          <div
+            data-slot="compare-radar-tooltip"
+            role="tooltip"
+            style={radarTooltipStyle(activeAxis() ?? 0, axes().length)}
+          >
+            <strong>{axes()[activeAxis() ?? 0]?.label}</strong>
+            <p>{axes()[activeAxis() ?? 0]?.description}</p>
+          </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>
+          <thead>
+            <tr>
+              <th>Model</th>
+              <For each={axes()}>{(axis) => <th>{axis.label}</th>}</For>
+            </tr>
+          </thead>
+          <tbody>
+            <For each={series()}>
+              {(model) => (
+                <tr>
+                  <th>{model.name}</th>
+                  <For each={model.scores}>{(score) => <td>{formatRadarScore(score)}</td>}</For>
+                </tr>
+              )}
+            </For>
+          </tbody>
+        </table>
+      </div>
+    </section>
+  )
+}
+
+function buildRadarAxes(catalogModels: readonly ModelCatalogEntry[]): RadarAxis[] {
+  const benchmarks = benchmarkScoreGroups(catalogModels)
+  const toolUseBenchmarks = benchmarkScoreGroups(catalogModels, true)
+  const costs = catalogModels.flatMap((model) => {
+    const cost = modelCost(model)
+    return cost === undefined ? [] : [cost]
+  })
+  const contexts = catalogModels.flatMap((model) => (model.limit?.context === undefined ? [] : [model.limit.context]))
+  const multimodalMaximum = Math.max(...catalogModels.map(multimodalFeatureCount), 0)
+
+  // Speed and safety stay out until the catalog exposes comparable values for them.
+  return [
+    {
+      label: "Reasoning",
+      description: "Ability to solve complex, multi-step problems. Based on reasoning benchmarks when available.",
+      score: (model) =>
+        benchmarkPercentile(model, benchmarks, reasoningBenchmarkPattern) ?? (model.reasoning ? 100 : 0),
+    },
+    {
+      label: "Coding",
+      description: "Performance on software engineering and coding benchmarks.",
+      score: (model) => benchmarkPercentile(model, benchmarks, codingBenchmarkPattern),
+    },
+    {
+      label: "Cost efficiency",
+      description: "Relative input and output pricing. Lower-cost models score higher.",
+      score: (model) => {
+        const cost = modelCost(model)
+        if (cost === undefined) return
+        if (cost === 0) return 100
+        return percentileScore(cost, costs, "lower")
+      },
+    },
+    {
+      label: "Context window",
+      description: "How much input the model can process at once. Larger context windows score higher.",
+      score: (model) => {
+        const context = model.limit?.context
+        if (context === undefined) return
+        return percentileScore(context, contexts, "higher")
+      },
+    },
+    {
+      label: "Multimodal",
+      description: "Support for non-text input and output, including images, audio, and video.",
+      score: (model) => {
+        if (multimodalMaximum === 0) return
+        return (multimodalFeatureCount(model) / multimodalMaximum) * 100
+      },
+    },
+    {
+      label: "Tool use",
+      description: "Performance on agent benchmarks including Terminal-Bench, Tau3, and Claw-Eval.",
+      score: (model) =>
+        benchmarkPercentile(model, toolUseBenchmarks, toolUseBenchmarkPattern, {
+          aggregate: "average",
+          includeHarness: true,
+        }),
+    },
+  ]
+}
+
+function benchmarkScoreGroups(catalogModels: readonly ModelCatalogEntry[], includeHarness = false) {
+  return catalogModels.reduce<Map<string, number[]>>((groups, model) => {
+    model.benchmarks
+      .reduce<Map<string, number>>((scores, benchmark) => {
+        const key = benchmarkKey(benchmark, includeHarness)
+        scores.set(key, Math.max(scores.get(key) ?? -Infinity, benchmark.score))
+        return scores
+      }, new Map())
+      .forEach((score, key) => {
+        groups.set(key, [...(groups.get(key) ?? []), score])
+      })
+    return groups
+  }, new Map())
+}
+
+function benchmarkKey(benchmark: ModelCatalogBenchmark, includeHarness: boolean) {
+  const name = normalizeBenchmarkName(benchmark.name)
+  const version = normalizeBenchmarkName(benchmark.version ?? "")
+  const versioned = version && !name.endsWith(version) ? `${name} ${version}` : name
+  if (!includeHarness) return versioned
+  const harness = normalizeBenchmarkName(benchmark.harness ?? benchmark.variant ?? "")
+  return harness ? `${versioned} | ${harness}` : versioned
+}
+
+function benchmarkPercentile(
+  model: ModelCatalogEntry,
+  benchmarks: Map<string, number[]>,
+  pattern: RegExp,
+  options?: { aggregate?: "average" | "best"; includeHarness?: boolean },
+) {
+  const scores = Object.entries(
+    model.benchmarks.reduce<Record<string, number>>((result, benchmark) => {
+      const key = benchmarkKey(benchmark, options?.includeHarness ?? false)
+      if (!pattern.test(key)) return result
+      result[key] = Math.max(result[key] ?? -Infinity, benchmark.score)
+      return result
+    }, {}),
+  ).flatMap(([key, score]) => {
+    const values = benchmarks.get(key)
+    const percentile = values ? percentileScore(score, values, "higher") : undefined
+    return percentile === undefined ? [] : [percentile]
+  })
+  if (scores.length === 0) return
+  if (options?.aggregate === "average") return scores.reduce((sum, score) => sum + score, 0) / scores.length
+  // Benchmark coverage varies by model, so additional published results should not lower a model's score.
+  return Math.max(...scores)
+}
+
+function normalizeBenchmarkName(value: string) {
+  return value
+    .toLowerCase()
+    .replace(/\u03c4/g, "tau")
+    .replace(/\u00b2/g, "2")
+    .replace(/\u00b3/g, "3")
+    .replace(/[^a-z0-9]+/g, " ")
+    .trim()
+}
+
+function modelCost(model: ModelCatalogEntry) {
+  if (!model.cost) return
+  return model.cost.input + model.cost.output
+}
+
+function multimodalFeatureCount(model: ModelCatalogEntry) {
+  return new Set([
+    ...model.modalities.input.filter((modality) => modality !== "text").map((modality) => `input:${modality}`),
+    ...model.modalities.output.filter((modality) => modality !== "text").map((modality) => `output:${modality}`),
+    ...(model.attachment ? ["attachment"] : []),
+  ]).size
+}
+
+function percentileScore(value: number, values: number[], direction: "higher" | "lower") {
+  const finite = values.filter(Number.isFinite)
+  if (!Number.isFinite(value) || finite.length < 2) return
+  const below = finite.filter((candidate) => candidate < value).length
+  const equal = finite.filter((candidate) => candidate === value).length
+  const percentile = ((below + (equal - 1) / 2) / (finite.length - 1)) * 100
+  return direction === "higher" ? percentile : 100 - percentile
+}
+
+function radarPoint(index: number, count: number, score: number): RadarPoint {
+  const angle = -Math.PI / 2 + (index * Math.PI * 2) / count
+  const radius = Math.max(0, Math.min(100, score)) / 2
+  return {
+    x: roundRadarCoordinate(50 + Math.cos(angle) * radius),
+    y: roundRadarCoordinate(50 + Math.sin(angle) * radius),
+  }
+}
+
+function radarPolygonPoints(count: number, score: number) {
+  return Array.from({ length: count })
+    .map((_, index) => radarPoint(index, count, score))
+    .map((point) => `${point.x},${point.y}`)
+    .join(" ")
+}
+
+function radarSeriesPolygon(scores: (number | undefined)[]) {
+  if (scores.some((score) => score === undefined)) return
+  return scores
+    .map((score, index) => radarPoint(index, scores.length, score ?? 0))
+    .map((point) => `${point.x},${point.y}`)
+    .join(" ")
+}
+
+function radarSeriesConnections(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: radarPoint(index, scores.length, score),
+        end: radarPoint(nextIndex, scores.length, next),
+      },
+    ]
+  })
+}
+
+function radarAxisStyle(index: number, count: number) {
+  const angle = -Math.PI / 2 + (index * Math.PI * 2) / count
+  const horizontal = Math.cos(angle)
+  return {
+    "--compare-radar-axis-x": `${roundRadarCoordinate(50 + horizontal * 42)}%`,
+    "--compare-radar-axis-mobile-x": `${roundRadarCoordinate(50 + horizontal * 36)}%`,
+    "--compare-radar-axis-y": `${roundRadarCoordinate(50 + Math.sin(angle) * 42)}%`,
+    "--compare-radar-axis-translate-x": horizontal > 0.25 ? "0%" : horizontal < -0.25 ? "-100%" : "-50%",
+  } as JSX.CSSProperties
+}
+
+function radarTooltipStyle(index: number, count: number) {
+  const angle = -Math.PI / 2 + (index * Math.PI * 2) / count
+  return {
+    "--compare-radar-tooltip-x": `${roundRadarCoordinate(50 + Math.cos(angle) * 42)}%`,
+    "--compare-radar-tooltip-y": `${roundRadarCoordinate(50 + Math.sin(angle) * 42)}%`,
+    "--compare-radar-tooltip-translate-y": Math.sin(angle) < -0.9 ? "20px" : "calc(-100% - 12px)",
+  } as JSX.CSSProperties
+}
+
+function roundRadarCoordinate(value: number) {
+  return Math.round(value * 1000) / 1000
+}
+
+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> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;" }
+    return entities[character] ?? character
+  })
+}

+ 2 - 0
packages/stats/app/src/routes/compare/[firstLab]/[firstModel]/[secondLab]/[secondModel].tsx

@@ -19,6 +19,7 @@ import {
   type ComparisonModelRef,
   type ComparisonPair,
 } from "../../../../compare-cards"
+import { ComparisonRadar } from "../../../../compare-radar"
 import {
   catalogSlug,
   findModelCatalogEntry,
@@ -260,6 +261,7 @@ export default function ModelComparePair() {
               }}
             />
           </Show>
+          <ComparisonRadar models={models()} catalogModels={catalog()?.models ?? []} />
           <div
             data-component="compare-detail-table"
             data-model-count={models().length}

+ 326 - 0
packages/stats/app/src/routes/index.css

@@ -6083,6 +6083,254 @@
   line-height: 16px;
 }
 
+[data-page="stats"] [data-section="compare-radar"] {
+  position: relative;
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) 800px minmax(0, 1fr);
+  width: 100%;
+  height: 800px;
+  box-sizing: border-box;
+  border-right: 1px solid var(--stats-line);
+  border-left: 1px solid var(--stats-line);
+  color: var(--stats-text);
+  background: var(--stats-bg);
+}
+
+[data-page="stats"] [data-slot="compare-radar-legend"] {
+  display: flex;
+  flex-direction: column;
+  gap: 14px;
+  min-width: 0;
+  margin: 0;
+  padding: 40px;
+  list-style: none;
+}
+
+[data-page="stats"] [data-slot="compare-radar-legend"] li {
+  display: grid;
+  grid-template-columns: 6px minmax(0, 1fr);
+  gap: 12px;
+  align-items: start;
+  min-width: 0;
+}
+
+[data-page="stats"] [data-slot="compare-radar-legend"] li > i {
+  width: 6px;
+  height: 6px;
+  margin-top: 6px;
+}
+
+[data-page="stats"] [data-slot="compare-radar-legend"] li > span {
+  display: grid;
+  gap: 2px;
+  min-width: 0;
+}
+
+[data-page="stats"] [data-slot="compare-radar-legend"] strong,
+[data-page="stats"] [data-slot="compare-radar-legend"] small {
+  overflow: hidden;
+  font-size: 13px;
+  line-height: 18px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+[data-page="stats"] [data-slot="compare-radar-legend"] strong {
+  color: var(--stats-text);
+  font-weight: 500;
+}
+
+[data-page="stats"] [data-slot="compare-radar-legend"] small {
+  color: var(--stats-muted);
+  font-weight: 400;
+}
+
+[data-page="stats"] [data-slot="compare-radar-chart"] {
+  position: relative;
+  width: 800px;
+  height: 800px;
+}
+
+[data-page="stats"] [data-slot="compare-radar-plot"] {
+  position: absolute;
+  inset: 17.5%;
+}
+
+[data-page="stats"] [data-slot="compare-radar-plot"] svg {
+  display: block;
+  width: 100%;
+  height: 100%;
+  overflow: visible;
+}
+
+[data-page="stats"] [data-slot="compare-radar-grid"] polygon,
+[data-page="stats"] [data-slot="compare-radar-grid"] line {
+  fill: none;
+  stroke: var(--stats-line);
+  stroke-width: 1px;
+  vector-effect: non-scaling-stroke;
+}
+
+[data-page="stats"] [data-slot="compare-radar-area"],
+[data-page="stats"] [data-slot="compare-radar-line"] {
+  stroke: currentColor;
+  stroke-width: 1.5px;
+  stroke-linejoin: round;
+  vector-effect: non-scaling-stroke;
+}
+
+[data-page="stats"] [data-slot="compare-radar-area"] {
+  fill: currentColor;
+  fill-opacity: 0.09;
+}
+
+[data-page="stats"] [data-slot="compare-radar-line"] {
+  fill: none;
+}
+
+[data-page="stats"] [data-slot="compare-radar-point"] {
+  fill: currentColor;
+  stroke: currentColor;
+  stroke-width: 1px;
+  vector-effect: non-scaling-stroke;
+}
+
+[data-page="stats"] [data-slot="compare-radar-point-hit"] {
+  fill: transparent;
+  cursor: pointer;
+}
+
+[data-page="stats"] [data-slot="compare-radar-axis"] {
+  position: absolute;
+  top: var(--compare-radar-axis-y);
+  left: var(--compare-radar-axis-x);
+  max-width: 160px;
+  color: var(--stats-text);
+  font-size: 16px;
+  font-weight: 400;
+  line-height: 20px;
+  text-align: center;
+  cursor: pointer;
+  transform: translate(var(--compare-radar-axis-translate-x), -50%);
+  white-space: nowrap;
+}
+
+[data-page="stats"] [data-slot="compare-radar-axis"]:focus-visible {
+  outline: none;
+}
+
+[data-page="stats"] [data-slot="compare-radar-axis-label"] {
+  display: inline-flex;
+  align-items: center;
+  height: 24px;
+  box-sizing: border-box;
+  padding: 0 8px;
+  margin: 0 -8px;
+}
+
+[data-page="stats"] [data-slot="compare-radar-axis"][data-active="true"] [data-slot="compare-radar-axis-label"] {
+  background: var(--stats-layer-2);
+}
+
+[data-page="stats"] [data-slot="compare-radar-axis"]:focus-visible [data-slot="compare-radar-axis-label"] {
+  outline: 1px solid var(--stats-text);
+  outline-offset: 2px;
+}
+
+[data-page="stats"] [data-slot="compare-radar-tooltip"] {
+  position: absolute;
+  z-index: 5;
+  top: var(--compare-radar-tooltip-y);
+  left: clamp(104px, var(--compare-radar-tooltip-x), calc(100% - 104px));
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  width: 192px;
+  box-sizing: border-box;
+  padding: 8px;
+  color: var(--stats-text);
+  background: var(--stats-layer);
+  box-shadow:
+    0 0 0 0.5px color-mix(in srgb, var(--stats-text) 12%, transparent),
+    0 4px 8px color-mix(in srgb, #000000 8%, transparent),
+    0 8px 16px color-mix(in srgb, #000000 4%, transparent);
+  pointer-events: none;
+  transform: translate(-50%, var(--compare-radar-tooltip-translate-y));
+}
+
+[data-page="stats"] [data-slot="compare-radar-tooltip"] strong,
+[data-page="stats"] [data-slot="compare-radar-tooltip"] p {
+  margin: 0;
+  font-size: 11px;
+  letter-spacing: 0;
+}
+
+[data-page="stats"] [data-slot="compare-radar-tooltip"] strong {
+  color: var(--stats-text);
+  font-weight: 500;
+  line-height: 12px;
+}
+
+[data-page="stats"] [data-slot="compare-radar-tooltip"] p {
+  color: var(--stats-muted);
+  font-weight: 400;
+  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;
+  height: 1px;
+  padding: 0;
+  overflow: hidden;
+  border: 0;
+  clip-path: inset(50%);
+  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;
@@ -6455,6 +6703,29 @@
     justify-content: flex-start;
   }
 
+  [data-page="stats"] [data-section="compare-radar"] {
+    grid-template-columns: minmax(180px, 208px) minmax(0, 1fr);
+  }
+
+  [data-page="stats"] [data-slot="compare-radar-legend"] {
+    padding-right: 24px;
+    padding-left: 32px;
+  }
+
+  [data-page="stats"] [data-slot="compare-radar-chart"] {
+    align-self: center;
+    width: min(100%, 800px);
+    height: auto;
+    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;
@@ -6484,6 +6755,33 @@
   [data-page="stats"] [data-slot="compare-detail-actions"] {
     flex-wrap: wrap;
   }
+
+  [data-page="stats"] [data-section="compare-radar"] {
+    grid-template-columns: minmax(0, 1fr);
+    height: auto;
+    padding: 32px 24px 24px;
+  }
+
+  [data-page="stats"] [data-slot="compare-radar-legend"] {
+    flex-direction: row;
+    flex-wrap: wrap;
+    gap: 16px 32px;
+    padding: 0 0 16px;
+  }
+
+  [data-page="stats"] [data-slot="compare-radar-legend"] li {
+    flex: 1 1 180px;
+  }
+
+  [data-page="stats"] [data-slot="compare-radar-chart"] {
+    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) {
@@ -6496,6 +6794,34 @@
     width: 100%;
   }
 
+  [data-page="stats"] [data-section="compare-radar"] {
+    padding-right: 16px;
+    padding-left: 16px;
+  }
+
+  [data-page="stats"] [data-slot="compare-radar-legend"] {
+    gap: 12px;
+  }
+
+  [data-page="stats"] [data-slot="compare-radar-legend"] li {
+    flex-basis: 140px;
+  }
+
+  [data-page="stats"] [data-slot="compare-radar-axis"] {
+    left: var(--compare-radar-axis-mobile-x);
+    max-width: 104px;
+    font-size: 13px;
+    line-height: 16px;
+    white-space: normal;
+  }
+
+  [data-page="stats"] [data-slot="compare-radar-axis-label"] {
+    padding-right: 6px;
+    padding-left: 6px;
+    margin-right: -6px;
+    margin-left: -6px;
+  }
+
   [data-page="stats"] [data-component="compare-detail-table"] {
     --compare-detail-label-column: 188px;
     --compare-detail-model-column-min: 246px;