diff --git a/packages/stats/app/src/i18n.ts b/packages/stats/app/src/i18n.ts index 301dcd6f36..eaa6f1674c 100644 --- a/packages/stats/app/src/i18n.ts +++ b/packages/stats/app/src/i18n.ts @@ -161,7 +161,7 @@ const en = { "lab.latestRelease": "Latest release {{date}}", "lab.shareOfUsage": "{{share}} of recent OpenCode usage", "lab.usageTitle": "{{lab}} token usage", - "lab.usageDescription": "Daily OpenCode token volume over the last two months.", + "lab.usageDescription": "Daily token volume.", "lab.noUsageTitle": "No usage yet", "lab.noUsageDescription": "Recent token usage appears here once this lab has activity.", "lab.dailyTokenChart": "{{lab}} daily token usage chart", diff --git a/packages/stats/app/src/routes/[lab]/index.tsx b/packages/stats/app/src/routes/[lab]/index.tsx index 1b5eea174a..f21561ec22 100644 --- a/packages/stats/app/src/routes/[lab]/index.tsx +++ b/packages/stats/app/src/routes/[lab]/index.tsx @@ -8,7 +8,7 @@ import { type StatsLabData, } from "@opencode-ai/stats-core/domain/home" import { createAsync, query, useParams } from "@solidjs/router" -import { createMemo, createSignal, For, onMount, Show, type JSX } from "solid-js" +import { createMemo, createSignal, createUniqueId, For, onMount, Show, type JSX } from "solid-js" import { getRequestEvent } from "solid-js/web" import { LocaleLinks } from "../../component/locale-links" import { useI18n } from "../../context/i18n" @@ -270,20 +270,58 @@ function LabOverviewMetric(props: { label: string; value: string }) { function LabUsageSection(props: { lab: ModelCatalogLab; data: StatsLabData | null }) { const i18n = useI18n() + const activeLineClipId = createUniqueId() + const activeLineMaskId = createUniqueId() const [activeIndex, setActiveIndex] = createSignal() const usage = createMemo(() => props.data?.usage ?? []) - const max = createMemo(() => Math.max(0, ...usage().map((item) => item.tokens)) || 1) + const tokenMax = createMemo(() => Math.max(0, ...usage().map((item) => item.tokens)) || 1) + const userMax = createMemo(() => Math.max(0, ...usage().map((item) => item.users)) || 1) + const linePoints = createMemo(() => + usage().map((point, index) => ({ + point, + x: usagePointX(index, usage().length), + y: usageLineY(point.users, userMax()), + })), + ) + const userLinePath = createMemo(() => usageLinePath(linePoints())) + const activeLineBreak = createMemo(() => { + const index = activeIndex() + if (index === undefined) return undefined + const points = linePoints() + if (points.length < 2) return undefined + return usageColumnBounds(index, points.length) + }) + const activeLineClip = createMemo(() => { + const index = activeIndex() + if (index === undefined) return undefined + const points = linePoints() + if (points.length < 2) return undefined + return usageColumnInnerBounds(index, points.length) + }) + const monthTicks = createMemo(() => labUsageMonthTicks(usage())) const activePoint = createMemo(() => { const index = activeIndex() if (index === undefined) return undefined return usage()[index] }) + const activeTooltip = createMemo(() => { + const index = activeIndex() + const point = activePoint() + if (index === undefined || !point) return undefined + const bounds = usageColumnBounds(index, usage().length) + return { + bounds, + index, + point, + userY: linePoints()[index]?.y ?? 100, + } + }) return (
{ if (event.pointerType === "touch") return setActiveIndex(undefined) }} > - -
- - {(point, index) => ( -
{ - if (event.pointerType !== "touch") return - setActiveIndex(index()) - }} - onPointerEnter={() => setActiveIndex(index())} - onPointerMove={(event) => { - if (event.pointerType === "touch") return - setActiveIndex(index()) - }} - onClick={() => setActiveIndex(index())} - onFocus={() => setActiveIndex(index())} - onBlur={() => setActiveIndex(undefined)} - onKeyDown={(event) => { - if (event.key !== "Enter" && event.key !== " ") return - event.preventDefault() - setActiveIndex(index()) - }} - > -
- - {(active) => ( -
usage().length * 0.62 ? "left" : "right"} +
+ + {(path) => ( + <> + + + + {(clip) => ( + {active().date} - - {formatTokens(active().tokens)} {i18n.t("lab.tokens")} - -
-

- - {i18n.t("lab.dailyTokens")} - - {formatTokens(active().tokens)} -

-
+ + + + + + + )}
-
+ + )} + +
+ + {(point, index) => ( +
{ + if (event.pointerType !== "touch") return + setActiveIndex(index()) + }} + onPointerEnter={() => setActiveIndex(index())} + onPointerMove={(event) => { + if (event.pointerType === "touch") return + setActiveIndex(index()) + }} + onClick={() => setActiveIndex(index())} + onFocus={() => setActiveIndex(index())} + onBlur={() => setActiveIndex(undefined)} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return + event.preventDefault() + setActiveIndex(index()) + }} + > +
+
+ )} + +
+ + {(active) => { + return ( +
usage().length * 0.62 ? "left" : "right"} + style={ + { + "--lab-usage-tooltip-left": `${active.bounds.x}%`, + "--lab-usage-tooltip-right": `${active.bounds.x + active.bounds.width}%`, + "--lab-usage-user-y": `${active.userY}`, + } as JSX.CSSProperties + } + > + {formatUsageTooltipDate(active.point.date)} + + {formatTokens(active.point.tokens)} {i18n.t("lab.tokens")} + +
+

+ + {i18n.t("lab.dailyTokens")} + + {formatTokens(active.point.tokens)} +

+
+ ) + }} + +
+ @@ -479,25 +577,119 @@ function formatTokens(value: number) { return String(Math.round(value)) } +function formatUsers(value: number) { + if (value >= 1_000_000) return `${trimNumber(value / 1_000_000, value >= 10_000_000 ? 0 : 1)}M` + if (value >= 1_000) return `${trimNumber(value / 1_000, value >= 10_000 ? 0 : 1)}K` + return new Intl.NumberFormat("en").format(Math.round(value)) +} + +function formatUsageTooltipDate(value: string) { + const match = /^([A-Z]{3})\s+(\d{1,2})$/.exec(value) + if (!match) return value + return `${monthName(match[1])} ${Number(match[2])} ${new Date().getFullYear()}` +} + +function monthName(value: string) { + const names: Record = { + JAN: "Jan", + FEB: "Feb", + MAR: "Mar", + APR: "Apr", + MAY: "May", + JUN: "Jun", + JUL: "Jul", + AUG: "Aug", + SEP: "Sep", + OCT: "Oct", + NOV: "Nov", + DEC: "Dec", + } + return names[value] ?? value +} + function trimNumber(value: number, digits: number) { return Number(value.toFixed(digits)).toLocaleString("en") } -function usageHeight(value: number, max: number) { +function usageStripHeight(value: number, max: number) { if (value <= 0 || max <= 0) return 0 - return Math.max(4, (value / max) * 100) + return Math.max(1, (value / max) * 40) +} + +function usageLineY(value: number, max: number) { + if (value <= 0 || max <= 0) return 100 + return Math.max(0, 100 - (value / max) * 100) +} + +function usagePointX(index: number, count: number) { + if (count <= 1) return 50 + return ((index + 0.5) / count) * 100 +} + +function usageColumnBounds(index: number, count: number) { + if (count <= 0) return { x: 0, width: 100 } + return { x: (index / count) * 100, width: 100 / count } +} + +function usageColumnInnerBounds(index: number, count: number) { + const bounds = usageColumnBounds(index, count) + const inset = Math.min(bounds.width * 0.1, 0.24) + return { x: bounds.x + inset, width: Math.max(0.001, bounds.width - inset * 2) } +} + +type UsageLinePoint = { x: number; y: number } + +function usageLinePath(points: UsageLinePoint[]) { + if (points.length === 0) return "" + if (points.length === 1) return `M ${pathNumber(points[0].x)} ${pathNumber(points[0].y)}` + + return points.slice(0, -1).reduce( + (path, point, index) => { + const next = points[index + 1] + const previous = points[index - 1] ?? point + const afterNext = points[index + 2] ?? next + const controlA = { + x: point.x + (next.x - previous.x) / 6, + y: clampUsagePercent(point.y + (next.y - previous.y) / 6), + } + const controlB = { + x: next.x - (afterNext.x - point.x) / 6, + y: clampUsagePercent(next.y - (afterNext.y - point.y) / 6), + } + return `${path} C ${pathNumber(controlA.x)} ${pathNumber(controlA.y)}, ${pathNumber(controlB.x)} ${pathNumber(controlB.y)}, ${pathNumber(next.x)} ${pathNumber(next.y)}` + }, + `M ${pathNumber(points[0].x)} ${pathNumber(points[0].y)}`, + ) +} + +function pathNumber(value: number) { + return Number(value.toFixed(3)) +} + +function clampUsagePercent(value: number) { + return Math.max(0, Math.min(100, value)) +} + +function labUsageMonthTicks(points: ModelUsagePoint[]) { + const seen = new Set() + return points.flatMap((point, index) => { + const label = point.date.split(" ")[0] + if (!label || seen.has(label)) return [] + seen.add(label) + return [ + { + label, + left: usagePointX(index, points.length), + align: index === 0 ? "start" : index >= points.length - 2 ? "end" : "center", + }, + ] + }) } function isLabUsageDense(count: number) { return count > 20 } -function isLabUsageLabelHidden(index: number, count: number) { - if (count <= 14) return false - const cadence = count > 45 ? 7 : count > 28 ? 4 : 2 - return index % cadence !== 0 && index !== count - 1 -} - function getProviderIconId(provider: string) { const id = provider.toLowerCase().replace(/[^a-z0-9]+/g, "") if (id === "moonshot") return "moonshotai" diff --git a/packages/stats/app/src/routes/index.css b/packages/stats/app/src/routes/index.css index a5649328c6..67a84c2fdc 100644 --- a/packages/stats/app/src/routes/index.css +++ b/packages/stats/app/src/routes/index.css @@ -2848,7 +2848,7 @@ align-content: start; gap: 24px; min-height: 312px; - padding: 128px 40px 40px; + padding: 128px 0 40px; box-shadow: none; } @@ -3583,6 +3583,189 @@ font-weight: 500; } +[data-page="stats"] [data-component="model-usage-chart"][data-variant="lab-usage"] { + --lab-usage-gap: 4px; + --lab-usage-column-gutter: calc(var(--lab-usage-gap) / 2); + --lab-usage-bar-height: 40px; + --lab-usage-line-bottom: 80px; + --lab-usage-bar: #dbdbdb; + --lab-usage-line: #808080; + --lab-usage-active: #3b5cf6; + --lab-usage-band: #f2f2f2; + grid-template-rows: minmax(0, 450px) 14px; + gap: 40px; + height: 504px; +} + +[data-page="stats"] [data-slot="lab-usage-plot"] { + position: relative; + min-width: 0; + min-height: 0; + overflow: visible; +} + +[data-page="stats"] [data-slot="lab-usage-plot"]::after { + position: absolute; + inset: 0; + z-index: 0; + background: linear-gradient(to bottom, transparent 42%, color-mix(in srgb, var(--stats-text) 3%, transparent)); + content: ""; + pointer-events: none; +} + +[data-page="stats"] [data-slot="lab-usage-line"] { + position: absolute; + top: 0; + right: 0; + bottom: auto; + left: 0; + display: block; + width: 100%; + height: calc(100% - var(--lab-usage-line-bottom)); + overflow: visible; + pointer-events: none; +} + +[data-page="stats"] [data-slot="lab-usage-line"][data-layer="base"] { + z-index: 2; +} + +[data-page="stats"] [data-slot="lab-usage-line"][data-layer="active"] { + z-index: 4; +} + +[data-page="stats"] [data-slot="lab-usage-line"] path { + fill: none; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.5; + vector-effect: non-scaling-stroke; +} + +[data-page="stats"] [data-slot="lab-usage-line-base"] { + stroke: var(--lab-usage-line); +} + +[data-page="stats"] [data-slot="lab-usage-line-active"] { + stroke: var(--lab-usage-active); +} + +[data-page="stats"] [data-slot="lab-usage-bars"] { + position: absolute; + inset: 0; + z-index: 3; + display: flex; + gap: 0; + min-width: 0; +} + +[data-page="stats"] [data-slot="lab-usage-column"] { + position: relative; + box-sizing: border-box; + flex: 1 1 0; + min-width: 0; + height: 100%; + outline: none; + cursor: pointer; +} + +[data-page="stats"] [data-slot="lab-usage-column"]::before { + position: absolute; + inset: 0; + z-index: 1; + background: transparent; + content: ""; + pointer-events: none; + transition: background 120ms ease; +} + +[data-page="stats"] [data-slot="lab-usage-column"][data-active="true"]::before, +[data-page="stats"] [data-slot="lab-usage-column"]:focus-visible::before { + background: var(--lab-usage-band); + box-shadow: + inset var(--lab-usage-column-gutter) 0 0 var(--stats-bg), + inset calc(var(--lab-usage-column-gutter) * -1) 0 0 var(--stats-bg); +} + +[data-page="stats"] [data-slot="lab-usage-token-bar"] { + position: absolute; + right: var(--lab-usage-column-gutter); + bottom: 0; + left: var(--lab-usage-column-gutter); + z-index: 2; + height: var(--lab-usage-token-height); + background: var(--lab-usage-bar); + transition: + background 120ms ease, + opacity 120ms ease; +} + +[data-page="stats"] [data-slot="lab-usage-column"][data-active="true"] [data-slot="lab-usage-token-bar"] { + background: var(--lab-usage-active); +} + +[data-page="stats"] [data-slot="lab-usage-column"][data-muted="true"] [data-slot="lab-usage-token-bar"] { + opacity: 0.64; +} + +[data-page="stats"] [data-slot="lab-usage-months"] { + position: relative; + height: 14px; + color: var(--stats-muted); + font-size: 11px; + font-weight: 500; + line-height: 14px; +} + +[data-page="stats"] [data-slot="lab-usage-months"] span { + position: absolute; + top: 0; + left: var(--lab-usage-month-left); + transform: translateX(-50%); +} + +[data-page="stats"] [data-slot="lab-usage-months"] span[data-align="start"] { + transform: none; +} + +[data-page="stats"] [data-slot="lab-usage-months"] span[data-align="end"] { + transform: translateX(-100%); +} + +[data-page="stats"] [data-component="model-usage-chart"][data-variant="lab-usage"] [data-component="chart-tooltip"] { + z-index: 6; + top: clamp(12px, calc(var(--lab-usage-user-y) * 1% - 38px), calc(100% - 92px)); + width: 192px; + min-width: 192px; + background: var(--stats-bg); +} + +[data-page="stats"] + [data-component="model-usage-chart"][data-variant="lab-usage"] + [data-component="chart-tooltip"][data-placement="right"] { + right: auto; + left: calc(var(--lab-usage-tooltip-right) + 8px); +} + +[data-page="stats"] + [data-component="model-usage-chart"][data-variant="lab-usage"] + [data-component="chart-tooltip"][data-placement="left"] { + right: calc(100% - var(--lab-usage-tooltip-left) + 8px); + left: auto; +} + +[data-page="stats"] [data-component="model-usage-chart"][data-variant="lab-usage"] [data-component="chart-tooltip"] p { + height: 16px; + margin: 8px 0; +} + +[data-page="stats"] + [data-component="model-usage-chart"][data-variant="lab-usage"] + [data-component="chart-tooltip"] + i[data-kind="tokens"] { + background: var(--lab-usage-active); +} + [data-page="stats"] [data-component="model-peer-list"] { display: grid; gap: 8px; @@ -3669,6 +3852,15 @@ --stats-logo-stroke: #211e1e; } +[data-page="stats"][data-theme="dark"] [data-component="model-usage-chart"][data-variant="lab-usage"], +:root[data-stats-theme="dark"] + [data-page="stats"]:not([data-theme="light"]) + [data-component="model-usage-chart"][data-variant="lab-usage"] { + --lab-usage-bar: #3a3a3a; + --lab-usage-line: #a3a3a3; + --lab-usage-band: #242424; +} + [data-page="stats"][data-theme="dark"] [data-component="chart-tooltip"], :root[data-stats-theme="dark"] [data-page="stats"]:not([data-theme="light"]) [data-component="chart-tooltip"] { background: #242424f2; @@ -3820,6 +4012,14 @@ --stats-logo-stroke: #211e1e; } + :root:not([data-stats-theme="light"]) + [data-page="stats"]:not([data-theme="light"]) + [data-component="model-usage-chart"][data-variant="lab-usage"] { + --lab-usage-bar: #3a3a3a; + --lab-usage-line: #a3a3a3; + --lab-usage-band: #242424; + } + :root:not([data-stats-theme="light"]) [data-page="stats"]:not([data-theme="light"]) [data-component="chart-tooltip"] { background: #242424f2; } @@ -3915,6 +4115,21 @@ } } +[data-page="stats"][data-theme="dark"] + [data-component="model-usage-chart"][data-variant="lab-usage"] + [data-component="chart-tooltip"], +:root[data-stats-theme="dark"] + [data-page="stats"]:not([data-theme="light"]) + [data-component="model-usage-chart"][data-variant="lab-usage"] + [data-component="chart-tooltip"], +:root:not([data-stats-theme="light"]) + [data-page="stats"]:not([data-theme="light"]) + [data-component="model-usage-chart"][data-variant="lab-usage"] + [data-component="chart-tooltip"], +[data-page="stats"] [data-component="model-usage-chart"][data-variant="lab-usage"] [data-component="chart-tooltip"] { + background: var(--stats-bg); +} + @media (min-width: 48rem) { [data-page="stats"] [data-slot="header-button"][data-variant="neutral"] { display: inline-flex; @@ -4372,6 +4587,35 @@ left: 0; } + [data-page="stats"] [data-component="model-usage-chart"][data-variant="lab-usage"] { + --model-usage-mobile-bar-width: 20px; + --model-usage-mobile-edge-space: 48px; + --model-usage-mobile-track-width: calc( + var(--model-usage-count) * var(--model-usage-mobile-bar-width) + var(--model-usage-mobile-edge-space) + ); + --lab-usage-line-bottom: 64px; + grid-template-rows: minmax(0, 360px) 14px; + gap: 24px; + height: 398px; + } + + [data-page="stats"] [data-component="model-usage-chart"][data-variant="lab-usage"] [data-slot="lab-usage-plot"], + [data-page="stats"] [data-component="model-usage-chart"][data-variant="lab-usage"] [data-slot="lab-usage-months"] { + direction: ltr; + width: var(--model-usage-mobile-track-width); + min-width: var(--model-usage-mobile-track-width); + } + + [data-page="stats"] [data-component="model-usage-chart"][data-variant="lab-usage"] [data-slot="lab-usage-column"] { + flex: 0 0 var(--model-usage-mobile-bar-width); + } + + [data-page="stats"] [data-component="model-usage-chart"][data-variant="lab-usage"] [data-component="chart-tooltip"] { + top: auto; + width: auto; + min-width: 0; + } + [data-page="stats"] [data-component="model-peer-list"] a { grid-template-columns: 30px minmax(0, 1fr) minmax(64px, auto); }