feat(stats): add i18n support (#33693)
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import "../index.css"
|
||||
import { Link, Meta, Title } from "@solidjs/meta"
|
||||
import { Meta, Title } from "@solidjs/meta"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { geoEquirectangular, geoPath } from "d3-geo"
|
||||
import { scaleSqrt } from "d3-scale"
|
||||
@@ -19,6 +19,10 @@ import { createMemo, createSignal, For, onMount, Show, type JSX } from "solid-js
|
||||
import { getRequestEvent } from "solid-js/web"
|
||||
import type { FeatureCollection, GeometryObject, GeoJsonProperties } from "geojson"
|
||||
import type { GeometryCollection, Topology } from "topojson-specification"
|
||||
import { LocaleLinks } from "../../component/locale-links"
|
||||
import { useI18n } from "../../context/i18n"
|
||||
import { useLanguage } from "../../context/language"
|
||||
import { localizedUrl } from "../../lib/language"
|
||||
import {
|
||||
findModelCatalogEntry,
|
||||
formatCatalogLabName,
|
||||
@@ -38,30 +42,9 @@ import {
|
||||
type ThemePreference,
|
||||
} from "../stats-shell"
|
||||
|
||||
const statsCanonicalBaseUrl = "https://opencode.ai/data/"
|
||||
const statsUnfurlPath = "banner.png"
|
||||
const statsUnfurlAlt = "OpenCode Data wordmark on a dark patterned background"
|
||||
const statsUnfurlUrl = new URL(statsUnfurlPath, statsCanonicalBaseUrl).toString()
|
||||
const modelHeaderLinks: readonly HeaderLink[] = [
|
||||
{ href: "#overview", label: "Overview" },
|
||||
{ href: "#usage", label: "Usage" },
|
||||
{ href: "#users", label: "Users" },
|
||||
{ href: "#efficiency", label: "Efficiency" },
|
||||
{ href: "#geo-breakdown", label: "Geo Breakdown" },
|
||||
{ href: "#peers", label: "Peers" },
|
||||
]
|
||||
const modelFooterLinks: readonly HeaderLink[] = [
|
||||
{ href: import.meta.env.BASE_URL, label: "Data Home" },
|
||||
{ href: `${import.meta.env.BASE_URL}#top-models`, label: "Top Models" },
|
||||
{ href: `${import.meta.env.BASE_URL}#leaderboard`, label: "Leaderboard" },
|
||||
{ href: `${import.meta.env.BASE_URL}#session-cost`, label: "Session Cost" },
|
||||
{ href: `${import.meta.env.BASE_URL}#token-cost`, label: "Token Cost" },
|
||||
{ href: `${import.meta.env.BASE_URL}#market-share`, label: "Market Share" },
|
||||
{ href: `${import.meta.env.BASE_URL}#geo-breakdown`, label: "Geo Breakdown" },
|
||||
]
|
||||
const geoMapWidth = 960
|
||||
const geoMapHeight = 430
|
||||
const countryDisplayNames = new Intl.DisplayNames(["en"], { type: "region" })
|
||||
|
||||
type IsoCountryCode = readonly [string, string, string]
|
||||
type WorldCountryProperties = GeoJsonProperties & { name?: string }
|
||||
@@ -100,6 +83,8 @@ const getModelData = query(async (lab: string, model: string) => {
|
||||
}, "getStatsModelData")
|
||||
|
||||
export default function StatsModel() {
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
const event = getRequestEvent()
|
||||
event?.response.headers.set("Cache-Control", "public, max-age=60, s-maxage=300, stale-while-revalidate=86400")
|
||||
const params = useParams()
|
||||
@@ -119,19 +104,34 @@ export default function StatsModel() {
|
||||
})
|
||||
const githubStars = createAsync(() => getGitHubStars())
|
||||
const [themePreference, setThemePreference] = createSignal<ThemePreference>("system")
|
||||
const modelName = createMemo(() => catalogEntry()?.name ?? stats()?.model ?? modelParam() ?? "Model")
|
||||
const modelName = createMemo(() => catalogEntry()?.name ?? stats()?.model ?? modelParam() ?? i18n.t("model.fallback"))
|
||||
const labName = createMemo(() => formatCatalogLabName(catalogEntry()?.lab ?? stats()?.provider ?? labParam()))
|
||||
const modelTitle = createMemo(() => `${modelName()} Usage, Cost & Rank | OpenCode Data`)
|
||||
const modelTitle = createMemo(() => i18n.t("model.title", { model: modelName() }))
|
||||
const modelDescription = createMemo(
|
||||
() =>
|
||||
`View ${modelName()} OpenCode Go usage data, including token volume, weekly rank, token mix, costs, cache ratio, sessions, geo breakdowns, and peer models.`,
|
||||
() => i18n.t("model.description", { model: modelName() }),
|
||||
)
|
||||
const modelUrl = createMemo(() =>
|
||||
new URL(
|
||||
catalogEntry()?.id ?? [labParam(), stats()?.slug ?? modelParam()].filter((part) => part.length > 0).join("/"),
|
||||
statsCanonicalBaseUrl,
|
||||
).toString(),
|
||||
const modelPath = createMemo(
|
||||
() => `/data/${catalogEntry()?.id ?? [labParam(), stats()?.slug ?? modelParam()].filter((part) => part.length > 0).join("/")}`,
|
||||
)
|
||||
const modelUrl = createMemo(() => localizedUrl(language.locale(), modelPath()))
|
||||
const statsUnfurlUrl = new URL(statsUnfurlPath, localizedUrl("en", "/data/")).toString()
|
||||
const modelHeaderLinks = createMemo<readonly HeaderLink[]>(() => [
|
||||
{ href: "#overview", label: i18n.t("nav.overview") },
|
||||
{ href: "#usage", label: i18n.t("nav.usage") },
|
||||
{ href: "#users", label: i18n.t("nav.users") },
|
||||
{ href: "#efficiency", label: i18n.t("nav.efficiency") },
|
||||
{ href: "#geo-breakdown", label: i18n.t("nav.geoBreakdown") },
|
||||
{ href: "#peers", label: i18n.t("nav.peers") },
|
||||
])
|
||||
const modelFooterLinks = createMemo<readonly HeaderLink[]>(() => [
|
||||
{ href: import.meta.env.BASE_URL, label: i18n.t("nav.dataHome") },
|
||||
{ href: `${import.meta.env.BASE_URL}#top-models`, label: i18n.t("nav.topModels") },
|
||||
{ href: `${import.meta.env.BASE_URL}#session-cost`, label: i18n.t("nav.sessionCost") },
|
||||
{ href: `${import.meta.env.BASE_URL}#token-cost`, label: i18n.t("nav.tokenCost") },
|
||||
{ href: `${import.meta.env.BASE_URL}#cache-ratio`, label: i18n.t("nav.cacheRatio") },
|
||||
{ href: `${import.meta.env.BASE_URL}#market-share`, label: i18n.t("nav.marketShare") },
|
||||
{ href: `${import.meta.env.BASE_URL}#geo-breakdown`, label: i18n.t("nav.geoBreakdown") },
|
||||
])
|
||||
const updateThemePreference = (preference: ThemePreference) => {
|
||||
applyThemePreference(preference)
|
||||
setThemePreference(preference)
|
||||
@@ -151,7 +151,7 @@ export default function StatsModel() {
|
||||
<main data-page="stats" data-theme={themePreference()}>
|
||||
<Title>{modelTitle()}</Title>
|
||||
<Meta name="description" content={modelDescription()} />
|
||||
<Link rel="canonical" href={modelUrl()} />
|
||||
<LocaleLinks path={modelPath()} />
|
||||
<Meta property="og:type" content="website" />
|
||||
<Meta property="og:site_name" content="OpenCode" />
|
||||
<Meta property="og:title" content={modelTitle()} />
|
||||
@@ -161,13 +161,13 @@ export default function StatsModel() {
|
||||
<Meta property="og:image:type" content="image/png" />
|
||||
<Meta property="og:image:width" content="1200" />
|
||||
<Meta property="og:image:height" content="630" />
|
||||
<Meta property="og:image:alt" content={statsUnfurlAlt} />
|
||||
<Meta property="og:image:alt" content={i18n.t("app.unfurlAlt")} />
|
||||
<Meta name="twitter:card" content="summary_large_image" />
|
||||
<Meta name="twitter:title" content={modelTitle()} />
|
||||
<Meta name="twitter:description" content={modelDescription()} />
|
||||
<Meta name="twitter:image" content={statsUnfurlUrl} />
|
||||
<Meta name="twitter:image:alt" content={statsUnfurlAlt} />
|
||||
<Header githubStars={githubStars() ?? "150K"} links={modelHeaderLinks} brandHref={import.meta.env.BASE_URL} />
|
||||
<Meta name="twitter:image:alt" content={i18n.t("app.unfurlAlt")} />
|
||||
<Header githubStars={githubStars() ?? "150K"} links={modelHeaderLinks()} brandHref={import.meta.env.BASE_URL} />
|
||||
<div data-component="container">
|
||||
<div data-component="content">
|
||||
<Show when={catalogEntry() || stats() !== undefined} fallback={<ModelLoading />}>
|
||||
@@ -187,7 +187,7 @@ export default function StatsModel() {
|
||||
<Footer
|
||||
themePreference={themePreference()}
|
||||
onThemePreferenceChange={updateThemePreference}
|
||||
links={modelFooterLinks}
|
||||
links={modelFooterLinks()}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
@@ -195,85 +195,91 @@ export default function StatsModel() {
|
||||
}
|
||||
|
||||
function ModelLoading() {
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<>
|
||||
<section id="overview" data-section="model-hero">
|
||||
<div data-slot="model-hero-grid">
|
||||
<div data-slot="model-hero-copy">
|
||||
<a data-slot="model-back-link" href={import.meta.env.BASE_URL}>
|
||||
Data
|
||||
<a data-slot="model-back-link" href={language.route(import.meta.env.BASE_URL)}>
|
||||
{i18n.t("footer.modelData")}
|
||||
</a>
|
||||
<h1>Model Data</h1>
|
||||
<p>Reading model aggregates from model_stat.</p>
|
||||
<h1>{i18n.t("model.loadingTitle")}</h1>
|
||||
<p>{i18n.t("model.loadingDescription")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section data-section="model-panel">
|
||||
<ModelEmptyState title="Loading model data" description="Reading the model profile." />
|
||||
<ModelEmptyState title={i18n.t("model.loadingTitle")} description={i18n.t("model.loadingProfile")} />
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ModelNotFound(props: { lab: string; model: string }) {
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<>
|
||||
<section id="overview" data-section="model-hero">
|
||||
<div data-slot="model-hero-grid">
|
||||
<div data-slot="model-hero-copy">
|
||||
<a data-slot="model-back-link" href={import.meta.env.BASE_URL}>
|
||||
Data
|
||||
<a data-slot="model-back-link" href={language.route(import.meta.env.BASE_URL)}>
|
||||
{i18n.t("footer.modelData")}
|
||||
</a>
|
||||
<h1>{props.model || "Model"}</h1>
|
||||
<p>No model facts or model_stat rows matched {props.lab ? `${props.lab}/${props.model}` : props.model}.</p>
|
||||
<h1>{props.model || i18n.t("model.fallback")}</h1>
|
||||
<p>{i18n.t("model.noMatched", { id: props.lab ? `${props.lab}/${props.model}` : props.model })}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section data-section="model-panel">
|
||||
<ModelEmptyState title="No model data" description="Try opening a model from the leaderboard." />
|
||||
<ModelEmptyState title={i18n.t("model.noDataTitle")} description={i18n.t("model.noDataDescription")} />
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ModelHero(props: { data: StatsModelData | null; catalog: ModelCatalogEntry | null; labName: string }) {
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
const labId = () => props.catalog?.lab ?? props.data?.provider ?? props.labName
|
||||
const modelId = () => props.catalog?.id ?? props.data?.model ?? "Model"
|
||||
const modelId = () => props.catalog?.id ?? props.data?.model ?? i18n.t("model.fallback")
|
||||
const weights = () => props.catalog?.weights[0]
|
||||
return (
|
||||
<section id="overview" data-section="model-hero">
|
||||
<a data-slot="model-back-link" href={import.meta.env.BASE_URL}>
|
||||
Data
|
||||
<a data-slot="model-back-link" href={language.route(import.meta.env.BASE_URL)}>
|
||||
{i18n.t("footer.modelData")}
|
||||
</a>
|
||||
<div data-slot="model-hero-grid">
|
||||
<div data-slot="model-hero-copy">
|
||||
<div data-slot="model-hero-tags">
|
||||
<a data-slot="hero-meta" href={`${import.meta.env.BASE_URL}${providerSlug(labId())}`}>
|
||||
<a data-slot="hero-meta" href={language.route(`${import.meta.env.BASE_URL}${providerSlug(labId())}`)}>
|
||||
<ProviderIcon aria-hidden="true" id={getProviderIconId(labId())} />
|
||||
<span>{props.labName}</span>
|
||||
</a>
|
||||
<span data-slot="model-id-tag">{modelId()}</span>
|
||||
</div>
|
||||
<h1>{props.catalog?.name ?? props.data?.model ?? "Model"}</h1>
|
||||
<h1>{props.catalog?.name ?? props.data?.model ?? i18n.t("model.fallback")}</h1>
|
||||
<Show
|
||||
when={props.data}
|
||||
fallback={
|
||||
<p>Model facts from the shared model index. OpenCode Go usage appears once this model has activity.</p>
|
||||
<p>{i18n.t("model.catalogFallback")}</p>
|
||||
}
|
||||
>
|
||||
{(data) => (
|
||||
<p>
|
||||
{data().rank === null
|
||||
? "Unranked across last week's OpenCode Go usage"
|
||||
: `Ranked #${data().rank} across last week's OpenCode Go usage`}{" "}
|
||||
with {formatPercent(data().tokenShare)} of observed 2M volume.
|
||||
? i18n.t("model.unranked")
|
||||
: i18n.t("model.ranked", { rank: data().rank ?? "" })}{" "}
|
||||
{i18n.t("model.observedVolume", { share: formatPercent(data().tokenShare) })}
|
||||
</p>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={props.catalog?.openWeights && weights()}>
|
||||
{(weight) => (
|
||||
<a data-slot="model-weight-link" href={weight().url} target="_blank" rel="noopener noreferrer">
|
||||
Model weights: {weight().label}
|
||||
{i18n.t("model.weights", { label: weight().label })}
|
||||
</a>
|
||||
)}
|
||||
</Show>
|
||||
@@ -281,9 +287,9 @@ function ModelHero(props: { data: StatsModelData | null; catalog: ModelCatalogEn
|
||||
<Show when={props.data} fallback={<ModelCatalogCallout catalog={props.catalog} />}>
|
||||
{(data) => (
|
||||
<div data-component="model-rank-panel">
|
||||
<span>7D Rank</span>
|
||||
<span>{i18n.t("model.rank")}</span>
|
||||
<strong>{data().rank === null ? "—" : `#${data().rank}`}</strong>
|
||||
<p>{formatModelRankMoveLabel(data())}</p>
|
||||
<p>{formatModelRankMoveLabel(data(), i18n)}</p>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
@@ -295,24 +301,38 @@ function ModelHero(props: { data: StatsModelData | null; catalog: ModelCatalogEn
|
||||
}
|
||||
|
||||
function ModelCatalogCallout(props: { catalog: ModelCatalogEntry | null }) {
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<div data-component="model-rank-panel">
|
||||
<span>Model Profile</span>
|
||||
<strong>{props.catalog?.releaseDate ? formatCatalogDate(props.catalog.releaseDate) : "Listed"}</strong>
|
||||
<p>No OpenCode Go usage in the current data window.</p>
|
||||
<span>{i18n.t("model.profile")}</span>
|
||||
<strong>
|
||||
{props.catalog?.releaseDate
|
||||
? formatCatalogDate(props.catalog.releaseDate, language.tag(language.locale()), i18n.t("home.unknown"))
|
||||
: i18n.t("model.listed")}
|
||||
</strong>
|
||||
<p>{i18n.t("model.noCurrentUsage")}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ModelCatalogPanel(props: { data: ModelCatalogEntry }) {
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<aside data-component="model-catalog" aria-label="Model facts">
|
||||
<aside data-component="model-catalog" aria-label={i18n.t("model.facts")}>
|
||||
<div data-slot="model-catalog-grid">
|
||||
<CatalogDatum label="Context" value={formatCatalogLimit(props.data.limit?.context)} />
|
||||
<CatalogDatum label="Output" value={formatCatalogLimit(props.data.limit?.output)} />
|
||||
<CatalogDatum label="Knowledge" value={formatCatalogDate(props.data.knowledge)} />
|
||||
<CatalogDatum label="Release" value={formatCatalogDate(props.data.releaseDate)} />
|
||||
<CatalogDatum label="Inputs" value={formatCatalogModalities(props.data.modalities.input)} />
|
||||
<CatalogDatum label={i18n.t("model.context")} value={formatCatalogLimit(props.data.limit?.context, i18n.t("home.unknown"))} />
|
||||
<CatalogDatum label={i18n.t("model.output")} value={formatCatalogLimit(props.data.limit?.output, i18n.t("home.unknown"))} />
|
||||
<CatalogDatum
|
||||
label={i18n.t("model.knowledge")}
|
||||
value={formatCatalogDate(props.data.knowledge, language.tag(language.locale()), i18n.t("home.unknown"))}
|
||||
/>
|
||||
<CatalogDatum
|
||||
label={i18n.t("model.release")}
|
||||
value={formatCatalogDate(props.data.releaseDate, language.tag(language.locale()), i18n.t("home.unknown"))}
|
||||
/>
|
||||
<CatalogDatum label={i18n.t("model.inputs")} value={formatCatalogModalities(props.data.modalities.input, i18n)} />
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
@@ -328,29 +348,30 @@ function CatalogDatum(props: { label: string; value: string }) {
|
||||
}
|
||||
|
||||
function ModelOverview(props: { data: StatsModelData | null }) {
|
||||
const i18n = useI18n()
|
||||
return (
|
||||
<section data-section="model-panel">
|
||||
<SectionTitle title="Overview" description="Recent OpenCode Go tokens, unique users, and market position." />
|
||||
<SectionTitle title={i18n.t("nav.overview")} description={i18n.t("model.overviewDescription")} />
|
||||
<Show
|
||||
when={props.data}
|
||||
fallback={
|
||||
<ModelEmptyState title="No usage summary" description="This model has no OpenCode Go usage rows yet." />
|
||||
<ModelEmptyState title={i18n.t("model.noSummaryTitle")} description={i18n.t("model.noSummaryDescription")} />
|
||||
}
|
||||
>
|
||||
{(data) => (
|
||||
<div data-component="model-metric-grid">
|
||||
<MetricCard label="Tokens" value={formatTokens(data().totals.tokens)} detail="last two months" />
|
||||
<MetricCard label="Unique Users" value={formatUsers(data().totals.uniqueUsers)} detail="last two months" />
|
||||
<MetricCard label="Sessions" value={formatInteger(data().totals.sessions)} detail="completed sessions" />
|
||||
<MetricCard label={i18n.t("model.tokens")} value={formatTokens(data().totals.tokens)} detail={i18n.t("model.lastTwoMonths")} />
|
||||
<MetricCard label={i18n.t("model.uniqueUsers")} value={formatUsers(data().totals.uniqueUsers)} detail={i18n.t("model.lastTwoMonths")} />
|
||||
<MetricCard label={i18n.t("model.sessions")} value={formatInteger(data().totals.sessions)} detail={i18n.t("model.completedSessions")} />
|
||||
<MetricCard
|
||||
label="Token Share"
|
||||
label={i18n.t("model.tokenShare")}
|
||||
value={formatPercent(data().tokenShare)}
|
||||
detail={`${data().totalModels} models`}
|
||||
detail={i18n.t("model.totalModels", { count: data().totalModels })}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Momentum"
|
||||
label={i18n.t("model.momentum")}
|
||||
value={formatChange(data().tokenChange)}
|
||||
detail="vs previous window"
|
||||
detail={i18n.t("model.vsPreviousWindow")}
|
||||
state={data().tokenChange < 0 ? "negative" : "positive"}
|
||||
/>
|
||||
</div>
|
||||
@@ -361,39 +382,42 @@ function ModelOverview(props: { data: StatsModelData | null }) {
|
||||
}
|
||||
|
||||
function ModelUsageSection(props: { data: ModelUsagePoint[] }) {
|
||||
const i18n = useI18n()
|
||||
return (
|
||||
<section id="usage" data-section="model-panel">
|
||||
<SectionTitle title="Usage" description="Daily OpenCode Go token volume over the recent two-month window." />
|
||||
<SectionTitle title={i18n.t("nav.usage")} description={i18n.t("model.usageDescription")} />
|
||||
<Show
|
||||
when={props.data.some((item) => item.tokens > 0)}
|
||||
fallback={<ModelEmptyState title="No usage" description="No usage landed in the current window." />}
|
||||
fallback={<ModelEmptyState title={i18n.t("model.noUsageTitle")} description={i18n.t("model.noUsageDescription")} />}
|
||||
>
|
||||
<ModelColumnChart data={props.data} metric="tokens" ariaLabel="Daily token usage chart" />
|
||||
<ModelColumnChart data={props.data} metric="tokens" ariaLabel={i18n.t("model.dailyTokenChart")} />
|
||||
</Show>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ModelUsersSection(props: { data: ModelUsagePoint[] }) {
|
||||
const i18n = useI18n()
|
||||
return (
|
||||
<section id="users" data-section="model-panel">
|
||||
<SectionTitle
|
||||
title="Unique Users"
|
||||
description="Daily unique OpenCode Go users over the recent two-month window."
|
||||
title={i18n.t("model.uniqueUsers")}
|
||||
description={i18n.t("model.usersDescription")}
|
||||
/>
|
||||
<Show
|
||||
when={props.data.some((item) => item.users > 0)}
|
||||
fallback={
|
||||
<ModelEmptyState title="No user data" description="No user-bearing rows landed in the current window." />
|
||||
<ModelEmptyState title={i18n.t("model.noUsersTitle")} description={i18n.t("model.noUsersDescription")} />
|
||||
}
|
||||
>
|
||||
<ModelColumnChart data={props.data} metric="users" ariaLabel="Daily unique user chart" />
|
||||
<ModelColumnChart data={props.data} metric="users" ariaLabel={i18n.t("model.dailyUserChart")} />
|
||||
</Show>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ModelColumnChart(props: { data: ModelUsagePoint[]; metric: "tokens" | "users"; ariaLabel: string }) {
|
||||
const i18n = useI18n()
|
||||
const [activeIndex, setActiveIndex] = createSignal<number>()
|
||||
const max = createMemo(() => Math.max(0, ...props.data.map((item) => modelUsageMetricValue(item, props.metric))) || 1)
|
||||
const activePoint = createMemo(() => {
|
||||
@@ -437,7 +461,7 @@ function ModelColumnChart(props: { data: ModelUsagePoint[]; metric: "tokens" | "
|
||||
data-slot="model-usage-column"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${point.date} ${formatModelUsageValue(point, props.metric)} ${modelUsageLabel(props.metric)}`}
|
||||
aria-label={`${point.date} ${formatModelUsageValue(point, props.metric)} ${modelUsageLabel(props.metric, i18n)}`}
|
||||
data-active={activeIndex() === index() ? "true" : undefined}
|
||||
data-muted={activeIndex() !== undefined && activeIndex() !== index() ? "true" : undefined}
|
||||
onPointerDown={(event) => {
|
||||
@@ -474,12 +498,12 @@ function ModelColumnChart(props: { data: ModelUsagePoint[]; metric: "tokens" | "
|
||||
>
|
||||
<strong>{active().date}</strong>
|
||||
<span>
|
||||
{formatModelUsageValue(active(), props.metric)} {modelUsageLabel(props.metric)}
|
||||
{formatModelUsageValue(active(), props.metric)} {modelUsageLabel(props.metric, i18n)}
|
||||
</span>
|
||||
<div data-slot="tooltip-divider" />
|
||||
<p>
|
||||
<span data-slot="tooltip-label">
|
||||
<i /> Daily {modelUsageLabel(props.metric)}
|
||||
<i /> {i18n.t("chart.daily")} {modelUsageLabel(props.metric, i18n)}
|
||||
</span>
|
||||
<b>{formatModelUsageValue(active(), props.metric)}</b>
|
||||
</p>
|
||||
@@ -504,42 +528,43 @@ function formatModelUsageValue(point: ModelUsagePoint, metric: "tokens" | "users
|
||||
return formatTokens(point.tokens)
|
||||
}
|
||||
|
||||
function modelUsageLabel(metric: "tokens" | "users") {
|
||||
if (metric === "users") return "users"
|
||||
return "tokens"
|
||||
function modelUsageLabel(metric: "tokens" | "users", i18n: ReturnType<typeof useI18n>) {
|
||||
if (metric === "users") return i18n.t("format.users")
|
||||
return i18n.t("format.tokens")
|
||||
}
|
||||
|
||||
function ModelEfficiencySection(props: { data: StatsModelData | null; catalog: ModelCatalogEntry | null }) {
|
||||
const i18n = useI18n()
|
||||
return (
|
||||
<section id="efficiency" data-section="model-panel">
|
||||
<SectionTitle title="Efficiency" description="Cost, cache behavior, and average OpenCode Go session shape." />
|
||||
<SectionTitle title={i18n.t("nav.efficiency")} description={i18n.t("model.efficiencyDescription")} />
|
||||
<Show
|
||||
when={props.data}
|
||||
fallback={
|
||||
<ModelEmptyState title="No efficiency data" description="Efficiency data appears after usage lands." />
|
||||
<ModelEmptyState title={i18n.t("model.noEfficiencyTitle")} description={i18n.t("model.noEfficiencyDescription")} />
|
||||
}
|
||||
>
|
||||
{(data) => (
|
||||
<div data-component="model-metric-grid" data-variant="dense">
|
||||
<MetricCard label="Cost" value={formatMoney(data().totals.cost)} detail="total spend" />
|
||||
<MetricCard label={i18n.t("model.cost")} value={formatMoney(data().totals.cost)} detail={i18n.t("model.totalSpend")} />
|
||||
<MetricCard
|
||||
label="Cost / 1M"
|
||||
label={i18n.t("model.costPerMillion")}
|
||||
value={
|
||||
props.catalog?.cost ? formatCatalogPrice(props.catalog.cost) : formatMoney(data().totals.costPerMillion)
|
||||
}
|
||||
detail={props.catalog?.cost ? "input / output" : "observed all tokens"}
|
||||
detail={props.catalog?.cost ? i18n.t("model.inputOutput") : i18n.t("model.observedTokens")}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Cost / Session"
|
||||
label={i18n.t("model.costSession")}
|
||||
value={formatSessionCost(data().totals.costPerSession)}
|
||||
detail="average"
|
||||
detail={i18n.t("model.average")}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Tokens / Session"
|
||||
label={i18n.t("model.tokensSession")}
|
||||
value={formatTokens(data().totals.tokensPerSession)}
|
||||
detail="average"
|
||||
detail={i18n.t("model.average")}
|
||||
/>
|
||||
<MetricCard label="Cache Ratio" value={formatPercent(data().totals.cacheRatio)} detail="input tokens" />
|
||||
<MetricCard label={i18n.t("model.cacheRatio")} value={formatPercent(data().totals.cacheRatio)} detail={i18n.t("model.inputTokens")} />
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
@@ -548,6 +573,8 @@ function ModelEfficiencySection(props: { data: StatsModelData | null; catalog: M
|
||||
}
|
||||
|
||||
function ModelGeoBreakdownSection(props: { data: Record<UsageRange, CountryEntry[]> }) {
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
const [activeCountry, setActiveCountry] = createSignal<string>()
|
||||
const data = createMemo(() => props.data["2M"])
|
||||
const countryById = createMemo(
|
||||
@@ -572,11 +599,11 @@ function ModelGeoBreakdownSection(props: { data: Record<UsageRange, CountryEntry
|
||||
setActiveCountry(undefined)
|
||||
}}
|
||||
>
|
||||
<SectionTitle title="Geo Breakdown" description="OpenCode Go model tokens used by country." />
|
||||
<SectionTitle title={i18n.t("nav.geoBreakdown")} description={i18n.t("model.geoDescription")} />
|
||||
<Show
|
||||
when={data().length > 0}
|
||||
fallback={
|
||||
<ModelEmptyState title="No geo data" description="No OpenCode Go geo_stat rows matched this model." />
|
||||
<ModelEmptyState title={i18n.t("model.noGeoTitle")} description={i18n.t("model.noGeoDescription")} />
|
||||
}
|
||||
>
|
||||
<div data-component="geo-breakdown">
|
||||
@@ -591,7 +618,7 @@ function ModelGeoBreakdownSection(props: { data: Record<UsageRange, CountryEntry
|
||||
{(country) => (
|
||||
<div data-slot="geo-active-country">
|
||||
<span>#{String(country().rank).padStart(2, "0")}</span>
|
||||
<strong>{formatCountryName(country().country)}</strong>
|
||||
<strong>{formatCountryName(country().country, language.tag(language.locale()), i18n)}</strong>
|
||||
<p>
|
||||
<b>{formatGeoTokens(country().tokens)}</b>
|
||||
<em>{formatGeoShare(country().share)}</em>
|
||||
@@ -618,6 +645,7 @@ function GeoWorldMap(props: {
|
||||
maxTokens: number
|
||||
onActiveCountryChange: (country: string | undefined) => void
|
||||
}) {
|
||||
const i18n = useI18n()
|
||||
const opacityScale = createMemo(() => scaleSqrt().domain([0, props.maxTokens]).range([0.26, 0.96]).clamp(true))
|
||||
const countryOpacity = (country: CountryEntry | undefined) => {
|
||||
if (!country) return 0
|
||||
@@ -631,9 +659,9 @@ function GeoWorldMap(props: {
|
||||
data-component="geo-world-map"
|
||||
viewBox={`0 0 ${geoMapWidth} ${geoMapHeight}`}
|
||||
role="img"
|
||||
aria-label="World map of model token usage by country"
|
||||
aria-label={i18n.t("model.worldMap")}
|
||||
>
|
||||
<title>Geo Breakdown map</title>
|
||||
<title>{i18n.t("home.geoMapTitle")}</title>
|
||||
<g data-slot="geo-countries">
|
||||
<For each={worldCountryPaths}>
|
||||
{(country) => {
|
||||
@@ -703,6 +731,8 @@ function GeoCountryList(props: {
|
||||
maxTokens: number
|
||||
onActiveCountryChange: (country: string | undefined) => void
|
||||
}) {
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
const opacityScale = createMemo(() => scaleSqrt().domain([0, props.maxTokens]).range([0.26, 0.96]).clamp(true))
|
||||
|
||||
return (
|
||||
@@ -714,16 +744,14 @@ function GeoCountryList(props: {
|
||||
type="button"
|
||||
data-active={props.activeCountry === country.country ? "true" : undefined}
|
||||
style={{ "--geo-row-opacity": String(opacityScale()(country.tokens)) } as JSX.CSSProperties}
|
||||
aria-label={`${formatCountryName(country.country)} ${formatGeoTokens(country.tokens)} ${formatGeoShare(
|
||||
country.share,
|
||||
)}`}
|
||||
aria-label={`${formatCountryName(country.country, language.tag(language.locale()), i18n)} ${formatGeoTokens(country.tokens)} ${formatGeoShare(country.share)}`}
|
||||
onClick={() => props.onActiveCountryChange(country.country)}
|
||||
onPointerEnter={() => props.onActiveCountryChange(country.country)}
|
||||
onFocus={() => props.onActiveCountryChange(country.country)}
|
||||
>
|
||||
<span>{String(country.rank).padStart(2, "0")}</span>
|
||||
<i />
|
||||
<strong>{formatCountryName(country.country)}</strong>
|
||||
<strong>{formatCountryName(country.country, language.tag(language.locale()), i18n)}</strong>
|
||||
<em>{formatGeoTokens(country.tokens)}</em>
|
||||
<b>{formatGeoShare(country.share)}</b>
|
||||
</button>
|
||||
@@ -735,12 +763,13 @@ function GeoCountryList(props: {
|
||||
}
|
||||
|
||||
function ModelPeersSection(props: { data: StatsModelData | null }) {
|
||||
const i18n = useI18n()
|
||||
return (
|
||||
<section id="peers" data-section="model-panel">
|
||||
<SectionTitle title="Peers" description="Nearby models by recent OpenCode Go token volume." />
|
||||
<SectionTitle title={i18n.t("nav.peers")} description={i18n.t("model.peersDescription")} />
|
||||
<Show
|
||||
when={props.data?.peers.length}
|
||||
fallback={<ModelEmptyState title="No peers" description="Peer rankings appear after usage lands." />}
|
||||
fallback={<ModelEmptyState title={i18n.t("model.noPeersTitle")} description={i18n.t("model.noPeersDescription")} />}
|
||||
>
|
||||
<ol data-component="model-peer-list">
|
||||
<For each={props.data?.peers ?? []}>
|
||||
@@ -763,10 +792,11 @@ function MetricCard(props: { label: string; value: string; detail: string; state
|
||||
}
|
||||
|
||||
function PeerRow(props: { peer: ModelPeerEntry; active: boolean }) {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<li>
|
||||
<a
|
||||
href={`${import.meta.env.BASE_URL}${providerSlug(props.peer.provider)}/${props.peer.slug}`}
|
||||
href={language.route(`${import.meta.env.BASE_URL}${providerSlug(props.peer.provider)}/${props.peer.slug}`)}
|
||||
data-active={props.active ? "true" : undefined}
|
||||
>
|
||||
<span>{String(props.peer.rank).padStart(2, "0")}</span>
|
||||
@@ -828,11 +858,11 @@ function geoCountryMarker(country: (typeof worldCountries.features)[number]) {
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
function formatCountryName(country: string) {
|
||||
function formatCountryName(country: string, locale: string, i18n: ReturnType<typeof useI18n>) {
|
||||
const code = country.toUpperCase()
|
||||
if (code === "ZZ") return "Unknown"
|
||||
if (code === "ZZ") return i18n.t("home.unknown")
|
||||
if (!countryNumericId(code)) return code
|
||||
return countryDisplayNames.of(code) ?? code
|
||||
return new Intl.DisplayNames([locale], { type: "region" }).of(code) ?? code
|
||||
}
|
||||
|
||||
function formatGeoTokens(value: number) {
|
||||
@@ -862,13 +892,13 @@ function formatRankMove(previousRank: number, rank: number) {
|
||||
const change = previousRank - rank
|
||||
if (change > 0) return `+${change}`
|
||||
if (change < 0) return `${change}`
|
||||
return "Even"
|
||||
return "0"
|
||||
}
|
||||
|
||||
function formatModelRankMoveLabel(data: StatsModelData) {
|
||||
if (data.rank === null) return "No usage last week"
|
||||
if (data.previousRank === null) return "New this week"
|
||||
return `${formatRankMove(data.previousRank, data.rank)} vs previous week`
|
||||
function formatModelRankMoveLabel(data: StatsModelData, i18n: ReturnType<typeof useI18n>) {
|
||||
if (data.rank === null) return i18n.t("model.noUsageLastWeek")
|
||||
if (data.previousRank === null) return i18n.t("model.newThisWeek")
|
||||
return i18n.t("model.vsPreviousWeek", { change: formatRankMove(data.previousRank, data.rank) })
|
||||
}
|
||||
|
||||
function formatTokens(value: number) {
|
||||
@@ -918,28 +948,28 @@ function formatChange(value: number) {
|
||||
return `${value}%`
|
||||
}
|
||||
|
||||
function formatCatalogLimit(value: number | undefined) {
|
||||
return value === undefined ? "Unknown" : formatTokens(value)
|
||||
function formatCatalogLimit(value: number | undefined, unknown: string) {
|
||||
return value === undefined ? unknown : formatTokens(value)
|
||||
}
|
||||
|
||||
function formatCatalogModalities(value: string[]) {
|
||||
if (value.length === 0) return "Unknown"
|
||||
return value.map(formatCatalogModality).join(", ")
|
||||
function formatCatalogModalities(value: string[], i18n: ReturnType<typeof useI18n>) {
|
||||
if (value.length === 0) return i18n.t("home.unknown")
|
||||
return value.map((item) => formatCatalogModality(item, i18n)).join(", ")
|
||||
}
|
||||
|
||||
function formatCatalogModality(value: string) {
|
||||
if (value === "pdf") return "PDF"
|
||||
function formatCatalogModality(value: string, i18n: ReturnType<typeof useI18n>) {
|
||||
if (value === "pdf") return i18n.t("model.pdf")
|
||||
return value.charAt(0).toUpperCase() + value.slice(1)
|
||||
}
|
||||
|
||||
function formatCatalogDate(value: string | undefined) {
|
||||
if (!value) return "Unknown"
|
||||
function formatCatalogDate(value: string | undefined, locale: string, unknown: string) {
|
||||
if (!value) return unknown
|
||||
const match = /^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?$/.exec(value)
|
||||
if (!match) return value
|
||||
const year = Number(match[1])
|
||||
const month = match[2] ? Number(match[2]) - 1 : 0
|
||||
const day = match[3] ? Number(match[3]) : 1
|
||||
return new Intl.DateTimeFormat("en", {
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
month: match[2] ? "short" : undefined,
|
||||
day: match[3] ? "numeric" : undefined,
|
||||
year: "numeric",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import "../index.css"
|
||||
import { Link, Meta, Title } from "@solidjs/meta"
|
||||
import { Meta, Title } from "@solidjs/meta"
|
||||
import {
|
||||
getStatsLabData,
|
||||
type LabUsageModelEntry,
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
import { createAsync, query, useParams } from "@solidjs/router"
|
||||
import { createMemo, createSignal, 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"
|
||||
import { useLanguage } from "../../context/language"
|
||||
import { localizedUrl } from "../../lib/language"
|
||||
import {
|
||||
findModelCatalogLab,
|
||||
formatCatalogLabName,
|
||||
@@ -28,21 +32,7 @@ import {
|
||||
type ThemePreference,
|
||||
} from "../stats-shell"
|
||||
|
||||
const statsCanonicalBaseUrl = "https://opencode.ai/data/"
|
||||
const statsUnfurlPath = "banner.png"
|
||||
const statsUnfurlAlt = "OpenCode Data wordmark on a dark patterned background"
|
||||
const statsUnfurlUrl = new URL(statsUnfurlPath, statsCanonicalBaseUrl).toString()
|
||||
const labHeaderLinks: readonly HeaderLink[] = [
|
||||
{ href: "#overview", label: "Overview" },
|
||||
{ href: "#usage", label: "Usage" },
|
||||
{ href: "#models", label: "Models" },
|
||||
]
|
||||
const labFooterLinks: readonly HeaderLink[] = [
|
||||
{ href: import.meta.env.BASE_URL, label: "Data Home" },
|
||||
{ href: `${import.meta.env.BASE_URL}#top-models`, label: "Top Models" },
|
||||
{ href: `${import.meta.env.BASE_URL}#market-share`, label: "Market Share" },
|
||||
{ href: `${import.meta.env.BASE_URL}#geo-breakdown`, label: "Geo Breakdown" },
|
||||
]
|
||||
|
||||
const getLabData = query(async (lab: string) => {
|
||||
"use server"
|
||||
@@ -50,6 +40,8 @@ const getLabData = query(async (lab: string) => {
|
||||
}, "getStatsLabData")
|
||||
|
||||
export default function StatsLab() {
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
const event = getRequestEvent()
|
||||
event?.response.headers.set("Cache-Control", "public, max-age=60, s-maxage=300, stale-while-revalidate=86400")
|
||||
const params = useParams()
|
||||
@@ -69,12 +61,24 @@ export default function StatsLab() {
|
||||
const githubStars = createAsync(() => getGitHubStars())
|
||||
const [themePreference, setThemePreference] = createSignal<ThemePreference>("system")
|
||||
const labName = createMemo(() => lab()?.name ?? formatCatalogLabName(labParam()))
|
||||
const labTitle = createMemo(() => `${labName()} AI Model Usage & Rankings | OpenCode Data`)
|
||||
const labTitle = createMemo(() => i18n.t("lab.title", { lab: labName() }))
|
||||
const labDescription = createMemo(
|
||||
() =>
|
||||
`Compare ${labName()} models used in OpenCode Go, including token usage, model rankings, context windows, release dates, costs, and model-specific data.`,
|
||||
() => i18n.t("lab.description", { lab: labName() }),
|
||||
)
|
||||
const labUrl = createMemo(() => new URL(lab()?.id ?? labParam(), statsCanonicalBaseUrl).toString())
|
||||
const labPath = createMemo(() => `/data/${lab()?.id ?? labParam()}`)
|
||||
const labUrl = createMemo(() => localizedUrl(language.locale(), labPath()))
|
||||
const statsUnfurlUrl = new URL(statsUnfurlPath, localizedUrl("en", "/data/")).toString()
|
||||
const labHeaderLinks = createMemo<readonly HeaderLink[]>(() => [
|
||||
{ href: "#overview", label: i18n.t("nav.overview") },
|
||||
{ href: "#usage", label: i18n.t("nav.usage") },
|
||||
{ href: "#models", label: i18n.t("nav.models") },
|
||||
])
|
||||
const labFooterLinks = createMemo<readonly HeaderLink[]>(() => [
|
||||
{ href: import.meta.env.BASE_URL, label: i18n.t("nav.dataHome") },
|
||||
{ href: `${import.meta.env.BASE_URL}#top-models`, label: i18n.t("nav.topModels") },
|
||||
{ href: `${import.meta.env.BASE_URL}#market-share`, label: i18n.t("nav.marketShare") },
|
||||
{ href: `${import.meta.env.BASE_URL}#geo-breakdown`, label: i18n.t("nav.geoBreakdown") },
|
||||
])
|
||||
const updateThemePreference = (preference: ThemePreference) => {
|
||||
applyThemePreference(preference)
|
||||
setThemePreference(preference)
|
||||
@@ -94,7 +98,7 @@ export default function StatsLab() {
|
||||
<main data-page="stats" data-theme={themePreference()}>
|
||||
<Title>{labTitle()}</Title>
|
||||
<Meta name="description" content={labDescription()} />
|
||||
<Link rel="canonical" href={labUrl()} />
|
||||
<LocaleLinks path={labPath()} />
|
||||
<Meta property="og:type" content="website" />
|
||||
<Meta property="og:site_name" content="OpenCode" />
|
||||
<Meta property="og:title" content={labTitle()} />
|
||||
@@ -104,13 +108,13 @@ export default function StatsLab() {
|
||||
<Meta property="og:image:type" content="image/png" />
|
||||
<Meta property="og:image:width" content="1200" />
|
||||
<Meta property="og:image:height" content="630" />
|
||||
<Meta property="og:image:alt" content={statsUnfurlAlt} />
|
||||
<Meta property="og:image:alt" content={i18n.t("app.unfurlAlt")} />
|
||||
<Meta name="twitter:card" content="summary_large_image" />
|
||||
<Meta name="twitter:title" content={labTitle()} />
|
||||
<Meta name="twitter:description" content={labDescription()} />
|
||||
<Meta name="twitter:image" content={statsUnfurlUrl} />
|
||||
<Meta name="twitter:image:alt" content={statsUnfurlAlt} />
|
||||
<Header githubStars={githubStars() ?? "150K"} links={labHeaderLinks} brandHref={import.meta.env.BASE_URL} />
|
||||
<Meta name="twitter:image:alt" content={i18n.t("app.unfurlAlt")} />
|
||||
<Header githubStars={githubStars() ?? "150K"} links={labHeaderLinks()} brandHref={import.meta.env.BASE_URL} />
|
||||
<div data-component="container">
|
||||
<div data-component="content">
|
||||
<Show when={catalog() !== undefined} fallback={<LabLoading />}>
|
||||
@@ -128,7 +132,7 @@ export default function StatsLab() {
|
||||
<Footer
|
||||
themePreference={themePreference()}
|
||||
onThemePreferenceChange={updateThemePreference}
|
||||
links={labFooterLinks}
|
||||
links={labFooterLinks()}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
@@ -136,15 +140,17 @@ export default function StatsLab() {
|
||||
}
|
||||
|
||||
function LabLoading() {
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<section id="overview" data-section="lab-hero">
|
||||
<div data-slot="model-hero-grid">
|
||||
<div data-slot="model-hero-copy">
|
||||
<a data-slot="model-back-link" href={import.meta.env.BASE_URL}>
|
||||
Data
|
||||
<a data-slot="model-back-link" href={language.route(import.meta.env.BASE_URL)}>
|
||||
{i18n.t("footer.modelData")}
|
||||
</a>
|
||||
<h1>Model Lab</h1>
|
||||
<p>Reading model availability and recent OpenCode usage.</p>
|
||||
<h1>{i18n.t("lab.loadingTitle")}</h1>
|
||||
<p>{i18n.t("lab.loadingDescription")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -152,15 +158,17 @@ function LabLoading() {
|
||||
}
|
||||
|
||||
function LabNotFound(props: { lab: string }) {
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<section id="overview" data-section="lab-hero">
|
||||
<div data-slot="model-hero-grid">
|
||||
<div data-slot="model-hero-copy">
|
||||
<a data-slot="model-back-link" href={import.meta.env.BASE_URL}>
|
||||
Data
|
||||
<a data-slot="model-back-link" href={language.route(import.meta.env.BASE_URL)}>
|
||||
{i18n.t("footer.modelData")}
|
||||
</a>
|
||||
<h1>{formatCatalogLabName(props.lab)}</h1>
|
||||
<p>No models matched this lab.</p>
|
||||
<p>{i18n.t("lab.notFound")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -168,6 +176,8 @@ function LabNotFound(props: { lab: string }) {
|
||||
}
|
||||
|
||||
function LabHero(props: { lab: ModelCatalogLab; stats: StatsLabData | null }) {
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
const latest = createMemo(
|
||||
() =>
|
||||
props.lab.models
|
||||
@@ -179,28 +189,31 @@ function LabHero(props: { lab: ModelCatalogLab; stats: StatsLabData | null }) {
|
||||
|
||||
return (
|
||||
<section id="overview" data-section="lab-hero">
|
||||
<a data-slot="model-back-link" href={import.meta.env.BASE_URL}>
|
||||
Data
|
||||
<a data-slot="model-back-link" href={language.route(import.meta.env.BASE_URL)}>
|
||||
{i18n.t("footer.modelData")}
|
||||
</a>
|
||||
<div data-slot="model-hero-grid">
|
||||
<div data-slot="model-hero-copy">
|
||||
<h1>{props.lab.name}</h1>
|
||||
<div data-slot="model-hero-pattern" aria-hidden="true" />
|
||||
<p>
|
||||
Explore {props.lab.models.length} {props.lab.name} models used in OpenCode
|
||||
<Show when={featuredModels().length > 0}> including {formatList(featuredModels())}</Show>. Compare recent
|
||||
token usage, context windows, release dates, and model-specific data.
|
||||
{i18n.t("lab.heroPrefix", { count: props.lab.models.length, lab: props.lab.name })}
|
||||
<Show when={featuredModels().length > 0}>
|
||||
{" "}
|
||||
{i18n.t("lab.heroIncluding", { models: formatList(featuredModels(), language.tag(language.locale())) })}
|
||||
</Show>
|
||||
. {i18n.t("lab.heroSuffix")}
|
||||
</p>
|
||||
</div>
|
||||
<div data-component="model-rank-panel">
|
||||
<span>Tokens Processed</span>
|
||||
<strong>{props.stats ? formatTokens(props.stats.totals.tokens) : "Pending"}</strong>
|
||||
<span>{i18n.t("lab.tokensProcessed")}</span>
|
||||
<strong>{props.stats ? formatTokens(props.stats.totals.tokens) : i18n.t("lab.pending")}</strong>
|
||||
<p>
|
||||
{props.stats
|
||||
? `${formatPercent(props.stats.tokenShare)} of recent OpenCode usage`
|
||||
? i18n.t("lab.shareOfUsage", { share: formatPercent(props.stats.tokenShare) })
|
||||
: latest()
|
||||
? `Latest release ${formatCatalogDate(latest())}`
|
||||
: "Usage appears after model activity lands"}
|
||||
? i18n.t("lab.latestRelease", { date: formatCatalogDate(latest(), language.tag(language.locale()), i18n.t("home.unknown")) })
|
||||
: i18n.t("lab.usageAfterActivity")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -209,6 +222,7 @@ function LabHero(props: { lab: ModelCatalogLab; stats: StatsLabData | null }) {
|
||||
}
|
||||
|
||||
function LabUsageSection(props: { lab: ModelCatalogLab; data: StatsLabData | null }) {
|
||||
const i18n = useI18n()
|
||||
const [activeIndex, setActiveIndex] = createSignal<number>()
|
||||
const usage = createMemo(() => props.data?.usage ?? [])
|
||||
const max = createMemo(() => Math.max(0, ...usage().map((item) => item.tokens)) || 1)
|
||||
@@ -221,15 +235,15 @@ function LabUsageSection(props: { lab: ModelCatalogLab; data: StatsLabData | nul
|
||||
return (
|
||||
<section id="usage" data-section="model-panel">
|
||||
<p data-slot="section-title">
|
||||
<strong>{props.lab.name} token usage.</strong>{" "}
|
||||
<span>Daily OpenCode token volume over the last two months.</span>
|
||||
<strong>{i18n.t("lab.usageTitle", { lab: props.lab.name })}.</strong>{" "}
|
||||
<span>{i18n.t("lab.usageDescription")}</span>
|
||||
</p>
|
||||
<Show
|
||||
when={usage().some((item) => item.tokens > 0)}
|
||||
fallback={
|
||||
<LabEmptyState
|
||||
title="No usage yet"
|
||||
description="Recent token usage appears here once this lab has activity."
|
||||
title={i18n.t("lab.noUsageTitle")}
|
||||
description={i18n.t("lab.noUsageDescription")}
|
||||
/>
|
||||
}
|
||||
>
|
||||
@@ -237,7 +251,7 @@ function LabUsageSection(props: { lab: ModelCatalogLab; data: StatsLabData | nul
|
||||
data-component="model-usage-chart"
|
||||
data-dense-labels={isLabUsageDense(usage().length) ? "true" : undefined}
|
||||
role="img"
|
||||
aria-label={`${props.lab.name} daily token usage chart`}
|
||||
aria-label={i18n.t("lab.dailyTokenChart", { lab: props.lab.name })}
|
||||
style={{ "--model-usage-count": usage().length } as JSX.CSSProperties}
|
||||
onPointerLeave={(event) => {
|
||||
if (event.pointerType === "touch") return
|
||||
@@ -266,7 +280,7 @@ function LabUsageSection(props: { lab: ModelCatalogLab; data: StatsLabData | nul
|
||||
data-slot="model-usage-column"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${point.date} ${formatTokens(point.tokens)} tokens`}
|
||||
aria-label={`${point.date} ${formatTokens(point.tokens)} ${i18n.t("lab.tokens")}`}
|
||||
data-active={activeIndex() === index() ? "true" : undefined}
|
||||
data-muted={activeIndex() !== undefined && activeIndex() !== index() ? "true" : undefined}
|
||||
onPointerDown={(event) => {
|
||||
@@ -298,11 +312,13 @@ function LabUsageSection(props: { lab: ModelCatalogLab; data: StatsLabData | nul
|
||||
data-placement={index() > usage().length * 0.62 ? "left" : "right"}
|
||||
>
|
||||
<strong>{active().date}</strong>
|
||||
<span>{formatTokens(active().tokens)} tokens</span>
|
||||
<span>
|
||||
{formatTokens(active().tokens)} {i18n.t("lab.tokens")}
|
||||
</span>
|
||||
<div data-slot="tooltip-divider" />
|
||||
<p>
|
||||
<span data-slot="tooltip-label">
|
||||
<i /> Daily tokens
|
||||
<i /> {i18n.t("lab.dailyTokens")}
|
||||
</span>
|
||||
<b>{formatTokens(active().tokens)}</b>
|
||||
</p>
|
||||
@@ -320,11 +336,13 @@ function LabUsageSection(props: { lab: ModelCatalogLab; data: StatsLabData | nul
|
||||
}
|
||||
|
||||
function LabModelsSection(props: { lab: ModelCatalogLab; usage: LabUsageModelEntry[] }) {
|
||||
const i18n = useI18n()
|
||||
const usageBySlug = createMemo(() => new Map(props.usage.map((item) => [item.slug, item])))
|
||||
return (
|
||||
<section id="models" data-section="model-panel">
|
||||
<p data-slot="section-title">
|
||||
<strong>{props.lab.name} models.</strong> <span>Recent usage and limits.</span>
|
||||
<strong>{i18n.t("lab.modelsTitle", { lab: props.lab.name })}.</strong>{" "}
|
||||
<span>{i18n.t("lab.recentUsageAndLimits")}</span>
|
||||
</p>
|
||||
<div data-component="lab-model-grid">
|
||||
<For each={props.lab.models}>
|
||||
@@ -336,29 +354,31 @@ function LabModelsSection(props: { lab: ModelCatalogLab; usage: LabUsageModelEnt
|
||||
}
|
||||
|
||||
function LabModelCard(props: { model: ModelCatalogEntry; usage: LabUsageModelEntry | undefined }) {
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<a data-component="lab-model-card" href={`${import.meta.env.BASE_URL}${props.model.id}`}>
|
||||
<a data-component="lab-model-card" href={language.route(`${import.meta.env.BASE_URL}${props.model.id}`)}>
|
||||
<strong>{props.model.name}</strong>
|
||||
<div data-slot="lab-model-card-meta">
|
||||
<p>
|
||||
<b>Usage</b>
|
||||
<b>{i18n.t("lab.usage")}</b>
|
||||
<em>{props.usage ? formatTokens(props.usage.tokens) : "—"}</em>
|
||||
</p>
|
||||
<p>
|
||||
<b>Share</b>
|
||||
<b>{i18n.t("lab.share")}</b>
|
||||
<em>{props.usage ? formatPercent(props.usage.share) : "—"}</em>
|
||||
</p>
|
||||
<p>
|
||||
<b>Context</b>
|
||||
<em>{formatCatalogLimit(props.model.limit?.context)}</em>
|
||||
<b>{i18n.t("model.context")}</b>
|
||||
<em>{formatCatalogLimit(props.model.limit?.context, i18n.t("home.unknown"))}</em>
|
||||
</p>
|
||||
<p>
|
||||
<b>Output</b>
|
||||
<em>{formatCatalogLimit(props.model.limit?.output)}</em>
|
||||
<b>{i18n.t("model.output")}</b>
|
||||
<em>{formatCatalogLimit(props.model.limit?.output, i18n.t("home.unknown"))}</em>
|
||||
</p>
|
||||
<p>
|
||||
<b>Release</b>
|
||||
<em>{formatCatalogDate(props.model.releaseDate)}</em>
|
||||
<b>{i18n.t("model.release")}</b>
|
||||
<em>{formatCatalogDate(props.model.releaseDate, language.tag(language.locale()), i18n.t("home.unknown"))}</em>
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
@@ -374,18 +394,18 @@ function LabEmptyState(props: { title: string; description: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function formatCatalogLimit(value: number | undefined) {
|
||||
return value === undefined ? "Unknown" : formatTokens(value)
|
||||
function formatCatalogLimit(value: number | undefined, unknown: string) {
|
||||
return value === undefined ? unknown : formatTokens(value)
|
||||
}
|
||||
|
||||
function formatCatalogDate(value: string | undefined) {
|
||||
if (!value) return "Unknown"
|
||||
function formatCatalogDate(value: string | undefined, locale: string, unknown: string) {
|
||||
if (!value) return unknown
|
||||
const match = /^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?$/.exec(value)
|
||||
if (!match) return value
|
||||
const year = Number(match[1])
|
||||
const month = match[2] ? Number(match[2]) - 1 : 0
|
||||
const day = match[3] ? Number(match[3]) : 1
|
||||
return new Intl.DateTimeFormat("en", {
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
month: match[2] ? "short" : undefined,
|
||||
day: match[3] ? "numeric" : undefined,
|
||||
year: "numeric",
|
||||
@@ -393,10 +413,9 @@ function formatCatalogDate(value: string | undefined) {
|
||||
}).format(new Date(Date.UTC(year, month, day)))
|
||||
}
|
||||
|
||||
function formatList(values: string[]) {
|
||||
function formatList(values: string[], locale = "en") {
|
||||
if (values.length <= 1) return values[0] ?? ""
|
||||
if (values.length === 2) return `${values[0]} and ${values[1]}`
|
||||
return `${values.slice(0, -1).join(", ")}, and ${values[values.length - 1]}`
|
||||
return new Intl.ListFormat(locale, { style: "long", type: "conjunction" }).format(values)
|
||||
}
|
||||
|
||||
function formatPercent(value: number) {
|
||||
|
||||
@@ -661,6 +661,38 @@
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="footer-controls"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="footer-language"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
color: var(--stats-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="footer-language"] select {
|
||||
height: 24px;
|
||||
min-width: 136px;
|
||||
margin: 0;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--stats-line);
|
||||
border-radius: 0;
|
||||
background: var(--stats-bg);
|
||||
color: var(--stats-text);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="theme-toggle"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -3737,6 +3769,11 @@
|
||||
[data-page="stats"] [data-slot="footer-bottom"] {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="footer-controls"] {
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 47.999rem) {
|
||||
|
||||
@@ -27,6 +27,10 @@ import { getRequestEvent } from "solid-js/web"
|
||||
import type { FeatureCollection, GeometryObject, GeoJsonProperties } from "geojson"
|
||||
import type { GeometryCollection, Topology } from "topojson-specification"
|
||||
import { runStatsEffect } from "../stats-runtime"
|
||||
import { LocaleLinks } from "../component/locale-links"
|
||||
import { useI18n } from "../context/i18n"
|
||||
import { useLanguage } from "../context/language"
|
||||
import { localizedUrl } from "../lib/language"
|
||||
import { findModelCatalogEntry, getModelCatalog, type ModelCatalog } from "./model-catalog"
|
||||
import {
|
||||
applyThemePreference,
|
||||
@@ -42,19 +46,7 @@ import {
|
||||
const products = ["All Users", "Zen", "Go"] as const
|
||||
const tokenProducts = ["Zen", "Go"] as const
|
||||
const ranges = ["1D", "1W", "2W", "1M", "2M"] as const
|
||||
const rangeLabels: Record<UsageRange, string> = {
|
||||
"1D": "1 Day",
|
||||
"1W": "1 Week",
|
||||
"2W": "2 Weeks",
|
||||
"1M": "1 Month",
|
||||
"2M": "2 Months",
|
||||
}
|
||||
const statsHomeTitle = "AI Model Usage Rankings | OpenCode Data"
|
||||
const statsHomeDescription =
|
||||
"Explore OpenCode Go usage across AI models, including token volume, rankings, market share, token pricing, session cost, cache ratio, and geo breakdowns."
|
||||
const statsHomeFallbackUrl = "https://opencode.ai/data/"
|
||||
const statsUnfurlPath = "banner.jpg"
|
||||
const statsUnfurlAlt = "OpenCode Data wordmark on a dark patterned background"
|
||||
const usageColors = [
|
||||
"#ed6aff",
|
||||
"#a684ff",
|
||||
@@ -71,7 +63,6 @@ const usageColors = [
|
||||
const marketColors = ["#ed6aff", "#a684ff", "#7c86ff", "#51a2ff", "#00d3f2", "#00d5be", "#00bc7d", "#9ae600", "#ffb900"]
|
||||
const geoMapWidth = 960
|
||||
const geoMapHeight = 430
|
||||
const countryDisplayNames = new Intl.DisplayNames(["en"], { type: "region" })
|
||||
|
||||
type UsageProduct = (typeof products)[number]
|
||||
type TokenProduct = (typeof tokenProducts)[number]
|
||||
@@ -80,6 +71,20 @@ type IsoCountryCode = readonly [string, string, string]
|
||||
type WorldCountryProperties = GeoJsonProperties & { name?: string }
|
||||
type WorldTopology = Topology<{ countries: GeometryCollection<WorldCountryProperties> }>
|
||||
|
||||
function productLabel(product: UsageProduct | TokenProduct, i18n: ReturnType<typeof useI18n>) {
|
||||
if (product === "All Users") return i18n.t("product.allUsers")
|
||||
if (product === "Zen") return i18n.t("product.zen")
|
||||
return i18n.t("product.go")
|
||||
}
|
||||
|
||||
function rangeLabel(range: UsageRange, i18n: ReturnType<typeof useI18n>) {
|
||||
if (range === "1D") return i18n.t("range.1D")
|
||||
if (range === "1W") return i18n.t("range.1W")
|
||||
if (range === "2W") return i18n.t("range.2W")
|
||||
if (range === "1M") return i18n.t("range.1M")
|
||||
return i18n.t("range.2M")
|
||||
}
|
||||
|
||||
const countryNumericIds = new Map(
|
||||
(JSON.parse(countryCodesSource) as IsoCountryCode[]).map((country) => [country[0], country[2]] as const),
|
||||
)
|
||||
@@ -113,13 +118,12 @@ const getData = query(async () => {
|
||||
}, "getStatsHomeData")
|
||||
|
||||
export default function StatsHome() {
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
const event = getRequestEvent()
|
||||
event?.response.headers.set("Cache-Control", "public, max-age=60, s-maxage=300, stale-while-revalidate=86400")
|
||||
const statsHomeUrl = getStatsHomeUrl(
|
||||
import.meta.env.BASE_URL,
|
||||
event?.request.url ?? (typeof window === "undefined" ? statsHomeFallbackUrl : window.location.href),
|
||||
)
|
||||
const statsUnfurlUrl = new URL(statsUnfurlPath, statsHomeUrl).toString()
|
||||
const statsHomeUrl = localizedUrl(language.locale(), "/data/")
|
||||
const statsUnfurlUrl = new URL(statsUnfurlPath, localizedUrl("en", "/data/")).toString()
|
||||
const data = createAsync(() => getData())
|
||||
const catalog = createAsync(() => getModelCatalog())
|
||||
const githubStars = createAsync(() => getGitHubStars())
|
||||
@@ -141,24 +145,24 @@ export default function StatsHome() {
|
||||
|
||||
return (
|
||||
<main data-page="stats" data-theme={themePreference()}>
|
||||
<Title>{statsHomeTitle}</Title>
|
||||
<Meta name="description" content={statsHomeDescription} />
|
||||
<Link rel="canonical" href={statsHomeUrl} />
|
||||
<Title>{i18n.t("app.title")}</Title>
|
||||
<Meta name="description" content={i18n.t("app.description")} />
|
||||
<LocaleLinks path="/data/" />
|
||||
<Meta property="og:type" content="website" />
|
||||
<Meta property="og:site_name" content="OpenCode" />
|
||||
<Meta property="og:title" content={statsHomeTitle} />
|
||||
<Meta property="og:description" content={statsHomeDescription} />
|
||||
<Meta property="og:title" content={i18n.t("app.title")} />
|
||||
<Meta property="og:description" content={i18n.t("app.description")} />
|
||||
<Meta property="og:url" content={statsHomeUrl} />
|
||||
<Meta property="og:image" content={statsUnfurlUrl} />
|
||||
<Meta property="og:image:type" content="image/jpeg" />
|
||||
<Meta property="og:image:width" content="1200" />
|
||||
<Meta property="og:image:height" content="630" />
|
||||
<Meta property="og:image:alt" content={statsUnfurlAlt} />
|
||||
<Meta property="og:image:alt" content={i18n.t("app.unfurlAlt")} />
|
||||
<Meta name="twitter:card" content="summary_large_image" />
|
||||
<Meta name="twitter:title" content={statsHomeTitle} />
|
||||
<Meta name="twitter:description" content={statsHomeDescription} />
|
||||
<Meta name="twitter:title" content={i18n.t("app.title")} />
|
||||
<Meta name="twitter:description" content={i18n.t("app.description")} />
|
||||
<Meta name="twitter:image" content={statsUnfurlUrl} />
|
||||
<Meta name="twitter:image:alt" content={statsUnfurlAlt} />
|
||||
<Meta name="twitter:image:alt" content={i18n.t("app.unfurlAlt")} />
|
||||
<Link rel="preload" href={ibmPlexMonoRegularLatin1} as="font" type="font/woff2" crossorigin="anonymous" />
|
||||
<Link rel="preload" href={ibmPlexMonoMediumLatin1} as="font" type="font/woff2" crossorigin="anonymous" />
|
||||
<Link rel="preload" href={ibmPlexMonoSemiBoldLatin1} as="font" type="font/woff2" crossorigin="anonymous" />
|
||||
@@ -187,23 +191,20 @@ export default function StatsHome() {
|
||||
)
|
||||
}
|
||||
|
||||
function getStatsHomeUrl(base: string, requestUrl: string) {
|
||||
const url = new URL(base, requestUrl)
|
||||
if (url.hostname === "stats.opencode.ai") return "https://opencode.ai/data/"
|
||||
if (url.hostname === "stats.dev.opencode.ai") return "https://dev.opencode.ai/data/"
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
function Hero(props: { updatedAt: string | null }) {
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
const [timeZone, setTimeZone] = createSignal("UTC")
|
||||
const [previousTimeZone, setPreviousTimeZone] = createSignal("UTC")
|
||||
const [isTicking, setIsTicking] = createSignal(false)
|
||||
const updatedAtParts = (timeZone: string) =>
|
||||
props.updatedAt ? formatUpdatedAtParts(props.updatedAt, timeZone) : { date: "No rows yet", time: "" }
|
||||
props.updatedAt
|
||||
? formatUpdatedAtParts(props.updatedAt, timeZone, language.tag(language.locale()), i18n.t("home.justNow"))
|
||||
: { date: i18n.t("home.noRows"), time: "" }
|
||||
const previousUpdatedAt = createMemo(() => updatedAtParts(previousTimeZone()))
|
||||
const currentUpdatedAt = createMemo(() => updatedAtParts(timeZone()))
|
||||
const currentUpdatedLabel = createMemo(() =>
|
||||
props.updatedAt ? `Updated ${formatUpdatedAtLabel(currentUpdatedAt())}` : "No rows yet",
|
||||
props.updatedAt ? `${i18n.t("home.updated")} ${formatUpdatedAtLabel(currentUpdatedAt())}` : i18n.t("home.noRows"),
|
||||
)
|
||||
const isDateTicking = createMemo(() => isTicking() && previousUpdatedAt().date !== currentUpdatedAt().date)
|
||||
const isTimeTicking = createMemo(() => isTicking() && previousUpdatedAt().time !== currentUpdatedAt().time)
|
||||
@@ -213,7 +214,9 @@ function Hero(props: { updatedAt: string | null }) {
|
||||
const nextTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"
|
||||
if (nextTimeZone === "UTC") return
|
||||
if (
|
||||
formatUpdatedAtLabel(formatUpdatedAtParts(props.updatedAt, nextTimeZone)) ===
|
||||
formatUpdatedAtLabel(
|
||||
formatUpdatedAtParts(props.updatedAt, nextTimeZone, language.tag(language.locale()), i18n.t("home.justNow")),
|
||||
) ===
|
||||
formatUpdatedAtLabel(updatedAtParts("UTC"))
|
||||
)
|
||||
return
|
||||
@@ -248,7 +251,7 @@ function Hero(props: { updatedAt: string | null }) {
|
||||
{props.updatedAt ? (
|
||||
<>
|
||||
<span data-slot="hero-meta-label" aria-hidden="true">
|
||||
Updated
|
||||
{i18n.t("home.updated")}
|
||||
</span>
|
||||
<span data-slot="hero-meta-time" aria-hidden="true">
|
||||
<HeroMetaTickerPart
|
||||
@@ -265,16 +268,13 @@ function Hero(props: { updatedAt: string | null }) {
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span data-slot="hero-meta-empty">No rows yet</span>
|
||||
<span data-slot="hero-meta-empty">{i18n.t("home.noRows")}</span>
|
||||
)}
|
||||
</p>
|
||||
<div data-slot="hero-canvas">
|
||||
<div data-slot="hero-pattern" aria-hidden="true" />
|
||||
<h1>Model Data</h1>
|
||||
<p data-slot="hero-copy">
|
||||
See which models are winning real usage, how the mix <br data-slot="hero-copy-break" />
|
||||
shifts over time, and where momentum is moving each week.
|
||||
</p>
|
||||
<h1>{i18n.t("footer.modelData")}</h1>
|
||||
<p data-slot="hero-copy">{i18n.t("home.heroCopy")}</p>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
@@ -292,11 +292,12 @@ function HeroMetaTickerPart(props: { previous: string; current: string; ticking:
|
||||
}
|
||||
|
||||
function StatsLoading() {
|
||||
const i18n = useI18n()
|
||||
return (
|
||||
<>
|
||||
<Hero updatedAt={null} />
|
||||
<ChartSection title="Usage">
|
||||
<EmptyState title="Loading data" description="Reading model aggregates from model_stat." />
|
||||
<ChartSection title={i18n.t("home.usageTitle")}>
|
||||
<EmptyState title={i18n.t("home.loadingTitle")} description={i18n.t("home.loadingDescription")} />
|
||||
</ChartSection>
|
||||
</>
|
||||
)
|
||||
@@ -332,9 +333,10 @@ function SectionTitle(props: { title: string; description: string }) {
|
||||
}
|
||||
|
||||
function SectionBridge(props: { label: string; href: string }) {
|
||||
const i18n = useI18n()
|
||||
return (
|
||||
<a data-component="section-bridge" href={props.href}>
|
||||
<span>LEAN MORE</span>
|
||||
<span>{i18n.t("bridge.learnMore")}</span>
|
||||
<i />
|
||||
<strong>{props.label}</strong>
|
||||
<b>▸</b>
|
||||
@@ -351,16 +353,16 @@ function EmptyState(props: { title: string; description: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function formatUpdatedAtParts(value: string, timeZone: string) {
|
||||
function formatUpdatedAtParts(value: string, timeZone: string, locale: string, fallback: string) {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return { date: "just now", time: "" }
|
||||
if (Number.isNaN(date.getTime())) return { date: fallback, time: "" }
|
||||
return {
|
||||
date: new Intl.DateTimeFormat("en", {
|
||||
date: new Intl.DateTimeFormat(locale, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone,
|
||||
}).format(date),
|
||||
time: new Intl.DateTimeFormat("en", {
|
||||
time: new Intl.DateTimeFormat(locale, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZone,
|
||||
@@ -375,6 +377,7 @@ function formatUpdatedAtLabel(value: { date: string; time: string }) {
|
||||
}
|
||||
|
||||
function TopModelsSection(props: { data: StatsHomeData["usage"]; leaderboard: StatsHomeData["leaderboard"] }) {
|
||||
const i18n = useI18n()
|
||||
const [product, setProduct] = createSignal<UsageProduct>("Go")
|
||||
const [range, setRange] = createSignal<UsageRange>("2M")
|
||||
const [sheet, setSheet] = createSignal<"product" | "range">()
|
||||
@@ -403,11 +406,11 @@ function TopModelsSection(props: { data: StatsHomeData["usage"]; leaderboard: St
|
||||
return (
|
||||
<section id="top-models" data-section="top-models">
|
||||
<h2 data-slot="top-models-title">
|
||||
<strong>Top models.</strong> <span>Usage of models across OpenCode Go.</span>
|
||||
<strong>{i18n.t("nav.topModels")}.</strong> <span>{i18n.t("home.topModelsDescription")}</span>
|
||||
</h2>
|
||||
<Show
|
||||
when={data().some((item) => usageTotal(item) > 0)}
|
||||
fallback={<EmptyState title="No usage data" description="No model_stat rows matched this product and range." />}
|
||||
fallback={<EmptyState title={i18n.t("home.noUsageTitle")} description={i18n.t("home.noUsageDescription")} />}
|
||||
>
|
||||
<TopModelsChart
|
||||
data={data()}
|
||||
@@ -419,7 +422,7 @@ function TopModelsSection(props: { data: StatsHomeData["usage"]; leaderboard: St
|
||||
<Show
|
||||
when={leaderboard().length > 0}
|
||||
fallback={
|
||||
<EmptyState title="No leaderboard data" description="No model_stat rows matched this product and range." />
|
||||
<EmptyState title={i18n.t("home.noLeaderboardTitle")} description={i18n.t("home.noLeaderboardDescription")} />
|
||||
}
|
||||
>
|
||||
<Leaderboard data={leaderboard()} activeModel={activeModel()} onActiveModelChange={setActiveModel} />
|
||||
@@ -428,14 +431,14 @@ function TopModelsSection(props: { data: StatsHomeData["usage"]; leaderboard: St
|
||||
<StatsFilters product={product()} range={range()} onProductSelect={setProduct} onRangeSelect={setRange} />
|
||||
<div data-slot="top-models-mobile-controls">
|
||||
<MobileFilterButton
|
||||
label="Product filter"
|
||||
value={product()}
|
||||
label={i18n.t("home.productFilter")}
|
||||
value={productLabel(product(), i18n)}
|
||||
expanded={sheet() === "product"}
|
||||
onClick={() => setSheet(sheet() === "product" ? undefined : "product")}
|
||||
/>
|
||||
<MobileFilterButton
|
||||
label="Date range"
|
||||
value={range()}
|
||||
label={i18n.t("home.dateRange")}
|
||||
value={rangeLabel(range(), i18n)}
|
||||
expanded={sheet() === "range"}
|
||||
onClick={() => setSheet(sheet() === "range" ? undefined : "range")}
|
||||
/>
|
||||
@@ -486,12 +489,13 @@ function MobileFilterSheet(props: {
|
||||
onRangeSelect: (range: UsageRange) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const i18n = useI18n()
|
||||
return (
|
||||
<div data-component="mobile-filter-sheet" role="presentation" onClick={props.onClose}>
|
||||
<div
|
||||
data-slot="filter-sheet-panel"
|
||||
role="radiogroup"
|
||||
aria-label={props.kind === "product" ? "Product filter" : "Date range"}
|
||||
aria-label={props.kind === "product" ? i18n.t("home.productFilter") : i18n.t("home.dateRange")}
|
||||
>
|
||||
<Show
|
||||
when={props.kind === "product"}
|
||||
@@ -508,7 +512,7 @@ function MobileFilterSheet(props: {
|
||||
props.onRangeSelect(item)
|
||||
}}
|
||||
>
|
||||
{rangeLabels[item]}
|
||||
{rangeLabel(item, i18n)}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
@@ -526,7 +530,7 @@ function MobileFilterSheet(props: {
|
||||
props.onProductSelect(item)
|
||||
}}
|
||||
>
|
||||
{item}
|
||||
{productLabel(item, i18n)}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
@@ -550,20 +554,23 @@ function StatsFilters(props: {
|
||||
onProductSelect: (product: UsageProduct) => void
|
||||
onRangeSelect: (range: UsageRange) => void
|
||||
}) {
|
||||
const i18n = useI18n()
|
||||
return (
|
||||
<>
|
||||
<FilterPills
|
||||
items={products}
|
||||
selected={props.product}
|
||||
label="Product filter"
|
||||
label={i18n.t("home.productFilter")}
|
||||
variant="product"
|
||||
formatLabel={(item) => productLabel(item, i18n)}
|
||||
onSelect={props.onProductSelect}
|
||||
/>
|
||||
<FilterPills
|
||||
items={ranges}
|
||||
selected={props.range}
|
||||
label="Date range"
|
||||
label={i18n.t("home.dateRange")}
|
||||
variant="range"
|
||||
formatLabel={(item) => rangeLabel(item, i18n)}
|
||||
onSelect={props.onRangeSelect}
|
||||
/>
|
||||
</>
|
||||
@@ -575,6 +582,7 @@ function FilterPills<T extends string>(props: {
|
||||
selected: T
|
||||
label: string
|
||||
variant: "product" | "range"
|
||||
formatLabel?: (item: T) => string
|
||||
onSelect: (item: T) => void
|
||||
}) {
|
||||
return (
|
||||
@@ -588,7 +596,7 @@ function FilterPills<T extends string>(props: {
|
||||
data-active={props.selected === item ? "true" : undefined}
|
||||
onClick={() => props.onSelect(item)}
|
||||
>
|
||||
{item}
|
||||
{props.formatLabel ? props.formatLabel(item) : item}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
@@ -604,6 +612,7 @@ function TopModelsChart(props: {
|
||||
activeModel: string | undefined
|
||||
onActiveModelChange: (model: string | undefined) => void
|
||||
}) {
|
||||
const i18n = useI18n()
|
||||
let chartRef: HTMLDivElement | undefined
|
||||
const [activeIndex, setActiveIndex] = createSignal<number>()
|
||||
const maxTotal = createMemo(() => getTopModelsMaxTotal(props.data))
|
||||
@@ -621,7 +630,7 @@ function TopModelsChart(props: {
|
||||
data-metric={metric()}
|
||||
data-dense-labels={isDenseColumnRange(props.range) ? "true" : undefined}
|
||||
role="img"
|
||||
aria-label={props.ariaLabel ?? "Stacked top model usage chart"}
|
||||
aria-label={props.ariaLabel ?? i18n.t("home.stackedUsageChart")}
|
||||
style={{ "--top-models-count": props.data.length } as JSX.CSSProperties}
|
||||
onPointerLeave={(event) => {
|
||||
if (event.pointerType === "touch") return
|
||||
@@ -662,7 +671,7 @@ function TopModelsChart(props: {
|
||||
data-slot="top-models-bar"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${day.date} ${formatUsageChartValue(usageTotal(day), metric())} ${usageChartTotalLabel(metric())}`}
|
||||
aria-label={`${day.date} ${formatUsageChartValue(usageTotal(day), metric())} ${usageChartTotalLabel(metric(), i18n)}`}
|
||||
data-active={activeIndex() === dayIndex() ? "true" : undefined}
|
||||
data-muted={activeIndex() !== undefined && activeIndex() !== dayIndex() ? "true" : undefined}
|
||||
style={{ "--top-models-bar-height": `${getTopModelsBarHeight(usageTotal(day), maxTotal())}%` }}
|
||||
@@ -746,7 +755,7 @@ function TopModelsChart(props: {
|
||||
<strong>{point().date}</strong>
|
||||
<Show when={metric() === "tokens"}>
|
||||
<span>
|
||||
{formatUsageChartValue(usageTotal(point()), metric())} {usageChartTotalLabel(metric())}
|
||||
{formatUsageChartValue(usageTotal(point()), metric())} {usageChartTotalLabel(metric(), i18n)}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={metric() === "tokens"}>
|
||||
@@ -786,24 +795,25 @@ function TopModelsChart(props: {
|
||||
}
|
||||
|
||||
function UniqueUsersSection(props: { data: StatsHomeData["users"] }) {
|
||||
const i18n = useI18n()
|
||||
const [activeModel, setActiveModel] = createSignal<string>()
|
||||
const data = createMemo(() => props.data.Go["2M"])
|
||||
|
||||
return (
|
||||
<section id="unique-users" data-section="unique-users">
|
||||
<SectionBridge label="TOP MODELS" href="#top-models" />
|
||||
<SectionTitle title="Unique Users" description="Daily unique OpenCode Go users by model." />
|
||||
<SectionBridge label={i18n.t("nav.topModels").toUpperCase()} href="#top-models" />
|
||||
<SectionTitle title={i18n.t("home.uniqueUsersTitle")} description={i18n.t("home.uniqueUsersDescription")} />
|
||||
<Show
|
||||
when={data().some((item) => usageTotal(item) > 0)}
|
||||
fallback={
|
||||
<EmptyState title="No user data" description="No user-bearing model_stat rows matched this window." />
|
||||
<EmptyState title={i18n.t("home.noUserDataTitle")} description={i18n.t("home.noUserDataDescription")} />
|
||||
}
|
||||
>
|
||||
<TopModelsChart
|
||||
data={data()}
|
||||
range="2M"
|
||||
metric="users"
|
||||
ariaLabel="Stacked unique user chart by model"
|
||||
ariaLabel={i18n.t("home.uniqueUsersChart")}
|
||||
activeModel={activeModel()}
|
||||
onActiveModelChange={setActiveModel}
|
||||
/>
|
||||
@@ -907,9 +917,9 @@ function formatUsageChartValue(value: number, metric: "tokens" | "users") {
|
||||
return formatTokens(value)
|
||||
}
|
||||
|
||||
function usageChartTotalLabel(metric: "tokens" | "users") {
|
||||
if (metric === "users") return "model users"
|
||||
return "total"
|
||||
function usageChartTotalLabel(metric: "tokens" | "users", i18n: ReturnType<typeof useI18n>) {
|
||||
if (metric === "users") return i18n.t("home.modelUsers")
|
||||
return i18n.t("home.total")
|
||||
}
|
||||
|
||||
function formatUsers(value: number) {
|
||||
@@ -923,11 +933,12 @@ function Leaderboard(props: {
|
||||
activeModel: string | undefined
|
||||
onActiveModelChange: (model: string | undefined) => void
|
||||
}) {
|
||||
const i18n = useI18n()
|
||||
const featured = createMemo(() => props.data.slice(0, 3))
|
||||
const compact = createMemo(() => props.data.slice(3))
|
||||
|
||||
return (
|
||||
<div id="leaderboard" data-component="leaderboard" role="list" aria-label="Model token leaderboard">
|
||||
<div id="leaderboard" data-component="leaderboard" role="list" aria-label={i18n.t("chart.leaderboardAria")}>
|
||||
<div data-slot="leaderboard-featured">
|
||||
<For each={featured()}>
|
||||
{(entry) => (
|
||||
@@ -953,7 +964,7 @@ function Leaderboard(props: {
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div data-slot="leaderboard-mobile" aria-label="Scrollable model token leaderboard">
|
||||
<div data-slot="leaderboard-mobile" aria-label={i18n.t("chart.scrollableLeaderboardAria")}>
|
||||
<For each={props.data}>
|
||||
{(entry) => (
|
||||
<LeaderboardCard
|
||||
@@ -975,15 +986,17 @@ function LeaderboardCard(props: {
|
||||
active: boolean
|
||||
onActiveModelChange: (model: string | undefined) => void
|
||||
}) {
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<a
|
||||
data-component="leader-card"
|
||||
data-size={props.size}
|
||||
data-active={props.active ? "true" : undefined}
|
||||
href={`${import.meta.env.BASE_URL}${modelSlug(props.entry.provider)}/${modelSlug(props.entry.model)}`}
|
||||
href={language.route(`${import.meta.env.BASE_URL}${modelSlug(props.entry.provider)}/${modelSlug(props.entry.model)}`)}
|
||||
role="listitem"
|
||||
tabIndex={0}
|
||||
aria-label={`${String(props.entry.rank).padStart(2, "0")} ${props.entry.model} by ${props.entry.author}`}
|
||||
aria-label={`${String(props.entry.rank).padStart(2, "0")} ${props.entry.model} ${i18n.t("chart.byAuthor", { author: props.entry.author })}`}
|
||||
onPointerEnter={() => props.onActiveModelChange(props.entry.model)}
|
||||
onPointerLeave={(event) => {
|
||||
if (event.pointerType === "touch") return
|
||||
@@ -1009,7 +1022,7 @@ function LeaderboardCard(props: {
|
||||
data-new={props.entry.change === null ? "true" : undefined}
|
||||
data-negative={props.entry.change !== null && props.entry.change < 0 ? "true" : undefined}
|
||||
>
|
||||
{formatChange(props.entry.change)}
|
||||
{formatChange(props.entry.change, i18n)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1030,13 +1043,14 @@ function formatBillions(value: number) {
|
||||
return `${value}B`
|
||||
}
|
||||
|
||||
function formatChange(value: number | null) {
|
||||
if (value === null) return "New"
|
||||
function formatChange(value: number | null, i18n: ReturnType<typeof useI18n>) {
|
||||
if (value === null) return i18n.t("home.new")
|
||||
if (value > 0) return `+${value}%`
|
||||
return `${value}%`
|
||||
}
|
||||
|
||||
function MarketShareSection(props: { data: StatsHomeData["market"] }) {
|
||||
const i18n = useI18n()
|
||||
const [range, setRange] = createSignal<UsageRange>("2M")
|
||||
const [activeIndex, setActiveIndex] = createSignal(2)
|
||||
const [activeAuthor, setActiveAuthor] = createSignal<string>()
|
||||
@@ -1056,11 +1070,11 @@ function MarketShareSection(props: { data: StatsHomeData["market"] }) {
|
||||
setInspecting(false)
|
||||
}}
|
||||
>
|
||||
<SectionBridge label="CACHE RATIO" href="#cache-ratio" />
|
||||
<SectionTitle title="Market Share" description="Compare token share by model author." />
|
||||
<SectionBridge label={i18n.t("nav.cacheRatio").toUpperCase()} href="#cache-ratio" />
|
||||
<SectionTitle title={i18n.t("home.marketShareTitle")} description={i18n.t("home.marketShareDescription")} />
|
||||
<Show
|
||||
when={activeDay()}
|
||||
fallback={<EmptyState title="No market data" description="No model_stat rows matched this range." />}
|
||||
fallback={<EmptyState title={i18n.t("home.noMarketTitle")} description={i18n.t("home.noMarketDescription")} />}
|
||||
>
|
||||
{(day) => (
|
||||
<>
|
||||
@@ -1095,13 +1109,15 @@ function MarketShareSection(props: { data: StatsHomeData["market"] }) {
|
||||
<div data-slot="market-footer">
|
||||
<p>
|
||||
<span>[*]</span>
|
||||
<strong>{inspecting() ? formatMarketDate(activeDay()) : formatMarketRange(data())}</strong>
|
||||
<strong>
|
||||
{inspecting() ? formatMarketDate(activeDay(), i18n.t("home.noData")) : formatMarketRange(data(), i18n.t("home.noData"))}
|
||||
</strong>
|
||||
</p>
|
||||
<div hidden>
|
||||
<FilterPills
|
||||
items={ranges}
|
||||
selected={range()}
|
||||
label="Date range"
|
||||
label={i18n.t("home.dateRange")}
|
||||
variant="range"
|
||||
onSelect={(item) => {
|
||||
setRange(item)
|
||||
@@ -1125,6 +1141,7 @@ function MarketShare(props: {
|
||||
onActiveIndexChange: (index: number) => void
|
||||
onActiveAuthorChange: (author: string) => void
|
||||
}) {
|
||||
const i18n = useI18n()
|
||||
let chartRef: HTMLDivElement | undefined
|
||||
|
||||
createEffect(() => scrollDenseChartToEnd(chartRef, props.range, props.data.length))
|
||||
@@ -1136,7 +1153,7 @@ function MarketShare(props: {
|
||||
data-range={props.range}
|
||||
data-dense-labels={isDenseColumnRange(props.range) ? "true" : undefined}
|
||||
role="img"
|
||||
aria-label="Market share by model author"
|
||||
aria-label={i18n.t("home.marketChart")}
|
||||
style={{ "--market-count": props.data.length } as JSX.CSSProperties}
|
||||
>
|
||||
<div data-slot="market-labels">
|
||||
@@ -1219,6 +1236,7 @@ function MarketShareList(props: {
|
||||
activeAuthor: string | undefined
|
||||
onActiveAuthorChange: (author: string) => void
|
||||
}) {
|
||||
const i18n = useI18n()
|
||||
return (
|
||||
<ol data-component="market-share-list">
|
||||
<For each={props.data}>
|
||||
@@ -1226,7 +1244,7 @@ function MarketShareList(props: {
|
||||
<li
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${item.author} ${formatTrillions(item.tokens)} ${item.share.toFixed(1)} percent`}
|
||||
aria-label={`${item.author} ${formatTrillions(item.tokens)} ${item.share.toFixed(1)} ${i18n.t("chart.percent")}`}
|
||||
data-active={props.activeAuthor === item.author ? "true" : undefined}
|
||||
onPointerEnter={() => props.onActiveAuthorChange(item.author)}
|
||||
onFocus={() => props.onActiveAuthorChange(item.author)}
|
||||
@@ -1249,6 +1267,8 @@ function MarketShareList(props: {
|
||||
}
|
||||
|
||||
function GeoBreakdownSection(props: { data: StatsHomeData["country"] }) {
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
const [activeCountry, setActiveCountry] = createSignal<string>()
|
||||
const data = createMemo(() => props.data["2M"])
|
||||
const countryById = createMemo(
|
||||
@@ -1273,11 +1293,11 @@ function GeoBreakdownSection(props: { data: StatsHomeData["country"] }) {
|
||||
setActiveCountry(undefined)
|
||||
}}
|
||||
>
|
||||
<SectionBridge label="MARKET SHARE" href="#market-share" />
|
||||
<SectionTitle title="Geo Breakdown" description="Tokens used by country." />
|
||||
<SectionBridge label={i18n.t("nav.marketShare").toUpperCase()} href="#market-share" />
|
||||
<SectionTitle title={i18n.t("home.geoTitle")} description={i18n.t("home.geoDescription")} />
|
||||
<Show
|
||||
when={data().length > 0}
|
||||
fallback={<EmptyState title="No geo data" description="No geo_stat rows matched this range." />}
|
||||
fallback={<EmptyState title={i18n.t("home.noGeoTitle")} description={i18n.t("home.noGeoDescription")} />}
|
||||
>
|
||||
<div data-component="geo-breakdown">
|
||||
<div data-slot="geo-map-panel">
|
||||
@@ -1291,7 +1311,7 @@ function GeoBreakdownSection(props: { data: StatsHomeData["country"] }) {
|
||||
{(country) => (
|
||||
<div data-slot="geo-active-country">
|
||||
<span>#{String(country().rank).padStart(2, "0")}</span>
|
||||
<strong>{formatCountryName(country().country)}</strong>
|
||||
<strong>{formatCountryName(country().country, language.tag(language.locale()), i18n.t("home.unknown"))}</strong>
|
||||
<p>
|
||||
<b>{formatGeoTokens(country().tokens)}</b>
|
||||
<em>{formatGeoShare(country().share)}</em>
|
||||
@@ -1318,6 +1338,7 @@ function GeoWorldMap(props: {
|
||||
maxTokens: number
|
||||
onActiveCountryChange: (country: string | undefined) => void
|
||||
}) {
|
||||
const i18n = useI18n()
|
||||
const opacityScale = createMemo(() => scaleSqrt().domain([0, props.maxTokens]).range([0.26, 0.96]).clamp(true))
|
||||
const countryOpacity = (country: CountryEntry | undefined) => {
|
||||
if (!country) return 0
|
||||
@@ -1331,9 +1352,9 @@ function GeoWorldMap(props: {
|
||||
data-component="geo-world-map"
|
||||
viewBox={`0 0 ${geoMapWidth} ${geoMapHeight}`}
|
||||
role="img"
|
||||
aria-label="World map of token usage by country"
|
||||
aria-label={i18n.t("home.worldMap")}
|
||||
>
|
||||
<title>Geo Breakdown map</title>
|
||||
<title>{i18n.t("home.geoMapTitle")}</title>
|
||||
<g data-slot="geo-countries">
|
||||
<For each={worldCountryPaths}>
|
||||
{(country) => {
|
||||
@@ -1403,6 +1424,8 @@ function GeoCountryList(props: {
|
||||
maxTokens: number
|
||||
onActiveCountryChange: (country: string | undefined) => void
|
||||
}) {
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
const opacityScale = createMemo(() => scaleSqrt().domain([0, props.maxTokens]).range([0.26, 0.96]).clamp(true))
|
||||
|
||||
return (
|
||||
@@ -1414,16 +1437,14 @@ function GeoCountryList(props: {
|
||||
type="button"
|
||||
data-active={props.activeCountry === country.country ? "true" : undefined}
|
||||
style={{ "--geo-row-opacity": String(opacityScale()(country.tokens)) } as JSX.CSSProperties}
|
||||
aria-label={`${formatCountryName(country.country)} ${formatGeoTokens(country.tokens)} ${formatGeoShare(
|
||||
country.share,
|
||||
)}`}
|
||||
aria-label={`${formatCountryName(country.country, language.tag(language.locale()), i18n.t("home.unknown"))} ${formatGeoTokens(country.tokens)} ${formatGeoShare(country.share)}`}
|
||||
onClick={() => props.onActiveCountryChange(country.country)}
|
||||
onPointerEnter={() => props.onActiveCountryChange(country.country)}
|
||||
onFocus={() => props.onActiveCountryChange(country.country)}
|
||||
>
|
||||
<span>{String(country.rank).padStart(2, "0")}</span>
|
||||
<i />
|
||||
<strong>{formatCountryName(country.country)}</strong>
|
||||
<strong>{formatCountryName(country.country, language.tag(language.locale()), i18n.t("home.unknown"))}</strong>
|
||||
<em>{formatGeoTokens(country.tokens)}</em>
|
||||
<b>{formatGeoShare(country.share)}</b>
|
||||
</button>
|
||||
@@ -1446,11 +1467,11 @@ function geoCountryMarker(country: (typeof worldCountries.features)[number]) {
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
function formatCountryName(country: string) {
|
||||
function formatCountryName(country: string, locale: string, unknown: string) {
|
||||
const code = country.toUpperCase()
|
||||
if (code === "ZZ") return "Unknown"
|
||||
if (code === "ZZ") return unknown
|
||||
if (!countryNumericId(code)) return code
|
||||
return countryDisplayNames.of(code) ?? code
|
||||
return new Intl.DisplayNames([locale], { type: "region" }).of(code) ?? code
|
||||
}
|
||||
|
||||
function formatGeoTokens(value: number) {
|
||||
@@ -1516,15 +1537,15 @@ function formatTrillions(value: number) {
|
||||
return `${value.toFixed(value >= 10 ? 0 : 1)}T`
|
||||
}
|
||||
|
||||
function formatMarketDate(day: MarketDay | undefined) {
|
||||
if (!day) return "No data"
|
||||
function formatMarketDate(day: MarketDay | undefined, fallback: string) {
|
||||
if (!day) return fallback
|
||||
return formatMarketDateLabel(day.date)
|
||||
}
|
||||
|
||||
function formatMarketRange(data: MarketDay[]) {
|
||||
function formatMarketRange(data: MarketDay[], fallback: string) {
|
||||
const first = data[0]?.date
|
||||
const last = data[data.length - 1]?.date
|
||||
if (!first || !last) return "No data"
|
||||
if (!first || !last) return fallback
|
||||
const start = marketDateParts(first).start
|
||||
const end = marketDateParts(last).end
|
||||
if (start === end) return formatMarketDateLabel(start)
|
||||
@@ -1544,6 +1565,7 @@ function marketDateParts(label: string) {
|
||||
}
|
||||
|
||||
function TokenCostSection(props: { data: StatsHomeData["tokenCost"]; catalog: ModelCatalog | null }) {
|
||||
const i18n = useI18n()
|
||||
const [product, setProduct] = createSignal<TokenProduct>("Go")
|
||||
const [activeIndex, setActiveIndex] = createSignal(2)
|
||||
const data = createMemo(() => priceTokenCostFromCatalog(props.data[product()], props.catalog))
|
||||
@@ -1552,12 +1574,12 @@ function TokenCostSection(props: { data: StatsHomeData["tokenCost"]; catalog: Mo
|
||||
|
||||
return (
|
||||
<section id="token-cost" data-section="token-cost">
|
||||
<SectionBridge label="SESSION COST" href="#session-cost" />
|
||||
<SectionTitle title="Token Cost" description="Price per 1M tokens." />
|
||||
<SectionBridge label={i18n.t("nav.sessionCost").toUpperCase()} href="#session-cost" />
|
||||
<SectionTitle title={i18n.t("home.tokenCostTitle")} description={i18n.t("home.tokenCostDescription")} />
|
||||
<Show
|
||||
when={visible().length > 0}
|
||||
fallback={
|
||||
<EmptyState title="No token cost data" description="No cost-bearing model_stat rows matched this product." />
|
||||
<EmptyState title={i18n.t("home.noTokenCostTitle")} description={i18n.t("home.noTokenCostDescription")} />
|
||||
}
|
||||
>
|
||||
<TokenCostChart data={visible()} activeIndex={selectedIndex()} onActiveIndexChange={setActiveIndex} />
|
||||
@@ -1566,8 +1588,9 @@ function TokenCostSection(props: { data: StatsHomeData["tokenCost"]; catalog: Mo
|
||||
<FilterPills
|
||||
items={tokenProducts}
|
||||
selected={product()}
|
||||
label="Product filter"
|
||||
label={i18n.t("home.productFilter")}
|
||||
variant="product"
|
||||
formatLabel={(item) => productLabel(item, i18n)}
|
||||
onSelect={setProduct}
|
||||
/>
|
||||
<LiveIndicator />
|
||||
@@ -1581,6 +1604,7 @@ function TokenCostChart(props: {
|
||||
activeIndex: number
|
||||
onActiveIndexChange: (index: number) => void
|
||||
}) {
|
||||
const i18n = useI18n()
|
||||
const max = createMemo(() => Math.max(0, ...props.data.map((item) => item.total)) || 1)
|
||||
const active = createMemo(() => props.data[props.activeIndex] ?? props.data[0])
|
||||
|
||||
@@ -1605,15 +1629,15 @@ function TokenCostChart(props: {
|
||||
{(item) => (
|
||||
<div data-component="token-tooltip" style={{ top: `${props.activeIndex * 36 + 2}px` }}>
|
||||
<p>
|
||||
<span>Input</span>
|
||||
<span>{i18n.t("chart.input")}</span>
|
||||
<strong>{formatDollars(item().input)}</strong>
|
||||
</p>
|
||||
<p>
|
||||
<span>Output</span>
|
||||
<span>{i18n.t("chart.output")}</span>
|
||||
<strong>{formatDollars(item().output)}</strong>
|
||||
</p>
|
||||
<p>
|
||||
<span>Cached</span>
|
||||
<span>{i18n.t("chart.cached")}</span>
|
||||
<strong>{formatDollars(item().cached)}</strong>
|
||||
</p>
|
||||
</div>
|
||||
@@ -1624,6 +1648,7 @@ function TokenCostChart(props: {
|
||||
}
|
||||
|
||||
function CacheRatioSection(props: { data: StatsHomeData["cacheRatio"] }) {
|
||||
const i18n = useI18n()
|
||||
const [product, setProduct] = createSignal<TokenProduct>("Go")
|
||||
const [activeIndex, setActiveIndex] = createSignal(2)
|
||||
const data = createMemo(() => props.data[product()])
|
||||
@@ -1632,12 +1657,12 @@ function CacheRatioSection(props: { data: StatsHomeData["cacheRatio"] }) {
|
||||
|
||||
return (
|
||||
<section id="cache-ratio" data-section="cache-ratio">
|
||||
<SectionBridge label="TOKEN COST" href="#token-cost" />
|
||||
<SectionTitle title="Cache Ratio" description="Share of input tokens served from cache." />
|
||||
<SectionBridge label={i18n.t("nav.tokenCost").toUpperCase()} href="#token-cost" />
|
||||
<SectionTitle title={i18n.t("home.cacheRatioTitle")} description={i18n.t("home.cacheRatioDescription")} />
|
||||
<Show
|
||||
when={visible().length > 0}
|
||||
fallback={
|
||||
<EmptyState title="No cache ratio data" description="No input-token model_stat rows matched this product." />
|
||||
<EmptyState title={i18n.t("home.noCacheTitle")} description={i18n.t("home.noCacheDescription")} />
|
||||
}
|
||||
>
|
||||
<CacheRatioChart data={visible()} activeIndex={selectedIndex()} onActiveIndexChange={setActiveIndex} />
|
||||
@@ -1646,8 +1671,9 @@ function CacheRatioSection(props: { data: StatsHomeData["cacheRatio"] }) {
|
||||
<FilterPills
|
||||
items={tokenProducts}
|
||||
selected={product()}
|
||||
label="Product filter"
|
||||
label={i18n.t("home.productFilter")}
|
||||
variant="product"
|
||||
formatLabel={(item) => productLabel(item, i18n)}
|
||||
onSelect={setProduct}
|
||||
/>
|
||||
<LiveIndicator />
|
||||
@@ -1661,13 +1687,14 @@ function CacheRatioChart(props: {
|
||||
activeIndex: number
|
||||
onActiveIndexChange: (index: number) => void
|
||||
}) {
|
||||
const i18n = useI18n()
|
||||
const active = createMemo(() => props.data[props.activeIndex] ?? props.data[0])
|
||||
|
||||
return (
|
||||
<div data-component="cache-ratio" data-variant="marker">
|
||||
<div data-slot="cache-ratio-heading" aria-hidden="true">
|
||||
<strong>Ratio</strong>
|
||||
<span>Model</span>
|
||||
<strong>{i18n.t("chart.ratio")}</strong>
|
||||
<span>{i18n.t("chart.model")}</span>
|
||||
<b>0-100%</b>
|
||||
</div>
|
||||
<div data-slot="cache-ratio-rows">
|
||||
@@ -1695,15 +1722,15 @@ function CacheRatioChart(props: {
|
||||
style={{ top: `${props.activeIndex * 36 + 28}px` }}
|
||||
>
|
||||
<p>
|
||||
<span>Cache Ratio</span>
|
||||
<span>{i18n.t("chart.cacheRatio")}</span>
|
||||
<strong>{formatRatio(item().ratio)}</strong>
|
||||
</p>
|
||||
<p>
|
||||
<span>Cached</span>
|
||||
<span>{i18n.t("chart.cached")}</span>
|
||||
<strong>{formatBillions(item().cached)}</strong>
|
||||
</p>
|
||||
<p>
|
||||
<span>Uncached</span>
|
||||
<span>{i18n.t("chart.uncached")}</span>
|
||||
<strong>{formatBillions(item().uncached)}</strong>
|
||||
</p>
|
||||
</div>
|
||||
@@ -1749,6 +1776,7 @@ function MetricBar(props: { value: number; max: number; active: boolean }) {
|
||||
}
|
||||
|
||||
function SessionCostSection(props: { data: StatsHomeData["sessionCost"] }) {
|
||||
const i18n = useI18n()
|
||||
const [product, setProduct] = createSignal<TokenProduct>("Go")
|
||||
const [activeIndex, setActiveIndex] = createSignal(2)
|
||||
const data = createMemo(() => props.data[product()])
|
||||
@@ -1757,14 +1785,14 @@ function SessionCostSection(props: { data: StatsHomeData["sessionCost"] }) {
|
||||
|
||||
return (
|
||||
<section id="session-cost" data-section="session-cost">
|
||||
<SectionBridge label="TOP MODELS" href="#top-models" />
|
||||
<SectionTitle title="Session Cost" description="Average cost per session." />
|
||||
<SectionBridge label={i18n.t("nav.topModels").toUpperCase()} href="#top-models" />
|
||||
<SectionTitle title={i18n.t("home.sessionCostTitle")} description={i18n.t("home.sessionCostDescription")} />
|
||||
<Show
|
||||
when={visible().length > 0}
|
||||
fallback={
|
||||
<EmptyState
|
||||
title="No session cost data"
|
||||
description="No session-bearing model_stat rows matched this product."
|
||||
title={i18n.t("home.noSessionCostTitle")}
|
||||
description={i18n.t("home.noSessionCostDescription")}
|
||||
/>
|
||||
}
|
||||
>
|
||||
@@ -1774,8 +1802,9 @@ function SessionCostSection(props: { data: StatsHomeData["sessionCost"] }) {
|
||||
<FilterPills
|
||||
items={tokenProducts}
|
||||
selected={product()}
|
||||
label="Product filter"
|
||||
label={i18n.t("home.productFilter")}
|
||||
variant="product"
|
||||
formatLabel={(item) => productLabel(item, i18n)}
|
||||
onSelect={setProduct}
|
||||
/>
|
||||
<LiveIndicator />
|
||||
@@ -1789,6 +1818,7 @@ function SessionCostChart(props: {
|
||||
activeIndex: number
|
||||
onActiveIndexChange: (index: number) => void
|
||||
}) {
|
||||
const i18n = useI18n()
|
||||
const maxCost = createMemo(() => Math.max(0, ...props.data.map((item) => item.cost)) || 1)
|
||||
const maxTokens = createMemo(() => Math.max(0, ...props.data.map((item) => item.tokens)) || 1)
|
||||
const active = createMemo(() => props.data[props.activeIndex] ?? props.data[0])
|
||||
@@ -1798,8 +1828,8 @@ function SessionCostChart(props: {
|
||||
<div data-slot="session-heading">
|
||||
<strong aria-hidden="true" />
|
||||
<span aria-hidden="true" />
|
||||
<p>COST / SESSION</p>
|
||||
<p>TOKENS / SESSION</p>
|
||||
<p>{i18n.t("chart.costPerSession")}</p>
|
||||
<p>{i18n.t("chart.tokensPerSession")}</p>
|
||||
</div>
|
||||
<For each={props.data}>
|
||||
{(item, index) => (
|
||||
@@ -1826,11 +1856,11 @@ function SessionCostChart(props: {
|
||||
style={{ top: `${props.activeIndex * 36 + 28}px` }}
|
||||
>
|
||||
<p>
|
||||
<span>Cost/Session</span>
|
||||
<span>{i18n.t("chart.costSession")}</span>
|
||||
<strong>{formatSessionCost(item().cost)}</strong>
|
||||
</p>
|
||||
<p>
|
||||
<span>Tokens/Session</span>
|
||||
<span>{i18n.t("chart.tokensSession")}</span>
|
||||
<strong>{formatTokenCount(item().tokens)}</strong>
|
||||
</p>
|
||||
</div>
|
||||
@@ -1841,7 +1871,8 @@ function SessionCostChart(props: {
|
||||
}
|
||||
|
||||
function LiveIndicator() {
|
||||
return <span data-component="live-filter">Live</span>
|
||||
const i18n = useI18n()
|
||||
return <span data-component="live-filter">{i18n.t("chart.live")}</span>
|
||||
}
|
||||
|
||||
function formatTokenCount(value: number) {
|
||||
|
||||
@@ -1,24 +1,16 @@
|
||||
import opencodeWordmarkDark from "../asset/logo-ornate-dark.svg"
|
||||
import { query } from "@solidjs/router"
|
||||
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
|
||||
import { useI18n } from "../context/i18n"
|
||||
import { useLanguage } from "../context/language"
|
||||
import { route, type Locale } from "../lib/language"
|
||||
|
||||
export type HeaderLink = { href: string; label: string }
|
||||
|
||||
export const headerLinks = [
|
||||
{ href: "#top-models", label: "Top Models" },
|
||||
{ href: "#leaderboard", label: "Leaderboard" },
|
||||
{ href: "#session-cost", label: "Session Cost" },
|
||||
{ href: "#token-cost", label: "Token Cost" },
|
||||
{ href: "#cache-ratio", label: "Cache Ratio" },
|
||||
{ href: "#market-share", label: "Market Share" },
|
||||
{ href: "#geo-breakdown", label: "Geo Breakdown" },
|
||||
] as const
|
||||
export const githubLink = {
|
||||
href: "https://github.com/anomalyco/opencode",
|
||||
apiHref: "https://api.github.com/repos/anomalyco/opencode",
|
||||
label: "GitHub",
|
||||
fallbackStars: "150K",
|
||||
ariaLabel: "Star OpenCode on GitHub",
|
||||
}
|
||||
export const themePreferences = ["dark", "light", "system"] as const
|
||||
export const themeStorageKey = "opencode:stats-theme"
|
||||
@@ -28,11 +20,6 @@ const compactNumberFormatter = new Intl.NumberFormat("en", {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
})
|
||||
const themePreferenceLabels = {
|
||||
dark: "Dark",
|
||||
light: "Light",
|
||||
system: "System",
|
||||
} as const
|
||||
|
||||
export const getGitHubStars = query(async () => {
|
||||
"use server"
|
||||
@@ -66,9 +53,23 @@ export function applyThemePreference(preference: ThemePreference) {
|
||||
}
|
||||
|
||||
export function Header(props: { githubStars: string; links?: readonly HeaderLink[]; brandHref?: string }) {
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
const [menuOpen, setMenuOpen] = createSignal(false)
|
||||
const [menuViewport, setMenuViewport] = createSignal(false)
|
||||
const links = createMemo(() => props.links ?? headerLinks)
|
||||
const links = createMemo(
|
||||
() =>
|
||||
props.links ?? [
|
||||
{ href: "#top-models", label: i18n.t("nav.topModels") },
|
||||
{ href: "#leaderboard", label: i18n.t("nav.leaderboard") },
|
||||
{ href: "#session-cost", label: i18n.t("nav.sessionCost") },
|
||||
{ href: "#token-cost", label: i18n.t("nav.tokenCost") },
|
||||
{ href: "#cache-ratio", label: i18n.t("nav.cacheRatio") },
|
||||
{ href: "#market-share", label: i18n.t("nav.marketShare") },
|
||||
{ href: "#geo-breakdown", label: i18n.t("nav.geoBreakdown") },
|
||||
],
|
||||
)
|
||||
const localHref = (href: string) => (href.startsWith("/") ? language.route(href) : href)
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
@@ -101,15 +102,15 @@ export function Header(props: { githubStars: string; links?: readonly HeaderLink
|
||||
return (
|
||||
<header data-component="top" data-menu-open={menuOpen() ? "true" : undefined}>
|
||||
<div data-slot="header-bar">
|
||||
<a data-slot="brand" href={props.brandHref ?? import.meta.env.BASE_URL} aria-label="Data home">
|
||||
<a data-slot="brand" href={localHref(props.brandHref ?? import.meta.env.BASE_URL)} aria-label={i18n.t("header.brandLabel")}>
|
||||
<DataWordmark />
|
||||
</a>
|
||||
<nav data-component="section-nav" aria-label="Data sections">
|
||||
<nav data-component="section-nav" aria-label={i18n.t("header.sectionNavLabel")}>
|
||||
<ul>
|
||||
<For each={links()}>
|
||||
{(link) => (
|
||||
<li>
|
||||
<a href={link.href}>{link.label}</a>
|
||||
<a href={localHref(link.href)}>{link.label}</a>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
@@ -122,20 +123,20 @@ export function Header(props: { githubStars: string; links?: readonly HeaderLink
|
||||
href={githubLink.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={`${githubLink.ariaLabel} (${props.githubStars} stars)`}
|
||||
aria-label={`${i18n.t("header.githubAria")} (${props.githubStars} stars)`}
|
||||
>
|
||||
<strong>{githubLink.label}</strong>
|
||||
<strong>{i18n.t("header.github")}</strong>
|
||||
<span>[{props.githubStars}]</span>
|
||||
</a>
|
||||
<a data-slot="header-button" data-variant="contrast" href="https://opencode.ai/">
|
||||
<strong>Try OpenCode</strong>
|
||||
<strong>{i18n.t("header.tryOpenCode")}</strong>
|
||||
</a>
|
||||
<button
|
||||
data-slot="menu-button"
|
||||
type="button"
|
||||
aria-controls="stats-mobile-nav"
|
||||
aria-expanded={menuOpen() ? "true" : "false"}
|
||||
aria-label={menuOpen() ? "Close navigation" : "Open navigation"}
|
||||
aria-label={menuOpen() ? i18n.t("header.closeNav") : i18n.t("header.openNav")}
|
||||
onClick={() => setMenuOpen((value) => !value)}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
@@ -146,21 +147,21 @@ export function Header(props: { githubStars: string; links?: readonly HeaderLink
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<nav id="stats-mobile-nav" data-slot="mobile-menu" aria-label="Data sections" hidden={!menuOpen()}>
|
||||
<nav id="stats-mobile-nav" data-slot="mobile-menu" aria-label={i18n.t("header.sectionNavLabel")} hidden={!menuOpen()}>
|
||||
<a
|
||||
data-slot="mobile-menu-item"
|
||||
data-variant="github"
|
||||
href={githubLink.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={`${githubLink.ariaLabel} (${props.githubStars} stars)`}
|
||||
aria-label={`${i18n.t("header.githubAria")} (${props.githubStars} stars)`}
|
||||
>
|
||||
<strong>{githubLink.label}</strong>
|
||||
<strong>{i18n.t("header.github")}</strong>
|
||||
<span>[{props.githubStars}]</span>
|
||||
</a>
|
||||
<For each={links()}>
|
||||
{(link) => (
|
||||
<a data-slot="mobile-menu-item" href={link.href} onClick={() => setMenuOpen(false)}>
|
||||
<a data-slot="mobile-menu-item" href={localHref(link.href)} onClick={() => setMenuOpen(false)}>
|
||||
{link.label}
|
||||
</a>
|
||||
)}
|
||||
@@ -207,67 +208,73 @@ export function Footer(props: {
|
||||
onThemePreferenceChange: (preference: ThemePreference) => void
|
||||
links?: readonly HeaderLink[]
|
||||
}) {
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
const [subscribeOpen, setSubscribeOpen] = createSignal(false)
|
||||
const localHref = (href: string) => (href.startsWith("/") ? language.route(href) : href)
|
||||
const modelStats = props.links ?? [
|
||||
{ href: "#top-models", label: "Top Models" },
|
||||
{ href: "#leaderboard", label: "Leaderboard" },
|
||||
{ href: "#session-cost", label: "Session Cost" },
|
||||
{ href: "#token-cost", label: "Token Cost" },
|
||||
{ href: "#cache-ratio", label: "Cache Ratio" },
|
||||
{ href: "#market-share", label: "Market Share" },
|
||||
{ href: "#geo-breakdown", label: "Geo Breakdown" },
|
||||
{ href: "#top-models", label: i18n.t("nav.topModels") },
|
||||
{ href: "#leaderboard", label: i18n.t("nav.leaderboard") },
|
||||
{ href: "#session-cost", label: i18n.t("nav.sessionCost") },
|
||||
{ href: "#token-cost", label: i18n.t("nav.tokenCost") },
|
||||
{ href: "#cache-ratio", label: i18n.t("nav.cacheRatio") },
|
||||
{ href: "#market-share", label: i18n.t("nav.marketShare") },
|
||||
{ href: "#geo-breakdown", label: i18n.t("nav.geoBreakdown") },
|
||||
]
|
||||
const legal = [
|
||||
{ href: "https://opencode.ai/legal/terms-of-service", label: "Terms of service" },
|
||||
{ href: "https://opencode.ai/legal/privacy-policy", label: "Privacy policy" },
|
||||
{ href: "https://opencode.ai/legal/terms-of-service", label: i18n.t("footer.terms") },
|
||||
{ href: "https://opencode.ai/legal/privacy-policy", label: i18n.t("footer.privacy") },
|
||||
]
|
||||
const connect = [
|
||||
{ href: "mailto:hello@opencode.ai", label: "Contact us" },
|
||||
{ href: "https://opencode.ai/discord", label: "Community" },
|
||||
{ href: "mailto:hello@opencode.ai", label: i18n.t("footer.contact") },
|
||||
{ href: "https://opencode.ai/discord", label: i18n.t("footer.community") },
|
||||
{ href: "https://x.com/opencode", label: "X" },
|
||||
githubLink,
|
||||
{ href: "https://www.youtube.com/@anomaly-co", label: "YouTube" },
|
||||
{ href: githubLink.href, label: i18n.t("header.github") },
|
||||
{ href: "https://www.youtube.com/@anomaly-co", label: i18n.t("footer.youtube") },
|
||||
]
|
||||
|
||||
return (
|
||||
<footer data-component="footer">
|
||||
<SectionBridge label="GEO BREAKDOWN" href="#geo-breakdown" />
|
||||
<SectionBridge label={i18n.t("nav.geoBreakdown").toUpperCase()} href="#geo-breakdown" />
|
||||
<div data-slot="footer-grid">
|
||||
<a data-slot="footer-mark" href="https://opencode.ai" aria-label="OpenCode home">
|
||||
<a data-slot="footer-mark" href="https://opencode.ai" aria-label={i18n.t("footer.homeAria")}>
|
||||
<OpenCodeMark />
|
||||
</a>
|
||||
<FooterColumn title="Model Data" links={modelStats} />
|
||||
<FooterColumn title="Legal" links={legal} />
|
||||
<FooterColumn title="Connect" links={connect} />
|
||||
<FooterColumn title={i18n.t("footer.modelData")} links={modelStats} localHref={localHref} />
|
||||
<FooterColumn title={i18n.t("footer.legal")} links={legal} localHref={localHref} />
|
||||
<FooterColumn title={i18n.t("footer.connect")} links={connect} localHref={localHref} />
|
||||
<div data-slot="footer-column">
|
||||
<h2>Newsletter</h2>
|
||||
<p>Be the first to know about new releases.</p>
|
||||
<h2>{i18n.t("footer.newsletter")}</h2>
|
||||
<p>{i18n.t("footer.newsletterBody")}</p>
|
||||
<button data-slot="subscribe-button" type="button" onClick={() => setSubscribeOpen(true)}>
|
||||
Subscribe
|
||||
{i18n.t("footer.subscribe")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="footer-pattern" aria-hidden="true" />
|
||||
<div data-slot="footer-bottom">
|
||||
<div>
|
||||
<span>© 2026 Anomaly Innovations Inc.</span>
|
||||
<span data-slot="status">All systems Operational</span>
|
||||
<span>{i18n.t("footer.copyright")}</span>
|
||||
<span data-slot="status">{i18n.t("footer.status")}</span>
|
||||
</div>
|
||||
<div data-slot="theme-toggle" role="group" aria-label="Theme">
|
||||
<For each={themePreferences}>
|
||||
{(preference) => (
|
||||
<button
|
||||
data-slot="theme-option"
|
||||
type="button"
|
||||
aria-label={themePreferenceLabels[preference]}
|
||||
aria-pressed={props.themePreference === preference ? "true" : "false"}
|
||||
title={themePreferenceLabels[preference]}
|
||||
onClick={() => props.onThemePreferenceChange(preference)}
|
||||
>
|
||||
<ThemePreferenceIcon preference={preference} />
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
<div data-slot="footer-controls">
|
||||
<FooterLanguageSwitcher />
|
||||
<div data-slot="theme-toggle" role="group" aria-label={i18n.t("theme.groupLabel")}>
|
||||
<For each={themePreferences}>
|
||||
{(preference) => (
|
||||
<button
|
||||
data-slot="theme-option"
|
||||
type="button"
|
||||
aria-label={i18n.t(themePreferenceLabelKey(preference))}
|
||||
aria-pressed={props.themePreference === preference ? "true" : "false"}
|
||||
title={i18n.t(themePreferenceLabelKey(preference))}
|
||||
onClick={() => props.onThemePreferenceChange(preference)}
|
||||
>
|
||||
<ThemePreferenceIcon preference={preference} />
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={subscribeOpen()}>
|
||||
@@ -277,10 +284,38 @@ export function Footer(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function FooterLanguageSwitcher() {
|
||||
const i18n = useI18n()
|
||||
const language = useLanguage()
|
||||
|
||||
return (
|
||||
<label data-slot="footer-language">
|
||||
<span>{i18n.t("footer.language")}</span>
|
||||
<select
|
||||
value={language.locale()}
|
||||
aria-label={i18n.t("footer.language")}
|
||||
onChange={(event) => {
|
||||
const locale = event.currentTarget.value as Locale
|
||||
const url = new URL(window.location.href)
|
||||
const current = `${url.pathname}${url.search}${url.hash}`
|
||||
const href = `${route(locale, url.pathname)}${url.search}${url.hash}`
|
||||
language.setLocale(locale)
|
||||
if (href !== current) window.location.assign(href)
|
||||
}}
|
||||
>
|
||||
<For each={language.locales}>
|
||||
{(locale) => <option value={locale}>{language.label(locale)}</option>}
|
||||
</For>
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionBridge(props: { label: string; href: string }) {
|
||||
const i18n = useI18n()
|
||||
return (
|
||||
<a data-component="section-bridge" href={props.href}>
|
||||
<span>LEAN MORE</span>
|
||||
<span>{i18n.t("bridge.learnMore")}</span>
|
||||
<i />
|
||||
<strong>{props.label}</strong>
|
||||
<b>▸</b>
|
||||
@@ -355,6 +390,7 @@ function ThemePreferenceIcon(props: { preference: ThemePreference }) {
|
||||
}
|
||||
|
||||
function SubscribeModal(props: { onClose: () => void }) {
|
||||
const i18n = useI18n()
|
||||
const [status, setStatus] = createSignal<"idle" | "pending" | "success" | "error">("idle")
|
||||
const [message, setMessage] = createSignal("")
|
||||
let input: HTMLInputElement | undefined
|
||||
@@ -386,7 +422,7 @@ function SubscribeModal(props: { onClose: () => void }) {
|
||||
<div data-slot="modal-panel">
|
||||
<div data-slot="modal-brand">
|
||||
<img data-slot="modal-logo" src={opencodeWordmarkDark} alt="OpenCode" />
|
||||
<button data-slot="modal-close" type="button" aria-label="Close newsletter signup" onClick={props.onClose}>
|
||||
<button data-slot="modal-close" type="button" aria-label={i18n.t("modal.closeNewsletter")} onClick={props.onClose}>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path d="M4.44 4.44L11.56 11.56M11.56 4.44L4.44 11.56" stroke="currentColor" />
|
||||
</svg>
|
||||
@@ -394,12 +430,8 @@ function SubscribeModal(props: { onClose: () => void }) {
|
||||
</div>
|
||||
<div data-slot="modal-body">
|
||||
<div data-slot="modal-intro">
|
||||
<h2 id="subscribe-title">OpenCode Newsletter</h2>
|
||||
<p>
|
||||
Be the first to know
|
||||
<br />
|
||||
about new releases.
|
||||
</p>
|
||||
<h2 id="subscribe-title">{i18n.t("modal.title")}</h2>
|
||||
<p>{i18n.t("modal.body")}</p>
|
||||
</div>
|
||||
<form
|
||||
data-slot="subscribe-form"
|
||||
@@ -419,24 +451,24 @@ function SubscribeModal(props: { onClose: () => void }) {
|
||||
setStatus("success")
|
||||
return
|
||||
}
|
||||
setMessage(await newsletterErrorMessage(response))
|
||||
setMessage(await newsletterErrorMessage(response, i18n.t("modal.error")))
|
||||
setStatus("error")
|
||||
},
|
||||
() => {
|
||||
setMessage("Failed to subscribe")
|
||||
setMessage(i18n.t("modal.error"))
|
||||
setStatus("error")
|
||||
},
|
||||
)
|
||||
}}
|
||||
>
|
||||
<input ref={input} type="email" name="email" placeholder="Email address" required />
|
||||
<input ref={input} type="email" name="email" placeholder={i18n.t("modal.email")} required />
|
||||
<button type="submit" disabled={status() === "pending"}>
|
||||
<span>{status() === "pending" ? "Subscribing..." : "Subscribe"}</span>
|
||||
<span>{status() === "pending" ? i18n.t("modal.subscribing") : i18n.t("modal.subscribe")}</span>
|
||||
</button>
|
||||
</form>
|
||||
<div data-slot="subscribe-feedback" aria-live="polite">
|
||||
<Show when={status() === "success"}>
|
||||
<p data-state="success">You're subscribed.</p>
|
||||
<p data-state="success">{i18n.t("modal.success")}</p>
|
||||
</Show>
|
||||
<Show when={status() === "error"}>
|
||||
<p data-state="error">{message()}</p>
|
||||
@@ -448,24 +480,26 @@ function SubscribeModal(props: { onClose: () => void }) {
|
||||
)
|
||||
}
|
||||
|
||||
function newsletterErrorMessage(response: Response) {
|
||||
function newsletterErrorMessage(response: Response, fallback: string) {
|
||||
return response.json().then(
|
||||
(body: unknown) =>
|
||||
body && typeof body === "object" && "error" in body && typeof body.error === "string"
|
||||
? body.error
|
||||
: "Failed to subscribe",
|
||||
() => "Failed to subscribe",
|
||||
body && typeof body === "object" && "error" in body && typeof body.error === "string" ? body.error : fallback,
|
||||
() => fallback,
|
||||
)
|
||||
}
|
||||
|
||||
function FooterColumn(props: { title: string; links: readonly { href: string; label: string }[] }) {
|
||||
function FooterColumn(props: {
|
||||
title: string
|
||||
links: readonly { href: string; label: string }[]
|
||||
localHref: (href: string) => string
|
||||
}) {
|
||||
return (
|
||||
<div data-slot="footer-column">
|
||||
<h2>{props.title}</h2>
|
||||
<nav aria-label={props.title}>
|
||||
<For each={props.links}>
|
||||
{(link) => (
|
||||
<a href={link.href} target={link.href.startsWith("http") ? "_blank" : undefined} rel="noreferrer">
|
||||
<a href={props.localHref(link.href)} target={link.href.startsWith("http") ? "_blank" : undefined} rel="noreferrer">
|
||||
{link.label}
|
||||
</a>
|
||||
)}
|
||||
@@ -474,3 +508,9 @@ function FooterColumn(props: { title: string; links: readonly { href: string; la
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function themePreferenceLabelKey(preference: ThemePreference) {
|
||||
if (preference === "dark") return "theme.dark"
|
||||
if (preference === "light") return "theme.light"
|
||||
return "theme.system"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user