diff --git a/frontend/src/components/charts/timeSeriesGap.ts b/frontend/src/components/charts/timeSeriesGap.ts new file mode 100644 index 000000000..05cb773fc --- /dev/null +++ b/frontend/src/components/charts/timeSeriesGap.ts @@ -0,0 +1,121 @@ +import type { LineSeriesOption } from 'echarts' + +type TemporalValue = number | null | undefined + +interface GapRun { + startIndex: number + endIndex: number +} + +interface GapBridgeOptions { + name: string + color: string + xAxisIndex?: number + yAxisIndex?: number + width?: number + z?: number + maxGapBuckets?: number +} + +const defaultShortGapBuckets = 2 + +export function sampleGapBridgeSeries( + values: TemporalValue[], + options: GapBridgeOptions, +): LineSeriesOption[] { + const data = values.map(normalizeValue) + const maxGapBuckets = options.maxGapBuckets ?? defaultShortGapBuckets + const series: LineSeriesOption[] = [] + + for (const [runIndex, run] of boundedGapRuns(data) + .filter(run => gapLength(run) <= maxGapBuckets) + .entries()) { + const beforeIndex = run.startIndex - 1 + const afterIndex = run.endIndex + 1 + const beforeValue = data[beforeIndex] + const afterValue = data[afterIndex] + const bridge = Array.from({ length: data.length }).fill(null) + + if (beforeValue == null || afterValue == null) + continue + + const span = afterIndex - beforeIndex + for (let index = beforeIndex; index <= afterIndex; index += 1) { + const progress = (index - beforeIndex) / span + bridge[index] = beforeValue + (afterValue - beforeValue) * progress + } + + series.push({ + name: `${options.name}-gap-${runIndex}`, + type: 'line', + data: bridge, + connectNulls: false, + smooth: false, + showSymbol: false, + silent: true, + xAxisIndex: options.xAxisIndex ?? 0, + yAxisIndex: options.yAxisIndex ?? 0, + z: Math.max(0, (options.z ?? 2) - 1), + lineStyle: { + color: options.color, + type: 'dashed', + width: Math.max(1, (options.width ?? 2.2) - 0.7), + opacity: 0.32, + }, + itemStyle: { color: options.color, opacity: 0 }, + emphasis: { disabled: true }, + tooltip: { show: false }, + }) + } + + return series +} + +export function requestActivityByBucket( + buckets: string[], + activity: Array<{ bucket: string, totalRequests: number }>, +) { + const requestsByBucket = new Map( + activity.map(point => [point.bucket, Math.max(0, point.totalRequests)]), + ) + return buckets.map(bucket => (requestsByBucket.get(bucket) ?? 0) > 0) +} + +export function zeroInactiveValues( + values: TemporalValue[], + active: boolean[], +) { + return values.map((value, index) => normalizeValue(value) ?? (active[index] === false ? 0 : null)) +} + +function normalizeValue(value: TemporalValue) { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +function boundedGapRuns(values: Array) { + return gapRuns(values.map(value => value == null)).filter( + run => run.startIndex > 0 && run.endIndex < values.length - 1, + ) +} + +function gapRuns(isGap: boolean[]) { + const runs: GapRun[] = [] + let startIndex = -1 + + for (let index = 0; index <= isGap.length; index += 1) { + if (isGap[index] && startIndex < 0) { + startIndex = index + continue + } + if (isGap[index] || startIndex < 0) + continue + runs.push({ startIndex, endIndex: index - 1 }) + startIndex = -1 + } + + return runs +} + +function gapLength(run: GapRun) { + return run.endIndex - run.startIndex + 1 +} diff --git a/frontend/src/views/dashboard/composables/useDashboard.ts b/frontend/src/views/dashboard/composables/useDashboard.ts index 81f6a24fa..9e53e24ec 100644 --- a/frontend/src/views/dashboard/composables/useDashboard.ts +++ b/frontend/src/views/dashboard/composables/useDashboard.ts @@ -373,10 +373,10 @@ function aggregateUsageTrend(points: DashboardTrendPoint[]) { ) const uncachedInputTokensValue = inputTokensValue - cachedTokensValue const effectiveTokensValue = uncachedInputTokensValue + outputTokensValue - const cacheHitRateValue = inputTokensValue ? cachedTokensValue / inputTokensValue : null + const cacheHitRateValue = inputTokensValue ? cachedTokensValue / inputTokensValue : 0 const successRateValue = requestsValue ? ((requestsValue - errorsValue) / requestsValue) * 100 - : null + : 0 return { ...first, @@ -392,12 +392,12 @@ function aggregateUsageTrend(points: DashboardTrendPoint[]) { uncachedInputTokensValue, effectiveTokens: formatCompactNumber(effectiveTokensValue), effectiveTokensValue: effectiveTokensValue > 0 ? effectiveTokensValue : null, - cacheHitRate: cacheHitRateValue === null ? '—' : formatDashboardRate(cacheHitRateValue), + cacheHitRate: formatDashboardRate(cacheHitRateValue), cacheHitRateValue, tokensValue: inputTokensValue + outputTokensValue, errors: formatCompactNumber(errorsValue), errorsValue, - successRate: successRateValue === null ? '—' : `${successRateValue.toFixed(1)}%`, + successRate: `${successRateValue.toFixed(1)}%`, successRateValue, } }) diff --git a/frontend/src/views/dashboard/composables/useRequestTrendChart.ts b/frontend/src/views/dashboard/composables/useRequestTrendChart.ts index 23cc0f0b3..e5d4e5d8e 100644 --- a/frontend/src/views/dashboard/composables/useRequestTrendChart.ts +++ b/frontend/src/views/dashboard/composables/useRequestTrendChart.ts @@ -4,6 +4,7 @@ import type { dashboardTrendView, normalizeDashboardTrendKind } from './useDashb import { usePreferredReducedMotion } from '@vueuse/core' import { computed, shallowRef, watch } from 'vue' +import { sampleGapBridgeSeries } from '@/components/charts/timeSeriesGap' import { chartTooltipStyle, tooltipIndex } from '@/components/charts/tooltip' import { useChartPalette } from '@/composables/useChartPalette' @@ -197,7 +198,7 @@ export function useRequestTrendChart(options: { ), tooltipItem( '缓存命中', - trendPointText(point, 'cacheHitRate'), + zeroAwareRateText(point, 'cacheHitRate', 'inputTokensValue'), trendColor('缓存', '--cp-color-text-tertiary', '#94A3B8'), ), tooltipItem('请求', point?.requests, palette.value.textSecondary), @@ -235,19 +236,19 @@ export function useRequestTrendChart(options: { if (name === '错误数') return point?.errors ?? value if (name === '成功率') - return point?.successRate ?? value + return zeroAwareRateText(point, 'successRate', 'requestsValue') ?? value if (name === '总请求') return point?.requests ?? value return value } - function getSeries() { + function getSeries(): Array { if (activeKind.value === 'usage') { const cacheColor = trendColor('缓存', '--cp-color-text-tertiary', '#94A3B8') const inputColor = trendColor('输入', '--cp-color-blue-solid', '#5983F4') const outputColor = trendColor('输出', '--cp-color-green-solid', '#12B981') return [ - lineSeries('缓存', activeSeriesValues('cachedTokensValue'), cacheColor, { + lineSeries('缓存', seriesValues('cachedTokensValue'), cacheColor, { area: true, areaStartAlpha: '30', areaEndAlpha: '08', @@ -257,7 +258,7 @@ export function useRequestTrendChart(options: { width: 1.25, z: 1, }), - lineSeries('输入', activeSeriesValues('uncachedInputTokensValue'), inputColor, { + lineSeries('输入', seriesValues('uncachedInputTokensValue'), inputColor, { area: true, areaStartAlpha: '2A', areaEndAlpha: '05', @@ -266,7 +267,7 @@ export function useRequestTrendChart(options: { width: 2.3, z: 3, }), - lineSeries('输出', activeSeriesValues('outputTokensValue'), outputColor, { + lineSeries('输出', seriesValues('outputTokensValue'), outputColor, { smooth: 0.24, width: 2.1, yAxisIndex: 1, @@ -277,19 +278,19 @@ export function useRequestTrendChart(options: { if (activeKind.value === 'latency') { return [ - lineSeries( + ...gapLineSeries( '总耗时 P95', activeSeriesValues('latencyP95Ms'), trendColor('总耗时', '--cp-color-orange-solid', '#F59E0B'), { lineType: 'dashed', smooth: 0.2, width: 1.8, z: 2 }, ), - lineSeries( + ...gapLineSeries( '首字 P95', activeSeriesValues('firstTokenP95Ms'), trendColor('首字', '--cp-color-cyan-solid', '#13C2C2'), { area: true, smooth: 0.24, width: 2.5, z: 4 }, ), - lineSeries( + ...gapLineSeries( '吞吐 P50', activeSeriesValues('outputThroughputP50'), trendColor('吞吐', '--cp-color-green-solid', '#12B981'), @@ -304,9 +305,9 @@ export function useRequestTrendChart(options: { seriesValues('errorsValue'), trendColor('错误', '--cp-color-red-solid', '#EF4444'), ), - lineSeries( + ...gapLineSeries( '成功率', - seriesValues('successRateValue'), + activeSeriesValues('successRateValue'), trendColor('成功', '--cp-color-green-solid', '#12B981'), { width: 2.4, yAxisIndex: 1 }, ), @@ -325,7 +326,7 @@ export function useRequestTrendChart(options: { function activeSeriesValues(key: string) { return points.value.map(point => - Number(point.requestsValue) > 0 ? trendPointNumber(point, key) : null, + Number(point.requestsValue) > 0 ? trendPointNumber(point, key) : 0, ) } @@ -377,6 +378,26 @@ export function useRequestTrendChart(options: { } } + function gapLineSeries( + name: string, + data: (number | null)[], + color: string, + lineOptions: LineSeriesOptions = {}, + ) { + const primary = lineSeries(name, data, color, lineOptions) + return [ + primary, + ...sampleGapBridgeSeries(data, { + name, + color, + width: lineOptions.width, + xAxisIndex: lineOptions.xAxisIndex, + yAxisIndex: lineOptions.yAxisIndex, + z: lineOptions.z, + }), + ] + } + function barSeries( name: string, data: (number | null)[], @@ -490,7 +511,7 @@ function tooltipValue(source: unknown, key: string) { function trendPointProperty(point: TrendPoint | undefined, key: string) { if (!point || !(key in point)) return undefined - return (point as Record)[key] + return (point as unknown as Record)[key] } function trendPointText(point: TrendPoint | undefined, key: string) { @@ -498,6 +519,17 @@ function trendPointText(point: TrendPoint | undefined, key: string) { return typeof value === 'string' ? value : undefined } +function zeroAwareRateText( + point: TrendPoint | undefined, + valueKey: string, + activityKey: string, +) { + const activity = trendPointProperty(point, activityKey) + if (typeof activity === 'number' && activity <= 0) + return '0.0%' + return trendPointText(point, valueKey) +} + function trendPointNumber(point: TrendPoint, key: string) { const value = trendPointProperty(point, key) return typeof value === 'number' && Number.isFinite(value) ? value : null diff --git a/frontend/src/views/usage/components/UsageCostCard.vue b/frontend/src/views/usage/components/UsageCostCard.vue index 0ab69ef2c..b7d6251d9 100644 --- a/frontend/src/views/usage/components/UsageCostCard.vue +++ b/frontend/src/views/usage/components/UsageCostCard.vue @@ -7,6 +7,10 @@ import BaseCard from '@/components/base/BaseCard.vue' import BaseEmpty from '@/components/base/BaseEmpty.vue' import BaseSegmented from '@/components/base/BaseSegmented.vue' import BaseChart from '@/components/charts/BaseChart.vue' +import { + requestActivityByBucket, + zeroInactiveValues, +} from '@/components/charts/timeSeriesGap' import { useChartPalette } from '@/composables/useChartPalette' import { formatLocalizedCompactNumber as formatCompactNumber } from '@/utils/number' @@ -14,10 +18,11 @@ import { tooltipIndex, tooltipRows, usageCategoryAxis, + usageGapAwareLineSeries, usageLegend, - usageLineSeries, usageTooltip, usageTooltipContent, + usageTooltipItem, usageValueAxis, } from '../utils/chart' import { @@ -28,11 +33,13 @@ import { } from '../utils/format' type Cost = Awaited>['cost'] +type Activity = Awaited>['health']['points'] type UsageChartPalette = ReturnType['palette']['value'] const props = withDefaults( defineProps<{ cost: Cost + activity: Activity loading?: boolean }>(), { @@ -43,6 +50,10 @@ const props = withDefaults( const activeView = shallowRef('cost') const { palette } = useChartPalette() const points = computed(() => props.cost.points) +const requestActivity = computed(() => requestActivityByBucket( + points.value.map(point => point.bucket), + props.activity, +)) const hasNoCacheCost = computed(() => points.value.some(point => point.noCacheCost != null), ) @@ -74,6 +85,7 @@ const hasData = computed(() => { const chartOption = computed(() => { const theme = palette.value const legend = legendNames() + const series = chartSeries(theme) return { animationDuration: 240, @@ -95,7 +107,7 @@ const chartOption = computed(() => { min: activeView.value === 'cache' ? 0 : undefined, max: activeView.value === 'cache' ? 1 : undefined, }), - series: chartSeries(theme), + series, } }) @@ -119,9 +131,12 @@ function chartSeries(theme: UsageChartPalette): LineSeriesOption[] { const chartPoints = points.value if (activeView.value === 'savings') { return [ - usageLineSeries( + ...usageGapAwareLineSeries( '缓存节省', - chartPoints.map(point => decimalDisplayNumber(point.cacheSavings)), + zeroInactiveValues( + chartPoints.map(point => decimalDisplayNumber(point.cacheSavings)), + requestActivity.value, + ), theme.success, { area: 'strong' }, ), @@ -130,12 +145,12 @@ function chartSeries(theme: UsageChartPalette): LineSeriesOption[] { if (activeView.value === 'cache') { return [ - usageLineSeries( + ...usageGapAwareLineSeries( '缓存 Token 占比', chartPoints.map(point => point.cachedTokenRate), theme.normal, ), - usageLineSeries( + ...usageGapAwareLineSeries( '命中请求率', chartPoints.map(point => point.cacheHitRequestRate), theme.success, @@ -144,18 +159,24 @@ function chartSeries(theme: UsageChartPalette): LineSeriesOption[] { } const series = [ - usageLineSeries( + ...usageGapAwareLineSeries( '实际费用', - chartPoints.map(point => decimalDisplayNumber(point.estimatedCost)), + zeroInactiveValues( + chartPoints.map(point => decimalDisplayNumber(point.estimatedCost)), + requestActivity.value, + ), theme.success, { area: 'strong' }, ), ] if (hasNoCacheCost.value) { series.push( - usageLineSeries( + ...usageGapAwareLineSeries( '无缓存费用', - chartPoints.map(point => decimalDisplayNumber(point.noCacheCost)), + zeroInactiveValues( + chartPoints.map(point => decimalDisplayNumber(point.noCacheCost)), + requestActivity.value, + ), theme.textMuted, ), ) @@ -165,29 +186,48 @@ function chartSeries(theme: UsageChartPalette): LineSeriesOption[] { function formatTooltip(params: unknown) { const rows = tooltipRows(params) - const point = points.value[tooltipIndex(rows[0])] + const pointIndex = tooltipIndex(rows[0]) + const point = points.value[pointIndex] if (!point) return '' + const theme = palette.value if (activeView.value === 'savings') { - return usageTooltipContent(palette.value, point.label, [ - `缓存节省: ${formatUsd(point.cacheSavings)}`, + return usageTooltipContent(theme, point.label, [ + usageTooltipItem( + '缓存节省', + formatUsd(costAmount(point.cacheSavings, pointIndex)), + theme.success, + ), ]) } if (activeView.value === 'cache') { - return usageTooltipContent(palette.value, point.label, [ - `缓存 Token 占比: ${formatPercent(point.cachedTokenRate)}`, - `命中请求率: ${formatPercent(point.cacheHitRequestRate)}`, + return usageTooltipContent(theme, point.label, [ + usageTooltipItem('缓存 Token 占比', formatPercent(point.cachedTokenRate), theme.normal), + usageTooltipItem('命中请求率', formatPercent(point.cacheHitRequestRate), theme.success), ]) } const lines = [ - `实际费用: ${formatUsd(point.estimatedCost)}`, + usageTooltipItem( + '实际费用', + formatUsd(costAmount(point.estimatedCost, pointIndex)), + theme.success, + ), ] - if (point.noCacheCost != null) - lines.push(`无缓存费用: ${formatUsd(point.noCacheCost)}`) - return usageTooltipContent(palette.value, point.label, lines) + if (point.noCacheCost != null) { + lines.push(usageTooltipItem( + '无缓存费用', + formatUsd(costAmount(point.noCacheCost, pointIndex)), + theme.textMuted, + )) + } + return usageTooltipContent(theme, point.label, lines) +} + +function costAmount(value: string | null, pointIndex: number) { + return value ?? (requestActivity.value[pointIndex] === false ? '0' : null) } diff --git a/frontend/src/views/usage/components/UsageHealthCard.vue b/frontend/src/views/usage/components/UsageHealthCard.vue index 228724f7e..6a66fc898 100644 --- a/frontend/src/views/usage/components/UsageHealthCard.vue +++ b/frontend/src/views/usage/components/UsageHealthCard.vue @@ -1,5 +1,5 @@ diff --git a/frontend/src/views/usage/components/UsageInsightsGrid.vue b/frontend/src/views/usage/components/UsageInsightsGrid.vue index 0bb0d7633..b2587ab56 100644 --- a/frontend/src/views/usage/components/UsageInsightsGrid.vue +++ b/frontend/src/views/usage/components/UsageInsightsGrid.vue @@ -28,7 +28,7 @@ const diagnosticDimension = defineModel('diagnosticDimension', { diff --git a/frontend/src/views/usage/components/UsagePerformanceCard.vue b/frontend/src/views/usage/components/UsagePerformanceCard.vue index 67fbbc6ac..2c83f81f9 100644 --- a/frontend/src/views/usage/components/UsagePerformanceCard.vue +++ b/frontend/src/views/usage/components/UsagePerformanceCard.vue @@ -7,6 +7,7 @@ import BaseCard from '@/components/base/BaseCard.vue' import BaseEmpty from '@/components/base/BaseEmpty.vue' import BaseSegmented from '@/components/base/BaseSegmented.vue' import BaseChart from '@/components/charts/BaseChart.vue' +import { requestActivityByBucket, zeroInactiveValues } from '@/components/charts/timeSeriesGap' import { useChartPalette } from '@/composables/useChartPalette' import { formatLocalizedCompactNumber as formatCompactNumber } from '@/utils/number' @@ -14,20 +15,23 @@ import { tooltipIndex, tooltipRows, usageCategoryAxis, + usageGapAwareLineSeries, usageLegend, - usageLineSeries, usageTooltip, usageTooltipContent, + usageTooltipItem, usageValueAxis, } from '../utils/chart' import { formatDuration, formatDurationAxis, formatPercent } from '../utils/format' type Performance = Awaited>['performance'] type PerformancePoint = Performance['points'][number] +type Activity = Awaited>['health']['points'] const props = withDefaults( defineProps<{ performance: Performance + activity: Activity loading?: boolean }>(), { @@ -38,6 +42,10 @@ const props = withDefaults( const activeView = shallowRef('total') const { palette } = useChartPalette() const performancePoints = computed(() => props.performance.points) +const requestActivity = computed(() => requestActivityByBucket( + performancePoints.value.map(point => point.bucket), + props.activity, +)) const viewOptions = [ { label: '总耗时', value: 'total' }, @@ -54,14 +62,28 @@ const seriesLabels = computed(() => { return ['P50', 'P95', 'P99'] as const }) -const selectedPoints = computed(() => - performancePoints.value.map(point => ({ +const selectedPoints = computed(() => { + const points = performancePoints.value + const active = requestActivity.value + const firstValues = zeroInactiveValues( + points.map(point => seriesValue(point, 'first')), + active, + ) + const secondValues = zeroInactiveValues( + points.map(point => seriesValue(point, 'second')), + active, + ) + const thirdValues = zeroInactiveValues( + points.map(point => seriesValue(point, 'third')), + active, + ) + return points.map((point, index) => ({ point, - first: seriesValue(point, 'first'), - second: seriesValue(point, 'second'), - third: seriesValue(point, 'third'), - })), -) + first: firstValues[index] ?? null, + second: secondValues[index] ?? null, + third: thirdValues[index] ?? null, + })) +}) const summaryMetrics = computed(() => { const performance = props.performance @@ -98,6 +120,25 @@ const hasData = computed( const chartOption = computed(() => { const theme = palette.value const points = selectedPoints.value + const series = [ + ...usageGapAwareLineSeries( + seriesLabels.value[0], + points.map(point => point.first), + theme.info, + { area: 'subtle' }, + ), + ...usageGapAwareLineSeries( + seriesLabels.value[1], + points.map(point => point.second), + theme.warning, + ), + ...usageGapAwareLineSeries( + seriesLabels.value[2], + points.map(point => point.third), + activeView.value === 'scheduling' ? theme.success : theme.danger, + { yAxisIndex: activeView.value === 'scheduling' ? 1 : 0 }, + ), + ] return { animationDuration: 240, @@ -130,27 +171,7 @@ const chartOption = computed(() => { ? value => `${formatCompactNumber(value)}/s` : formatDurationAxis, ), - series: [ - usageLineSeries( - seriesLabels.value[0], - points.map(point => point.first), - theme.info, - { area: 'subtle' }, - ), - usageLineSeries( - seriesLabels.value[1], - points.map(point => point.second), - theme.warning, - ), - { - ...usageLineSeries( - seriesLabels.value[2], - points.map(point => point.third), - activeView.value === 'scheduling' ? theme.success : theme.danger, - ), - yAxisIndex: activeView.value === 'scheduling' ? 1 : 0, - }, - ], + series, } }) @@ -180,10 +201,23 @@ function formatTooltip(params: unknown) { if (!selected) return '' - return usageTooltipContent(palette.value, selected.point.label, [ - `${seriesLabels.value[0]}: ${formatSeriesValue(selected.first, 'first')}`, - `${seriesLabels.value[1]}: ${formatSeriesValue(selected.second, 'second')}`, - `${seriesLabels.value[2]}: ${formatSeriesValue(selected.third, 'third')}`, + const theme = palette.value + return usageTooltipContent(theme, selected.point.label, [ + usageTooltipItem( + seriesLabels.value[0], + formatSeriesValue(selected.first, 'first'), + theme.info, + ), + usageTooltipItem( + seriesLabels.value[1], + formatSeriesValue(selected.second, 'second'), + theme.warning, + ), + usageTooltipItem( + seriesLabels.value[2], + formatSeriesValue(selected.third, 'third'), + activeView.value === 'scheduling' ? theme.success : theme.danger, + ), ]) } diff --git a/frontend/src/views/usage/utils/chart.ts b/frontend/src/views/usage/utils/chart.ts index a44bac159..36a93316a 100644 --- a/frontend/src/views/usage/utils/chart.ts +++ b/frontend/src/views/usage/utils/chart.ts @@ -1,5 +1,6 @@ import type { LineSeriesOption } from 'echarts' import type { useChartPalette } from '@/composables/useChartPalette' +import { sampleGapBridgeSeries } from '@/components/charts/timeSeriesGap' import { chartTooltipStyle } from '@/components/charts/tooltip' import { escapeTooltip } from './format' @@ -9,6 +10,8 @@ type UsageAreaStrength = 'strong' | 'subtle' interface UsageLineSeriesOptions { stack?: string area?: UsageAreaStrength | false + xAxisIndex?: number + yAxisIndex?: number } const areaAlpha: Record = { @@ -36,6 +39,8 @@ export function usageLineSeries( data: data.map(value => value ?? null), connectNulls: false, stack: options.stack, + xAxisIndex: options.xAxisIndex ?? 0, + yAxisIndex: options.yAxisIndex ?? 0, smooth: true, showSymbol: showSymbols, showAllSymbol: showSymbols, @@ -61,6 +66,26 @@ export function usageLineSeries( } } +export function usageGapAwareLineSeries( + name: string, + data: Array, + color: string, + options: UsageLineSeriesOptions = {}, +) { + const primary = usageLineSeries(name, data, color, options) + return [ + primary, + ...sampleGapBridgeSeries(data, { + name, + color, + xAxisIndex: options.xAxisIndex, + yAxisIndex: options.yAxisIndex, + width: 2.2, + z: 2, + }), + ] +} + export function usageTooltip( theme: UsageChartPalette, formatter: (params: unknown) => string, @@ -81,6 +106,10 @@ export function usageTooltipContent( return `
${title}
${lines.join('
')}
` } +export function usageTooltipItem(label: string, value: string, color: string) { + return `${escapeTooltip(label)}: ${escapeTooltip(value)}` +} + export function usageCategoryAxis(labels: string[], theme: UsageChartPalette) { return { type: 'category' as const,