Ver Fonte

fix(stats): polish comparison details

Adam há 1 mês atrás
pai
commit
5df822be11

+ 31 - 55
packages/stats/app/src/component/model-compare-detail.tsx

@@ -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 {

+ 14 - 1
packages/stats/app/src/routes/compare-cards.tsx

@@ -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 - 148
packages/stats/app/src/routes/compare-radar.tsx

@@ -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> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;" }
-    return entities[character] ?? character
-  })
-}

+ 4 - 82
packages/stats/app/src/routes/index.css

@@ -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) {