import { dataScheme as vchartDefaultDataScheme } from '@visactor/vchart/esm/theme/color-scheme/builtin/default' import { getCurrencyDisplay } from '@/lib/currency' import { formatChartTime, type TimeGranularity } from '@/lib/time' import { MAX_CHART_TREND_POINTS } from '@/features/dashboard/constants' import type { QuotaDataItem, ProcessedChartData, ProcessedUserChartData, } from '@/features/dashboard/types' type TFunction = (key: string) => string function getVChartDefaultColors(domainLength: number) { const scheme = vchartDefaultDataScheme.find( (item) => !item.maxDomainLength || domainLength <= item.maxDomainLength ) ?? vchartDefaultDataScheme[vchartDefaultDataScheme.length - 1] return scheme.scheme } function buildModelColorSpec(models: string[]) { const domain = Array.from(new Set(models)) return { type: 'ordinal', domain, range: getVChartDefaultColors(domain.length), } } function renderQuotaCompat(rawQuota: number, digits = 4): string { const { config, meta } = getCurrencyDisplay() if (meta.kind === 'tokens') return rawQuota.toLocaleString() const usd = rawQuota / config.quotaPerUnit const rate = 'exchangeRate' in meta ? meta.exchangeRate : 1 const symbol = 'symbol' in meta ? meta.symbol : '$' const value = usd * rate const fixed = value.toFixed(digits) if (parseFloat(fixed) === 0 && rawQuota > 0 && value > 0) { return symbol + Math.pow(10, -digits).toFixed(digits) } return symbol + fixed } /** * Process and aggregate chart data */ export function processChartData( data: QuotaDataItem[], timeGranularity: TimeGranularity = 'day', t?: TFunction ): ProcessedChartData { const tt: TFunction = t ?? ((x) => x) const otherLabel = tt('Other') const formatInt = (value: number) => Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(value) const formatQuotaValue = (value: number) => renderQuotaCompat(value, 4) const formatQuotaTotal = (value: number) => renderQuotaCompat(value, 2) const MAX_TOOLTIP_MODELS = 15 const makeTooltipDimensionUpdateContent = () => { return ( array: Array<{ key: string value: string | number datum?: Record }> ) => { array.sort((a, b) => (Number(b.value) || 0) - (Number(a.value) || 0)) let sum = 0 for (let i = 0; i < array.length; i++) { if (array[i].key === 'Other' || array[i].key === otherLabel) continue const v = Number(array[i].value) || 0 if ( array[i].datum && (array[i].datum as Record)?.TimeSum ) { sum = Number((array[i].datum as Record)?.TimeSum) || sum } array[i].value = formatQuotaValue(v) } if (array.length > MAX_TOOLTIP_MODELS) { const visible = array.slice(0, MAX_TOOLTIP_MODELS) let otherSum = 0 for (let i = MAX_TOOLTIP_MODELS; i < array.length; i++) { const raw = array[i].datum ? Number((array[i].datum as Record)?.rawQuota) || 0 : 0 otherSum += raw } visible.push({ key: otherLabel, value: formatQuotaValue(otherSum), }) array = visible } array.unshift({ key: tt('Total:'), value: formatQuotaValue(sum), }) return array } } if (!data || data.length === 0) { return { spec_pie: { type: 'pie', data: [{ id: 'id0', values: [] }], outerRadius: 0.8, innerRadius: 0.5, padAngle: 0.6, valueField: 'value', categoryField: 'type', title: { visible: true, text: tt('Call Count Distribution'), subtext: tt('No data available'), }, legends: { visible: false }, label: { visible: false }, tooltip: { mark: { content: [], }, }, }, spec_line: { type: 'bar', data: [{ id: 'barData', values: [] }], xField: 'Time', yField: 'Usage', seriesField: 'Model', stack: true, legends: { visible: true, selectMode: 'single' }, }, spec_area: { type: 'area', data: [{ id: 'areaData', values: [] }], xField: 'Time', yField: 'Usage', seriesField: 'Model', stack: true, legends: { visible: true, selectMode: 'single' }, }, spec_model_line: { type: 'line', data: [{ id: 'lineData', values: [] }], xField: 'Time', yField: 'Count', seriesField: 'Model', legends: { visible: true, selectMode: 'single' }, title: { visible: true, text: tt('Call Trend'), subtext: `${tt('Total:')} ${formatInt(0)}`, }, }, spec_rank_bar: { type: 'bar', data: [{ id: 'rankData', values: [] }], xField: 'Model', yField: 'Count', seriesField: 'Model', legends: { visible: true, selectMode: 'single' }, title: { visible: true, text: tt('Call Count Ranking'), subtext: `${tt('Total:')} ${formatInt(0)}`, }, }, totalQuotaDisplay: formatQuotaTotal(0), } } const { config } = getCurrencyDisplay() const quotaPerUnit = config.quotaPerUnit // Aggregate all metrics by time and model const timeModelMap = new Map< string, Map >() const modelTotalsMap = new Map< string, { quota: number; count: number; tokens: number } >() data.forEach((item) => { const timestamp = Number(item.created_at) const timeKey = formatChartTime(timestamp, timeGranularity) const model = item.model_name || 'Unknown' const quota = Number(item.quota) || 0 const count = Number(item.count) || 0 const tokens = Number(item.token_used) || 0 // Aggregate by time and model if (!timeModelMap.has(timeKey)) { timeModelMap.set(timeKey, new Map()) } const modelMap = timeModelMap.get(timeKey)! const existing = modelMap.get(model) || { quota: 0, count: 0, tokens: 0 } modelMap.set(model, { quota: existing.quota + quota, count: existing.count + count, tokens: existing.tokens + tokens, }) // Calculate totals const totalExisting = modelTotalsMap.get(model) || { quota: 0, count: 0, tokens: 0, } modelTotalsMap.set(model, { quota: totalExisting.quota + quota, count: totalExisting.count + count, tokens: totalExisting.tokens + tokens, }) }) const allModels = Array.from(modelTotalsMap.keys()) const sortedTimes = Array.from(timeModelMap.keys()).sort() const sortedModels = [...allModels].sort() const modelColor = buildModelColorSpec([...sortedModels, otherLabel]) // Pad time points if too few (default 7 points) const MAX_TREND_POINTS = MAX_CHART_TREND_POINTS const fillTimePoints = (times: string[]) => { if (times.length >= MAX_TREND_POINTS) return times const lastTime = Math.max( ...data.map((item) => Number(item.created_at) || 0) ) const intervalSec = timeGranularity === 'week' ? 604800 : timeGranularity === 'day' ? 86400 : 3600 const padded = Array.from({ length: MAX_TREND_POINTS }, (_, i) => formatChartTime( lastTime - (MAX_TREND_POINTS - 1 - i) * intervalSec, timeGranularity ) ) return padded } const chartTimes = fillTimePoints(sortedTimes) const totalTimes = Array.from(modelTotalsMap.values()).reduce( (sum, x) => sum + (Number(x.count) || 0), 0 ) const totalQuotaRaw = Array.from(modelTotalsMap.values()).reduce( (sum, x) => sum + (Number(x.quota) || 0), 0 ) // Pie chart (model call count proportion) const pieValues = Array.from(modelTotalsMap.entries()) .map(([model, stats]) => ({ type: model, value: Number(stats.count) || 0, })) .sort((a, b) => b.value - a.value) // Stacked bar: model quota distribution (quota -> USD) const lineValues: Array<{ Time: string Model: string rawQuota: number Usage: number TimeSum: number }> = [] chartTimes.forEach((time) => { let timeData = sortedModels.map((model) => { const stats = timeModelMap.get(time)?.get(model) const rawQuota = Number(stats?.quota) || 0 const usd = rawQuota ? rawQuota / quotaPerUnit : 0 // Match legacy frontend getQuotaWithUnit(..., 4) const usage = usd ? Number(usd.toFixed(4)) : 0 return { Time: time, Model: model, rawQuota, Usage: usage, TimeSum: 0, } }) const timeSum = timeData.reduce((sum, item) => sum + item.rawQuota, 0) timeData.sort((a, b) => b.rawQuota - a.rawQuota) timeData = timeData.map((item) => ({ ...item, TimeSum: timeSum })) lineValues.push(...timeData) }) lineValues.sort((a, b) => a.Time.localeCompare(b.Time)) // Area chart: top models by quota + "Other" bucket (too many series = unreadable) const MAX_AREA_MODELS = 15 const rankedQuotaModels = Array.from(modelTotalsMap.entries()) .map(([model, stats]) => ({ Model: model, Quota: Number(stats.quota) || 0, })) .sort((a, b) => b.Quota - a.Quota) const topAreaModels = new Set( rankedQuotaModels.slice(0, MAX_AREA_MODELS).map((m) => m.Model) ) const areaValues: typeof lineValues = [] chartTimes.forEach((time) => { const buckets = new Map() const modelMap = timeModelMap.get(time) let timeSum = 0 sortedModels.forEach((model) => { const stats = modelMap?.get(model) const rawQuota = Number(stats?.quota) || 0 const usd = rawQuota ? rawQuota / quotaPerUnit : 0 const usage = usd ? Number(usd.toFixed(4)) : 0 timeSum += rawQuota const key = topAreaModels.has(model) ? model : otherLabel const prev = buckets.get(key) || { rawQuota: 0, usage: 0 } buckets.set(key, { rawQuota: prev.rawQuota + rawQuota, usage: Number((prev.usage + usage).toFixed(4)), }) }) for (const [model, vals] of buckets) { areaValues.push({ Time: time, Model: model, rawQuota: vals.rawQuota, Usage: vals.usage, TimeSum: timeSum, }) } }) areaValues.sort((a, b) => a.Time.localeCompare(b.Time)) // Line chart: model call trend (top models + "Other" bucket) const MAX_TREND_MODELS = 20 const rankedTrendModels = Array.from(modelTotalsMap.entries()) .map(([model, stats]) => ({ Model: model, Count: Number(stats.count) || 0, })) .sort((a, b) => b.Count - a.Count) const topTrendModels = rankedTrendModels .slice(0, MAX_TREND_MODELS) .map((item) => item.Model) const otherTrendModels = rankedTrendModels .slice(MAX_TREND_MODELS) .map((item) => item.Model) const modelLineValues: Array<{ Time: string Model: string Count: number }> = [] chartTimes.forEach((time) => { const timeData = topTrendModels.map((model) => { const stats = timeModelMap.get(time)?.get(model) return { Time: time, Model: model, Count: Number(stats?.count) || 0, } }) if (otherTrendModels.length > 0) { const otherCount = otherTrendModels.reduce((sum, model) => { const stats = timeModelMap.get(time)?.get(model) return sum + (Number(stats?.count) || 0) }, 0) timeData.push({ Time: time, Model: otherLabel, Count: otherCount, }) } modelLineValues.push(...timeData) }) modelLineValues.sort((a, b) => a.Time.localeCompare(b.Time)) // Rank bar: model call count ranking (top 20 + "Other" bucket) const MAX_RANK_MODELS = 20 const allRankValues = Array.from(modelTotalsMap.entries()) .map(([model, stats]) => ({ Model: model, Count: Number(stats.count) || 0, })) .sort((a, b) => b.Count - a.Count) let rankValues: typeof allRankValues if (allRankValues.length > MAX_RANK_MODELS) { const topModels = allRankValues.slice(0, MAX_RANK_MODELS) const otherCount = allRankValues .slice(MAX_RANK_MODELS) .reduce((sum, item) => sum + item.Count, 0) rankValues = [...topModels, { Model: otherLabel, Count: otherCount }] } else { rankValues = allRankValues } return { spec_pie: { type: 'pie', data: [{ id: 'id0', values: pieValues }], outerRadius: 0.8, innerRadius: 0.5, padAngle: 0.6, valueField: 'value', categoryField: 'type', pie: { style: { cornerRadius: 10 }, state: { hover: { outerRadius: 0.85, stroke: '#000', lineWidth: 1 }, selected: { outerRadius: 0.85, stroke: '#000', lineWidth: 1 }, }, }, title: { visible: true, text: tt('Call Count Distribution'), subtext: `${tt('Total:')} ${formatInt(totalTimes)}`, }, legends: { visible: true, orient: 'left' }, label: { visible: true }, color: modelColor, tooltip: { mark: { content: [ { key: (datum: Record) => datum?.type, value: (datum: Record) => formatInt(Number(datum?.value) || 0), }, ], }, }, background: { fill: 'transparent' }, animation: true, }, spec_line: { type: 'bar', data: [{ id: 'barData', values: lineValues }], xField: 'Time', yField: 'Usage', seriesField: 'Model', stack: true, legends: { visible: true, selectMode: 'single' }, color: modelColor, bar: { state: { hover: { stroke: '#000', lineWidth: 1 }, }, }, tooltip: { mark: { content: [ { key: (datum: Record) => datum?.Model, value: (datum: Record) => formatQuotaValue(Number(datum?.rawQuota) || 0), }, ], }, dimension: { content: [ { key: (datum: Record) => datum?.Model, value: (datum: Record) => Number(datum?.rawQuota) || 0, }, ], updateContent: makeTooltipDimensionUpdateContent(), }, }, background: { fill: 'transparent' }, animation: true, }, spec_area: { type: 'area', data: [{ id: 'areaData', values: areaValues }], xField: 'Time', yField: 'Usage', seriesField: 'Model', stack: false, legends: { visible: true, selectMode: 'single' }, color: modelColor, tooltip: { mark: { content: [ { key: (datum: Record) => datum?.Model, value: (datum: Record) => formatQuotaValue(Number(datum?.rawQuota) || 0), }, ], }, dimension: { content: [ { key: (datum: Record) => datum?.Model, value: (datum: Record) => Number(datum?.rawQuota) || 0, }, ], updateContent: makeTooltipDimensionUpdateContent(), }, }, area: { style: { fillOpacity: 0.08, curveType: 'monotone', }, }, line: { style: { lineWidth: 2, curveType: 'monotone', }, }, point: { visible: false }, background: { fill: 'transparent' }, animation: true, }, spec_model_line: { type: 'area', data: [{ id: 'lineData', values: modelLineValues }], xField: 'Time', yField: 'Count', seriesField: 'Model', stack: false, legends: { visible: true, selectMode: 'single' }, color: modelColor, title: { visible: true, text: tt('Call Trend'), subtext: `${tt('Total:')} ${formatInt(totalTimes)}`, }, tooltip: { mark: { content: [ { key: (datum: Record) => datum?.Model, value: (datum: Record) => formatInt(Number(datum?.Count) || 0), }, ], }, dimension: { content: [ { key: (datum: Record) => datum?.Model, value: (datum: Record) => Number(datum?.Count) || 0, }, ], updateContent: ( array: Array<{ key: string value: string | number }> ) => { array.sort( (a, b) => (Number(b.value) || 0) - (Number(a.value) || 0) ) let sum = 0 for (let i = 0; i < array.length; i++) { const v = Number(array[i].value) || 0 sum += v array[i].value = formatInt(v) } array.unshift({ key: tt('Total:'), value: formatInt(sum), }) return array }, }, }, area: { style: { fillOpacity: 0.08, curveType: 'monotone', }, }, line: { style: { lineWidth: 2, curveType: 'monotone', }, }, point: { visible: false }, background: { fill: 'transparent' }, animation: true, }, spec_rank_bar: { type: 'bar', data: [{ id: 'rankData', values: rankValues }], xField: 'Model', yField: 'Count', seriesField: 'Model', legends: { visible: true, selectMode: 'single' }, color: modelColor, title: { visible: true, text: tt('Call Count Ranking'), subtext: `${tt('Total:')} ${formatInt(totalTimes)}`, }, bar: { state: { hover: { stroke: '#000', lineWidth: 1 }, }, }, tooltip: { mark: { content: [ { key: (datum: Record) => datum?.Model, value: (datum: Record) => formatInt(Number(datum?.Count) || 0), }, ], }, }, background: { fill: 'transparent' }, animation: true, }, totalQuotaDisplay: formatQuotaTotal(totalQuotaRaw), } } const USER_COLORS = [ '#5B8FF9', '#5AD8A6', '#F6BD16', '#E8684A', '#6DC8EC', '#9270CA', '#FF9D4D', '#269A99', '#FF99C3', '#5D7092', ] export function processUserChartData( data: QuotaDataItem[], timeGranularity: TimeGranularity = 'day', t?: TFunction, limit = 10 ): ProcessedUserChartData { const tt: TFunction = t ?? ((x) => x) const { config } = getCurrencyDisplay() const quotaPerUnit = config.quotaPerUnit const formatVal = (raw: number) => renderQuotaCompat(raw, 2) const emptyResult: ProcessedUserChartData = { spec_user_rank: { type: 'bar', data: [{ id: 'userRankData', values: [] }], xField: 'rawQuota', yField: 'User', seriesField: 'User', direction: 'horizontal', title: { visible: true, text: tt('User Consumption Ranking'), subtext: tt('No data available'), }, legends: { visible: false }, color: { type: 'ordinal', range: USER_COLORS }, background: { fill: 'transparent' }, }, spec_user_trend: { type: 'area', data: [{ id: 'userTrendData', values: [] }], xField: 'Time', yField: 'rawQuota', seriesField: 'User', title: { visible: true, text: tt('User Consumption Trend'), subtext: tt('No data available'), }, legends: { visible: true, selectMode: 'single' }, color: { type: 'ordinal', range: USER_COLORS }, point: { visible: false }, background: { fill: 'transparent' }, }, } if (!data || data.length === 0) return emptyResult const userQuotaTotal = new Map() data.forEach((item) => { const username = item.username || 'unknown' const prev = userQuotaTotal.get(username) || 0 userQuotaTotal.set(username, prev + (Number(item.quota) || 0)) }) const sorted = Array.from(userQuotaTotal.entries()).sort( (a, b) => b[1] - a[1] ) const topUsers = sorted.slice(0, limit).map(([u]) => u) const topUserSet = new Set(topUsers) const totalQuota = sorted.slice(0, limit).reduce((s, [, q]) => s + q, 0) const rankValues = sorted.slice(0, limit).map(([username, quota]) => ({ User: username, rawQuota: quota, Usage: Number((quota / quotaPerUnit).toFixed(4)), })) const userColorMap = topUsers.reduce>( (acc, user, i) => { acc[user] = USER_COLORS[i % USER_COLORS.length] return acc }, {} ) const timeUserMap = new Map>() const allTimePoints = new Set() data.forEach((item) => { const ts = Number(item.created_at) const timeKey = formatChartTime(ts, timeGranularity) allTimePoints.add(timeKey) const user = item.username || 'unknown' if (!topUserSet.has(user)) return if (!timeUserMap.has(timeKey)) timeUserMap.set(timeKey, new Map()) const map = timeUserMap.get(timeKey)! map.set(user, (map.get(user) || 0) + (Number(item.quota) || 0)) }) const sortedTimePoints = Array.from(allTimePoints).sort() const trendValues: Array<{ Time: string User: string rawQuota: number Usage: number }> = [] sortedTimePoints.forEach((time) => { topUsers.forEach((user) => { const q = timeUserMap.get(time)?.get(user) || 0 trendValues.push({ Time: time, User: user, rawQuota: q, Usage: Number((q / quotaPerUnit).toFixed(4)), }) }) }) return { spec_user_rank: { type: 'bar', data: [{ id: 'userRankData', values: rankValues }], xField: 'rawQuota', yField: 'User', seriesField: 'User', direction: 'horizontal', title: { visible: true, text: tt('User Consumption Ranking'), subtext: `${tt('Total:')} ${formatVal(totalQuota)}`, }, legends: { visible: false }, bar: { state: { hover: { stroke: '#000', lineWidth: 1 } }, }, label: { visible: true, position: 'outside', formatMethod: (value: number) => formatVal(value), style: { fontSize: 11 }, }, axes: [ { orient: 'left', type: 'band' }, { orient: 'bottom', type: 'linear', visible: false }, ], tooltip: { mark: { content: [ { key: (datum: Record) => datum?.User, value: (datum: Record) => formatVal(Number(datum?.rawQuota) || 0), }, ], }, }, color: { specified: userColorMap }, background: { fill: 'transparent' }, animation: true, }, spec_user_trend: { type: 'area', data: [{ id: 'userTrendData', values: trendValues }], xField: 'Time', yField: 'rawQuota', seriesField: 'User', stack: false, title: { visible: true, text: tt('User Consumption Trend'), subtext: `${tt('Total:')} ${formatVal(totalQuota)}`, }, legends: { visible: true, selectMode: 'single' }, axes: [ { orient: 'bottom', type: 'band' }, { orient: 'left', type: 'linear', label: { formatMethod: (value: number) => formatVal(value), }, }, ], tooltip: { mark: { content: [ { key: (datum: Record) => datum?.User, value: (datum: Record) => formatVal(Number(datum?.rawQuota) || 0), }, ], }, dimension: { content: [ { key: (datum: Record) => datum?.User, value: (datum: Record) => Number(datum?.rawQuota) || 0, }, ], updateContent: ( array: Array<{ key: string value: string | number }> ) => { array.sort( (a, b) => (Number(b.value) || 0) - (Number(a.value) || 0) ) let sum = 0 for (let i = 0; i < array.length; i++) { const v = Number(array[i].value) || 0 sum += v array[i].value = formatVal(v) } array.unshift({ key: tt('Total:'), value: formatVal(sum), }) return array }, }, }, area: { style: { fillOpacity: 0.15 } }, line: { style: { lineWidth: 2 } }, point: { visible: false }, color: { specified: userColorMap }, background: { fill: 'transparent' }, animation: true, }, } }