MeterChart
A chart component that renders the rows you pass as data: data + series go in, core’s createChartModel builds a headless model, and ECharts renders it. The rows themselves come from the meter service — query analysis/query (or usage/query) through the client, map the response into rows, and hand them to the chart.
Loading interactive preview…
Loading interactive preview…
View code
Actual React source for this preview, including demo controls, shared styles and isolated mocks. The docs host supplies the client and theme; application installation and React / Vue usage follow below.
import { useEffect, useState } from "react"import { MeterChart } from "@meterry/component-react"import type { MeterThemeInput, MeterFilterValues,} from "@meterry/component"import { fetchPreviewTrend, type PreviewEnvironment, type TrendRow,} from "./preview-data"import { EMPTY_FILTERS, styleProps, trendSeries } from "./preview-shared"
export function ChartPreview({ environment, theme, filters = EMPTY_FILTERS,}: { environment: PreviewEnvironment theme: MeterThemeInput filters?: MeterFilterValues}) { const [type, setType] = useState("line") const [visibleSeries, setVisibleSeries] = useState("both") const [showLegend, setShowLegend] = useState(true) const [showGrid, setShowGrid] = useState(true) const [state, setState] = useState<{ rows: TrendRow[] loading: boolean error?: Error }>({ rows: [], loading: true }) const filtersKey = JSON.stringify(filters) useEffect(() => { const abort = new AbortController() setState({ rows: [], loading: true }) void fetchPreviewTrend(environment, JSON.parse(filtersKey), abort.signal) .then((rows) => { if (!abort.signal.aborted) setState({ rows, loading: false }) }) .catch((error: unknown) => { if (!abort.signal.aborted) setState({ rows: [], loading: false, error: error instanceof Error ? error : new Error(String(error)), }) }) return () => abort.abort() }, [environment, filtersKey]) return ( <div data-testid="chart-preview"> <label className="docs-preview-chart-select"> Chart type <select value={type} onChange={(event) => setType(event.target.value)}> <option value="line">Line</option> <option value="bar">Bar</option> <option value="area">Area</option> </select> </label> <div className="docs-preview-actions docs-preview-chart-controls"> <label className="docs-preview-chart-select"> Chart series <select value={visibleSeries} onChange={(event) => setVisibleSeries(event.target.value)} > <option value="both">Input and output</option> <option value="input">Input only</option> <option value="output">Output only</option> </select> </label> <label className="docs-preview-chart-select"> <input type="checkbox" checked={showLegend} onChange={(event) => setShowLegend(event.target.checked)} /> Show legend </label> <label className="docs-preview-chart-select"> <input type="checkbox" checked={showGrid} onChange={(event) => setShowGrid(event.target.checked)} /> Show grid </label> </div> {state.loading ? ( <p className="docs-preview-status" role="status"> Loading chart… </p> ) : state.error ? ( <p className="docs-preview-status" role="alert"> {state.error.message} </p> ) : ( <MeterChart {...styleProps} theme={theme} type={type} title="Token usage · last 7 days" data={state.rows} x="bucket" height={260} showLegend={showLegend} showGrid={showGrid} series={trendSeries.filter( (series) => visibleSeries === "both" || series.key === visibleSeries )} /> )} </div> )}import { useSyncExternalStore } from "react"import { createEChartsSeriesRegistry, type ChartSeries, type MeterClassNames, type MeterFilterValues, type TableColumn, type UsageTableRow,} from "@meterry/component"import { METRICS, MODELS, type PreviewEnvironment, type PreviewRow, type TrendRow,} from "./preview-data"
export const EMPTY_FILTERS: MeterFilterValues = {}const classNames: MeterClassNames = { root: "docs-preview-root", card: "docs-preview-card", header: "docs-preview-header", caption: "docs-preview-caption", title: "docs-preview-title", body: "docs-preview-body", toolbar: "docs-preview-filter-bar", filterBar: "docs-preview-filter-bar", filter: "docs-preview-filter", table: "docs-preview-table", list: "docs-preview-list", listFooter: "docs-preview-pagination", pagination: "docs-preview-pagination", actions: "docs-preview-actions", button: "docs-preview-button", chart: "docs-preview-chart", value: "docs-preview-value", unit: "docs-preview-unit", empty: "docs-preview-status", loading: "docs-preview-status", error: "docs-preview-status",}export const styleProps = { unstyled: true, classNames }// Adapt only preview label colors to Starlight, without mutating the global registry.export const previewSeriesRegistry = createEChartsSeriesRegistry()for (const type of ["pie", "doughnut"]) { const builtin = previewSeriesRegistry.builder(type) previewSeriesRegistry.register( type, (series, index, model) => ({ ...builtin(series, index, model), label: { color: model.theme.colors.text, textBorderWidth: 0, }, }), { polar: true } )}
export const columns: TableColumn<PreviewRow>[] = [ { key: "bucket", header: "Date", sortable: true }, { key: "model", header: "Model", sortable: true, filter: { type: "select", options: MODELS.map((value) => ({ label: value, value })), }, }, { key: "provider", header: "Provider" }, { key: "metric", header: "Metric", filter: { type: "select", options: METRICS.map((value) => ({ label: value, value })), }, }, { key: "quantity", header: "Quantity", sortable: true, align: "right", filter: { type: "number", placeholder: "Minimum quantity" }, format: (value) => Number(value).toLocaleString("en-US"), }, { key: "amount", header: "Amount (USD)", sortable: true, align: "right", format: (value) => `$${Number(value).toFixed(4)}`, },]// Shared Model/Provider controls and table-local Metric/Quantity controls are independent.export const sharedColumns = columns.map((column) => column.key === "model" ? { ...column, filter: undefined } : column)
export const trendSeries: ChartSeries<TrendRow>[] = [ { key: "input", label: "Input tokens", value: "input_tokens" }, { key: "output", label: "Output tokens", value: "output_tokens" },]
// API identity is raw_event_id; override the snapshot's legacy default event column.export const eventColumns: TableColumn<UsageTableRow>[] = [ { key: "raw_event_id", header: "Event ID" }, { key: "occurred_at", header: "Occurred at", format: (value) => new Date(Number(value) * 1000).toISOString().slice(0, 10), }, { key: "source", header: "Source" }, { key: "model", header: "Model", accessor: (row) => (row.labels as Record<string, string> | undefined)?.model, },]
export function RequestSummary({ environment,}: { environment: PreviewEnvironment}) { useSyncExternalStore( environment.subscribeRequests, () => environment.requests.length, () => 0 ) const details = environment.requests.filter((request) => request.path.endsWith("/details") ).length const trend = environment.requests.filter((request) => request.path.endsWith("/analysis/query") ).length const aggregate = environment.requests.filter((request) => request.path.endsWith("/usage/query") ).length return ( <output className="docs-preview-filter-summary" data-testid="request-count"> Mock API requests: {environment.requests.length} · Events: {details} · Trend: {trend} · Aggregate: {aggregate} </output> )}import { SubjectClient, createSubjectTokenProvider, mergeMeterFilters, type AnalysisQueryRequest, type AnalysisQueryResponse, type FetchLike, type MeterFilterValues, type TableQuery, type TableResult, type UsageQueryRequest, type UsageQueryResponse,} from "@meterry/component"
export const DAY = 86_400export const MODELS = ["gpt-4o-mini", "claude-sonnet", "glm-5.1"] as constexport const PROVIDERS = ["openai", "anthropic", "r9s"] as constexport const METRICS = ["input_tokens", "output_tokens"] as constexport type PreviewScenario = "sample" | "empty" | "error"export type PreviewRow = { bucket: string model: string provider: string metric: string quantity: number amount: number}export type TrendRow = { bucket: string input_tokens: number output_tokens: number}export type PreviewRequest = { path: string; afterId?: string }export type PreviewEnvironment = { requests: PreviewRequest[] subscribeRequests: (listener: () => void) => () => void failNextRequest: () => void client: SubjectClient startTime: number endTime: number}
/** Synthetic browser-only fixtures, not production usage or pricing. */export function createFixture(now = Date.now()) { const today = Math.floor(now / 1000 / DAY) * DAY const startTime = today - 6 * DAY const rows: PreviewRow[] = [] for (let day = 0; day < 7; day++) { for (let model = 0; model < MODELS.length; model++) { for (let metric = 0; metric < METRICS.length; metric++) { const quantity = 1000 + day * 140 + model * 600 + metric * 400 rows.push({ bucket: new Date((startTime + day * DAY) * 1000) .toISOString() .slice(0, 10), model: MODELS[model]!, provider: PROVIDERS[model]!, metric: METRICS[metric]!, quantity, amount: Number((quantity * 0.000002).toFixed(6)), }) } } } return { rows, startTime, endTime: Math.floor(now / 1000) + 1 }}
function selectRows( rows: PreviewRow[], request: UsageQueryRequest | AnalysisQueryRequest) { const metrics = request.metrics?.length ? request.metrics : METRICS return rows.filter((row) => { const timestamp = Date.parse(row.bucket) / 1000 if ( timestamp < (request.start_time ?? -Infinity) || timestamp >= (request.end_time ?? Infinity) ) return false if (!metrics.includes(row.metric as (typeof METRICS)[number])) return false return Object.entries(request.filters ?? {}).every(([key, value]) => { if (value === "") return true if (key === "quantity" || key === "amount") { return Number.isFinite(Number(value)) && row[key] >= Number(value) } return key in row && String(row[key as keyof PreviewRow]) === value }) })}
function analysisResponse( rows: PreviewRow[], request: AnalysisQueryRequest): AnalysisQueryResponse { const groups = new Map< string, { metric: string measure: string model?: string points: Map<string, number> } >() for (const row of selectRows(rows, request)) { for (const measure of request.measures) { const model = request.group_by?.includes("model") ? row.model : undefined const key = [row.metric, measure, model].filter(Boolean).join(":") const group = groups.get(key) ?? { metric: row.metric, measure, model, points: new Map(), } const value = measure === "amount" ? row.amount : measure === "item_count" ? 1 : row.quantity group.points.set(row.bucket, (group.points.get(row.bucket) ?? 0) + value) groups.set(key, group) } } let remaining = request.limit ?? Infinity const series: AnalysisQueryResponse["series"] = [] for (const [key, group] of groups) { const points = [...group.points] .sort(([a], [b]) => a.localeCompare(b)) .slice(0, remaining) .map(([bucket, value]) => ({ bucket, value })) remaining -= points.length if (points.length) series.push({ key, label: group.metric, measure: group.measure, dimensions: { metric: group.metric, ...(group.model ? { model: group.model } : {}), }, points, }) } return { group_by: request.group_by ?? ["bucket"], measures: request.measures, series, meta: { timezone: "UTC", bucket_size: request.bucket_size ?? "1d", start_time: request.start_time, end_time: request.end_time, }, }}
function usageResponse( rows: PreviewRow[], request: UsageQueryRequest): UsageQueryResponse { const dimensions = request.group_by ?? ["bucket", "model", "metric"] const groups = new Map< string, { dimensions: Record<string, string>; measures: Record<string, number> } >() for (const row of selectRows(rows, request)) { const values = Object.fromEntries( dimensions.map((key) => [key, String(row[key as keyof PreviewRow] ?? "")]) ) const key = JSON.stringify(values) const group = groups.get(key) ?? { dimensions: values, measures: {} } for (const measure of request.measures ?? ["quantity"]) { const value = measure === "amount" ? row.amount : measure === "item_count" ? 1 : row.quantity group.measures[measure] = (group.measures[measure] ?? 0) + value } groups.set(key, group) } return { rows: [...groups.values()].slice(0, request.limit ?? groups.size) }}
function delay( milliseconds: number, signal?: AbortSignal | null): Promise<void> { return new Promise((resolve, reject) => { const abort = () => { clearTimeout(timer) signal?.removeEventListener("abort", abort) reject(new DOMException("Preview request aborted", "AbortError")) } const timer = setTimeout(() => { signal?.removeEventListener("abort", abort) resolve() }, milliseconds) if (signal?.aborted) abort() else signal?.addEventListener("abort", abort, { once: true }) })}
/** Every request stays in this fetch adapter; no network or stored credentials. */export function createPreviewEnvironment( options: { scenario?: PreviewScenario now?: number delayMs?: number } = {}): PreviewEnvironment { const fixture = createFixture(options.now) const rows = options.scenario === "empty" ? [] : fixture.rows const requests: PreviewRequest[] = [] const listeners = new Set<() => void>() let failNext = false const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" }, }) const fetch: FetchLike = async (input, init) => { const url = new URL( typeof input === "string" ? input : input instanceof URL ? input.href : input.url, "https://docs-preview.invalid" ) requests.push({ path: url.pathname, afterId: url.searchParams.get("after_id") ?? undefined, }) for (const listener of listeners) listener() await delay(options.delayMs ?? 180, init?.signal) if ( url.origin !== "https://docs-preview.invalid" || ![ "/v1/subject/analysis/query", "/v1/subject/usage/query", "/v1/subject/usage/events/details", ].includes(url.pathname) ) return json({ message: "Unknown preview endpoint" }, 404) if (options.scenario === "error" || failNext) { failNext = false return json({ message: "Simulated preview request failure" }, 503) } if (url.pathname.endsWith("/details")) { if (init?.method !== "GET") return json({ message: "Preview details require GET" }, 405) const request: UsageQueryRequest = { start_time: Number(url.searchParams.get("start_time")) || undefined, end_time: Number(url.searchParams.get("end_time")) || undefined, metrics: url.searchParams .getAll("metrics") .flatMap((value) => value.split(",")), metric: url.searchParams.get("metric") ?? undefined, } if (!request.metrics?.length && request.metric) request.metrics = [request.metric] const events = selectRows(rows, request).map((row) => ({ raw_event_id: `raw_preview_${String(fixture.rows.indexOf(row) + 1).padStart(4, "0")}`, occurred_at: Date.parse(row.bucket) / 1000, source: "documentation", subject_type: "user", subject_id: "documentation-preview", labels: { model: row.model, provider: row.provider }, metrics: { [row.metric]: { value: String(row.quantity), amount: String(row.amount), currency: "USD", }, }, })) const after = url.searchParams.get("after_id") const cursorIndex = after ? events.findIndex((event) => event.raw_event_id === after) : -1 if (after && cursorIndex < 0) return json({ message: "Invalid preview cursor" }, 400) const start = cursorIndex + 1 const limit = Math.min( 1000, Math.max(1, Number(url.searchParams.get("limit")) || 100) ) const page = events.slice(start, start + limit) return json({ usage_events: page, total: events.length, ...(start + page.length < events.length ? { next_after_id: page.at(-1)?.raw_event_id } : {}), }) } if (init?.method !== "POST") return json({ message: "Preview query requires POST" }, 405) let body: UsageQueryRequest | AnalysisQueryRequest try { body = JSON.parse(String(init?.body ?? "{}")) } catch { return json({ message: "Invalid preview query" }, 400) } if (!Array.isArray(body.measures)) return json({ message: "Missing preview measures" }, 400) return json( url.pathname.endsWith("/analysis/query") ? analysisResponse(rows, body as AnalysisQueryRequest) : usageResponse(rows, body) ) } const provider = createSubjectTokenProvider( async () => ({ token_type: "Bearer", access_token: "documentation-preview-only", expires_in: 900, expires_at: Math.floor(Date.now() / 1000) + 900, subject: { type: "user", id: "documentation-preview" }, rules: ["analysis"], }), { tokenStorage: null } ) return { requests, subscribeRequests: (listener) => { listeners.add(listener) return () => { listeners.delete(listener) } }, failNextRequest: () => { failNext = true }, client: new SubjectClient(provider, { baseUrl: "https://docs-preview.invalid", credentials: "omit", fetch, }), startTime: fixture.startTime, endTime: fixture.endTime, }}
export async function fetchPreviewTrend( environment: PreviewEnvironment, filters: MeterFilterValues = {}, signal?: AbortSignal): Promise<TrendRow[]> { const response = await environment.client.analysisQuery( { bucket_size: "1d", start_time: environment.startTime, end_time: environment.endTime, metrics: [...METRICS], group_by: ["bucket"], measures: ["quantity"], filters, }, { signal } ) const byBucket = new Map<string, TrendRow>() for (const series of response.series) { const metric = series.dimensions?.metric if (metric !== "input_tokens" && metric !== "output_tokens") continue for (const point of series.points) { const row = byBucket.get(point.bucket) ?? { bucket: point.bucket, input_tokens: 0, output_tokens: 0, } row[metric] += Number(point.value) byBucket.set(point.bucket, row) } } return [...byBucket.values()].sort((a, b) => a.bucket.localeCompare(b.bucket))}
export async function loadPreviewRows( environment: PreviewEnvironment, query: TableQuery, shared: MeterFilterValues = {}): Promise<TableResult<PreviewRow>> { const response = await environment.client.usageQuery({ bucket_size: "1d", start_time: environment.startTime, end_time: environment.endTime, metrics: [...METRICS], group_by: ["bucket", "model", "provider", "metric"], measures: ["quantity", "amount"], filters: mergeMeterFilters(query.filters, shared), limit: 500, }) const rows: PreviewRow[] = response.rows.map((row) => ({ bucket: String(row.dimensions.bucket), model: String(row.dimensions.model), provider: String(row.dimensions.provider), metric: String(row.dimensions.metric), quantity: Number(row.measures.quantity), amount: Number(row.measures.amount), })) if (query.sort) { const { key, direction } = query.sort rows.sort((a, b) => { const left = a[key as keyof PreviewRow], right = b[key as keyof PreviewRow] const order = typeof left === "number" && typeof right === "number" ? left - right : String(left).localeCompare(String(right)) return direction === "desc" ? -order : order }) } return { total: rows.length, rows: rows.slice( (query.page - 1) * query.pageSize, query.page * query.pageSize ), }}Installation
Section titled “Installation”npm install @meterry/component @meterry/component-react echarts echarts-for-reactnpm install @meterry/component @meterry/component-vue echarts vue-echartsFor default styles, optionally install @meterry/component-tailwindcss. For authentication, shared clients and theming, see Getting Started.
The examples below query the Subject API through a shared client, map the response into rows, and render those rows. See Getting Started for the complete query-to-chart wiring.
Fetch rows from the meter service, then render:
// React: fetch with the client, map, renderfunction DailyTokensChart() { const client = useMeterClient() const [rows, setRows] = useState([])
useEffect(() => { if (!client) return const end = Math.floor(Date.now() / 1000) client .analysisQuery({ bucket_size: '1d', start_time: end - 14 * 86_400, end_time: end, metrics: ['input_tokens', 'output_tokens'], group_by: ['bucket'], measures: ['quantity'], }) .then((response) => setRows(pivotByBucket(response))) // series → rows .catch((error) => console.error(error)) }, [client])
return ( <MeterChart type="line" title="Token usage · last 14 days" data={rows} x="bucket" series={[ { key: 'input', label: 'Input tokens', value: 'input_tokens' }, { key: 'output', label: 'Output tokens', value: 'output_tokens' }, ]} /> )}<!-- Vue 3 --><script setup>import { onMounted, ref } from 'vue'import { useMeterClient } from '@meterry/component-vue'
const client = useMeterClient()const rows = ref([])
onMounted(async () => { const end = Math.floor(Date.now() / 1000) const response = await client.analysisQuery({ bucket_size: '1d', start_time: end - 14 * 86_400, end_time: end, metrics: ['input_tokens', 'output_tokens'], group_by: ['bucket'], measures: ['quantity'], }) rows.value = pivotByBucket(response) // series → rows})</script>
<template> <MeterChart type="line" title="Token usage · last 14 days" :data="rows" x="bucket" :series="[ { key: 'input', label: 'Input tokens', value: 'input_tokens' }, { key: 'output', label: 'Output tokens', value: 'output_tokens' }, ]" /></template>| Prop | Type | Default | Description |
|---|---|---|---|
type |
ChartType |
— (required) | 'line' | 'bar' | 'area' | 'pie' | 'doughnut' or a registered custom type |
data |
T[] |
— (required) | Row data — typically the mapped result of a client query |
series |
ChartSeries<T>[] |
— (required) | { key, label?, value, color?, format? }; value accepts a field name or (row, index) => unknown |
x |
ChartAccessor<T> |
row index | x-axis field or accessor function |
title |
string |
messages.chartTitle |
Title (also used as the chart’s aria-label) |
height |
number |
300 |
Chart height in px |
showLegend |
boolean |
series.length > 1 |
Whether to show the legend |
showGrid |
boolean |
true |
Whether to show the grid |
theme |
MeterThemeInput |
'light' |
Chart palette (pair with CSS variables to reskin the whole card, see Theming & Styles) |
caption |
string |
messages.caption |
Small label above the title |
slots |
MeterChartSlots |
— | Replace the header / chart rendering |
messages |
Partial<MeterMessages> |
— | Copy overrides |
seriesRegistry |
EChartsSeriesRegistry |
global registry | Scoped chart-type registry |
className / classNames / unstyled |
— | — | Styling |
Display options and value formatting
Section titled “Display options and value formatting”Choose the chart type with type and the measures to display with series. Use a descriptive key for each measure, such as input_tokens or amount. To show a different dataset, pass the new rows through data.
showGrid={false} (Vue: :show-grid="false") hides Cartesian split lines; showLegend={false} hides the legend. Try Chart series, Show grid, and Show legend in the preview to choose which measures and visual guides are shown.
Use series.format to make tooltip values easier to read, for example by showing an amount with its currency. Formatting changes the display, not the usage values:
<MeterChart type="bar" data={rows} x="bucket" series={[{ key: 'amount', label: 'Amount (USD)', value: 'amount', format: (value, row) => `${Number(value).toFixed(4)} USD · ${row.bucket}`, }]}/>To add a chart type beyond the built-in options, see Chart Extension & Registries.
Empty data
Section titled “Empty data”When data is empty or series has no drawable points, the messages.chartEmpty empty state is rendered. Query a window with no usage (for example a future range) to exercise it against the live service.
Custom types
Section titled “Custom types”type is an open union: register an ECharts mapping with registerEChartsSeries(type, builder, { modules?, polar? }), or register a pure SVG renderer with registerChartSvgRenderer (for ECharts-free setups). See Chart Extension & Registries.
Headless usage
Section titled “Headless usage”When you want the model without the component:
import { createChartModel, createEChartsOption, renderChartSvg } from '@meterry/component'
// rows: mapped from a client query (see Getting Started)const model = createChartModel({ type: 'line', data: rows, x: 'bucket', series })const option = createEChartsOption(model) // feeds echarts directlyconst svg = renderChartSvg(model) // pure SVG string (register a renderer or use a built-in type)