Procházet zdrojové kódy

fix(data): refine model trend charts

Adam před 2 měsíci
rodič
revize
fc09646973

+ 1 - 1
packages/stats/app/src/i18n.ts

@@ -214,7 +214,7 @@ const en = {
   "model.noUsageTitle": "No usage",
   "model.noUsageDescription": "No usage landed in the current window.",
   "model.dailyTokenChart": "Daily token usage chart",
-  "model.usersDescription": "Daily unique OpenCode Go users over the recent two-month window.",
+  "model.usersDescription": "Daily unique users.",
   "model.noUsersTitle": "No user data",
   "model.noUsersDescription": "No user-bearing rows landed in the current window.",
   "model.dailyUserChart": "Daily unique user chart",

+ 128 - 26
packages/stats/app/src/routes/[lab]/[model].tsx

@@ -122,6 +122,7 @@ export default function StatsModel() {
     { href: "#overview", label: i18n.t("nav.overview") },
     { href: "#momentum", label: i18n.t("model.momentum") },
     { href: "#usage", label: i18n.t("nav.usage") },
+    { href: "#unique-users", label: i18n.t("model.uniqueUsers") },
     { href: "#efficiency", label: i18n.t("nav.efficiency") },
     { href: "#geo-breakdown", label: i18n.t("nav.geoBreakdown") },
     { href: "#peers", label: i18n.t("nav.peers") },
@@ -185,6 +186,7 @@ export default function StatsModel() {
                 <ModelOverview catalog={catalogEntry() ?? null} />
                 <ModelMomentumSection data={stats() ?? null} />
                 <ModelUsageSection data={stats() ?? null} />
+                <ModelUniqueUsersSection data={stats() ?? null} />
                 <ModelEfficiencySection data={stats() ?? null} catalog={catalogEntry() ?? null} />
                 <ModelGeoBreakdownSection data={stats()?.country ?? emptyCountryRecord()} />
                 <ModelPeersSection data={stats() ?? null} />
@@ -565,19 +567,82 @@ function MomentumMetric(props: { label: string; value: string; watermark?: strin
 
 function ModelUsageSection(props: { data: StatsModelData | null }) {
   const i18n = useI18n()
+  return (
+    <ModelTrendSection
+      data={props.data}
+      id="usage"
+      title={i18n.t("nav.usage")}
+      description={i18n.t("model.usageDescription")}
+      ariaLabel={i18n.t("model.dailyTokenChart")}
+      emptyTitle={i18n.t("model.noUsageTitle")}
+      emptyDescription={i18n.t("model.noUsageDescription")}
+      value={(point) => point.tokens}
+      formatValue={formatTokens}
+      valueUnit={i18n.t("lab.tokens")}
+      rowLabel={i18n.t("lab.dailyTokens")}
+      lineTone="muted"
+    />
+  )
+}
+
+function ModelUniqueUsersSection(props: { data: StatsModelData | null }) {
+  const i18n = useI18n()
+  return (
+    <ModelTrendSection
+      data={props.data}
+      id="unique-users"
+      title={i18n.t("model.uniqueUsers")}
+      description={i18n.t("model.usersDescription")}
+      ariaLabel={i18n.t("model.dailyUserChart")}
+      emptyTitle={i18n.t("model.noUsersTitle")}
+      emptyDescription={i18n.t("model.noUsersDescription")}
+      value={(point) => point.users}
+      formatValue={formatUsers}
+      valueUnit={i18n.t("format.users")}
+      rowLabel={i18n.t("model.uniqueUsers")}
+      lineTone="active"
+      activeLineBaseTone="muted"
+      highlightBars={false}
+      area
+    />
+  )
+}
+
+function ModelTrendSection(props: {
+  data: StatsModelData | null
+  id: string
+  title: string
+  description: string
+  ariaLabel: string
+  emptyTitle: string
+  emptyDescription: string
+  value: (point: ModelUsagePoint) => number
+  formatValue: (value: number) => string
+  valueUnit: string
+  rowLabel: string
+  lineTone: "muted" | "active"
+  activeLineBaseTone?: "muted" | "active"
+  highlightBars?: boolean
+  area?: boolean
+}) {
   const activeLineClipId = createUniqueId()
   const activeLineMaskId = createUniqueId()
+  const areaGradientId = createUniqueId()
   const [activeIndex, setActiveIndex] = createSignal<number>()
+  const highlightBars = createMemo(() => props.highlightBars ?? true)
+  const lineScale = 326 / 450
+  const areaBottom = 100 / lineScale
   const usage = createMemo(() => props.data?.usage ?? [])
-  const tokenMax = createMemo(() => Math.max(0, ...usage().map((item) => item.tokens)) || 1)
+  const valueMax = createMemo(() => Math.max(0, ...usage().map((item) => props.value(item))) || 1)
   const linePoints = createMemo(() =>
     usage().map((point, index) => ({
       point,
       x: modelUsagePointX(index, usage().length),
-      y: modelUsageLineY(point.tokens, tokenMax()),
+      y: modelUsageLineY(props.value(point), valueMax()),
     })),
   )
-  const tokenLinePath = createMemo(() => modelUsageLinePath(linePoints()))
+  const linePath = createMemo(() => modelUsageLinePath(linePoints()))
+  const areaPath = createMemo(() => modelUsageAreaPath(linePoints(), areaBottom))
   const activeLineBreak = createMemo(() => {
     const index = activeIndex()
     if (index === undefined || linePoints().length < 2) return undefined
@@ -597,26 +662,24 @@ function ModelUsageSection(props: { data: StatsModelData | null }) {
       bounds,
       index,
       point,
-      tokenY: linePoints()[index]?.y ?? 100,
-      tooltipY: ((linePoints()[index]?.y ?? 100) * 370) / 450,
+      tooltipY: (linePoints()[index]?.y ?? 100) * lineScale,
     }
   })
   const monthTicks = createMemo(() => modelUsageMonthTicks(usage(), props.data?.updatedAt ?? null))
 
   return (
-    <section id="usage" data-section="model-panel">
-      <SectionTitle href="#usage" title={i18n.t("nav.usage")} description={i18n.t("model.usageDescription")} />
+    <section id={props.id} data-section="model-panel">
+      <SectionTitle href={`#${props.id}`} title={props.title} description={props.description} />
       <Show
-        when={usage().some((item) => item.tokens > 0)}
-        fallback={
-          <ModelEmptyState title={i18n.t("model.noUsageTitle")} description={i18n.t("model.noUsageDescription")} />
-        }
+        when={usage().some((item) => props.value(item) > 0)}
+        fallback={<ModelEmptyState title={props.emptyTitle} description={props.emptyDescription} />}
       >
         <div
           data-component="model-usage-chart"
           data-variant="model-trend"
+          data-highlight-bars={highlightBars() ? "true" : undefined}
           role="img"
-          aria-label={i18n.t("model.dailyTokenChart")}
+          aria-label={props.ariaLabel}
           style={{ "--model-usage-count": usage().length } as JSX.CSSProperties}
           onPointerLeave={(event) => {
             if (event.pointerType === "touch") return
@@ -624,9 +687,28 @@ function ModelUsageSection(props: { data: StatsModelData | null }) {
           }}
         >
           <div data-slot="model-trend-plot">
-            <Show when={tokenLinePath()}>
+            <Show when={linePath()}>
               {(path) => (
                 <>
+                  <Show when={props.area && areaPath()}>
+                    {(area) => (
+                      <svg
+                        data-slot="model-trend-area-layer"
+                        viewBox={`0 0 100 ${formatUsagePathNumber(areaBottom)}`}
+                        preserveAspectRatio="none"
+                        aria-hidden="true"
+                      >
+                        <defs>
+                          <linearGradient id={areaGradientId} x1="0%" y1="0%" x2="0%" y2="100%">
+                            <stop offset="0%" stop-color="var(--model-trend-active)" stop-opacity="0.1" />
+                            <stop offset="66%" stop-color="var(--model-trend-active)" stop-opacity="0.045" />
+                            <stop offset="100%" stop-color="var(--model-trend-active)" stop-opacity="0" />
+                          </linearGradient>
+                        </defs>
+                        <path data-slot="model-trend-area" d={area()} fill={`url(#${areaGradientId})`} />
+                      </svg>
+                    )}
+                  </Show>
                   <svg
                     data-slot="model-trend-line"
                     data-layer="base"
@@ -634,7 +716,10 @@ function ModelUsageSection(props: { data: StatsModelData | null }) {
                     preserveAspectRatio="none"
                     aria-hidden="true"
                   >
-                    <Show when={activeLineBreak()} fallback={<path data-slot="model-trend-line-base" d={path()} />}>
+                    <Show
+                      when={activeLineBreak()}
+                      fallback={<path data-slot="model-trend-line-base" data-tone={props.lineTone} d={path()} />}
+                    >
                       {(lineBreak) => (
                         <>
                           <defs>
@@ -643,7 +728,12 @@ function ModelUsageSection(props: { data: StatsModelData | null }) {
                               <rect x={lineBreak().x} y="-2" width={lineBreak().width} height="104" fill="black" />
                             </mask>
                           </defs>
-                          <path data-slot="model-trend-line-base" d={path()} mask={`url(#${activeLineMaskId})`} />
+                          <path
+                            data-slot="model-trend-line-base"
+                            data-tone={props.activeLineBaseTone ?? props.lineTone}
+                            d={path()}
+                            mask={`url(#${activeLineMaskId})`}
+                          />
                         </>
                       )}
                     </Show>
@@ -673,11 +763,12 @@ function ModelUsageSection(props: { data: StatsModelData | null }) {
               {(point) => (
                 <span
                   data-slot="model-trend-end-marker"
+                  data-tone={props.lineTone}
                   aria-hidden="true"
                   style={
                     {
                       "--model-trend-end-x": `${point().x}%`,
-                      "--model-trend-end-top": `${(point().y * 370) / 450}%`,
+                      "--model-trend-end-top": `${point().y * lineScale}%`,
                     } as JSX.CSSProperties
                   }
                 />
@@ -690,12 +781,14 @@ function ModelUsageSection(props: { data: StatsModelData | null }) {
                     data-slot="model-trend-column"
                     role="button"
                     tabIndex={0}
-                    aria-label={`${point.date} ${formatTokens(point.tokens)} ${i18n.t("lab.tokens")}`}
+                    aria-label={`${point.date} ${props.formatValue(props.value(point))} ${props.valueUnit}`}
                     data-active={activeIndex() === index() ? "true" : undefined}
-                    data-muted={activeIndex() !== undefined && activeIndex() !== index() ? "true" : undefined}
+                    data-muted={
+                      highlightBars() && activeIndex() !== undefined && activeIndex() !== index() ? "true" : undefined
+                    }
                     style={
                       {
-                        "--model-trend-token-height": `${modelUsageStripHeight(point.tokens, tokenMax())}px`,
+                        "--model-trend-token-height": modelUsageStripHeight(props.value(point), valueMax()),
                       } as JSX.CSSProperties
                     }
                     onPointerDown={(event) => {
@@ -716,7 +809,9 @@ function ModelUsageSection(props: { data: StatsModelData | null }) {
                       setActiveIndex(index())
                     }}
                   >
-                    <div data-slot="model-trend-token-bar" />
+                    <div data-slot="model-trend-token-band">
+                      <div data-slot="model-trend-token-bar" />
+                    </div>
                   </div>
                 )}
               </For>
@@ -730,21 +825,20 @@ function ModelUsageSection(props: { data: StatsModelData | null }) {
                     {
                       "--model-trend-tooltip-left": `${active.bounds.x}%`,
                       "--model-trend-tooltip-right": `${active.bounds.x + active.bounds.width}%`,
-                      "--model-trend-token-y": `${active.tokenY}`,
                       "--model-trend-tooltip-y": `${active.tooltipY}`,
                     } as JSX.CSSProperties
                   }
                 >
                   <strong>{formatModelUsageTooltipDate(active.point.date, props.data?.updatedAt ?? null)}</strong>
                   <span>
-                    {formatTokens(active.point.tokens)} {i18n.t("lab.tokens")}
+                    {props.formatValue(props.value(active.point))} {props.valueUnit}
                   </span>
                   <div data-slot="tooltip-divider" />
                   <p>
                     <span data-slot="tooltip-label">
-                      <i data-kind="tokens" /> {i18n.t("lab.dailyTokens")}
+                      <i data-kind={props.lineTone === "active" ? "users" : "tokens"} /> {props.rowLabel}
                     </span>
-                    <b>{formatTokens(active.point.tokens)}</b>
+                    <b>{props.formatValue(props.value(active.point))}</b>
                   </p>
                 </div>
               )}
@@ -1222,8 +1316,8 @@ function formatMomentumDateLabel(date: string) {
 }
 
 function modelUsageStripHeight(value: number, max: number) {
-  if (value <= 0 || max <= 0) return 0
-  return Math.max(2, (value / max) * 40)
+  if (value <= 0 || max <= 0) return "0px"
+  return `max(2px, ${(value / max) * 100}%)`
 }
 
 function modelUsageLineY(value: number, max: number) {
@@ -1272,6 +1366,14 @@ function modelUsageLinePath(points: ModelUsageLinePoint[]) {
   )
 }
 
+function modelUsageAreaPath(points: ModelUsageLinePoint[], bottom: number) {
+  const path = modelUsageLinePath(points)
+  if (!path || points.length === 0) return ""
+  const first = points[0]
+  const last = points[points.length - 1]
+  return `${path} L ${formatUsagePathNumber(last.x)} ${formatUsagePathNumber(bottom)} L ${formatUsagePathNumber(first.x)} ${formatUsagePathNumber(bottom)} Z`
+}
+
 function formatUsagePathNumber(value: number) {
   return Number(value.toFixed(3))
 }

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

@@ -4874,7 +4874,8 @@
 [data-page="stats"] [data-component="model-usage-chart"][data-variant="model-trend"] {
   --model-trend-gap: 4px;
   --model-trend-column-gutter: calc(var(--model-trend-gap) / 2);
-  --model-trend-line-bottom: 80px;
+  --model-trend-bar-height: 76px;
+  --model-trend-line-bottom: 124px;
   --model-trend-bar: #dbdbdb;
   --model-trend-line: #808080;
   --model-trend-active: #3b5cf6;
@@ -4921,6 +4922,21 @@
   z-index: 4;
 }
 
+[data-page="stats"] [data-slot="model-trend-area-layer"] {
+  position: absolute;
+  inset: 0;
+  z-index: 1;
+  display: block;
+  width: 100%;
+  height: 100%;
+  overflow: visible;
+  pointer-events: none;
+}
+
+[data-page="stats"] [data-slot="model-trend-area-layer"] [data-slot="model-trend-area"] {
+  stroke: none;
+}
+
 [data-page="stats"] [data-slot="model-trend-line"] path {
   fill: none;
   stroke-linecap: round;
@@ -4933,6 +4949,10 @@
   stroke: var(--model-trend-line);
 }
 
+[data-page="stats"] [data-slot="model-trend-line-base"][data-tone="active"] {
+  stroke: var(--model-trend-active);
+}
+
 [data-page="stats"] [data-slot="model-trend-line-active"] {
   stroke: var(--model-trend-active);
 }
@@ -4949,6 +4969,10 @@
   pointer-events: none;
 }
 
+[data-page="stats"] [data-slot="model-trend-end-marker"][data-tone="active"] {
+  background: var(--model-trend-active);
+}
+
 [data-page="stats"] [data-slot="model-trend-bars"] {
   position: absolute;
   inset: 0;
@@ -4986,12 +5010,20 @@
     inset calc(var(--model-trend-column-gutter) * -1) 0 0 var(--stats-bg);
 }
 
-[data-page="stats"] [data-slot="model-trend-token-bar"] {
+[data-page="stats"] [data-slot="model-trend-token-band"] {
   position: absolute;
   right: var(--model-trend-column-gutter);
   bottom: 0;
   left: var(--model-trend-column-gutter);
   z-index: 2;
+  height: var(--model-trend-bar-height);
+}
+
+[data-page="stats"] [data-slot="model-trend-token-bar"] {
+  position: absolute;
+  right: 0;
+  bottom: 0;
+  left: 0;
   height: var(--model-trend-token-height);
   background: var(--model-trend-bar);
   transition:
@@ -4999,11 +5031,17 @@
     opacity 120ms ease;
 }
 
-[data-page="stats"] [data-slot="model-trend-column"][data-active="true"] [data-slot="model-trend-token-bar"] {
+[data-page="stats"]
+  [data-component="model-usage-chart"][data-highlight-bars="true"]
+  [data-slot="model-trend-column"][data-active="true"]
+  [data-slot="model-trend-token-bar"] {
   background: var(--model-trend-active);
 }
 
-[data-page="stats"] [data-slot="model-trend-column"][data-muted="true"] [data-slot="model-trend-token-bar"] {
+[data-page="stats"]
+  [data-component="model-usage-chart"][data-highlight-bars="true"]
+  [data-slot="model-trend-column"][data-muted="true"]
+  [data-slot="model-trend-token-bar"] {
   opacity: 0.64;
 }
 
@@ -5065,6 +5103,13 @@
   background: var(--model-trend-active);
 }
 
+[data-page="stats"]
+  [data-component="model-usage-chart"][data-variant="model-trend"]
+  [data-component="chart-tooltip"]
+  i[data-kind="users"] {
+  background: var(--model-trend-active);
+}
+
 [data-page="stats"] [data-component="model-peer-list"] {
   display: grid;
   gap: 8px;
@@ -6169,6 +6214,7 @@
     --model-usage-mobile-track-width: calc(
       var(--model-usage-count) * var(--model-usage-mobile-bar-width) + var(--model-usage-mobile-edge-space)
     );
+    --model-trend-bar-height: 64px;
     --model-trend-line-bottom: 104px;
     grid-template-rows: minmax(0, 360px) 14px;
     gap: 24px;