import { getChartColor } from '@/lib/colors' import { formatQuotaWithCurrency, 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 /** * Process and aggregate chart data */ export function processChartData( data: QuotaDataItem[], timeGranularity: TimeGranularity = 'day', t?: TFunction ): ProcessedChartData { const tt: TFunction = t ?? ((x) => x) const formatInt = (value: number) => Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(value) 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 Proportion'), 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' }, title: { visible: true, text: tt('Quota Distribution'), subtext: `${tt('Total:')} ${formatQuotaWithCurrency(0, { digitsLarge: 2, digitsSmall: 2, abbreviate: false, })}`, }, }, 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('Top Models'), subtext: `${tt('Total:')} ${formatInt(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() // 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 modelColorMap = sortedModels.reduce>( (acc, model, index) => { acc[model] = getChartColor(index) return acc }, {} ) 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)) // Line chart: model call trend const modelLineValues: Array<{ Time: string Model: string Count: number }> = [] chartTimes.forEach((time) => { const timeData = sortedModels.map((model) => { const stats = timeModelMap.get(time)?.get(model) return { Time: time, Model: model, Count: Number(stats?.count) || 0, } }) 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: tt('Other'), 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 Proportion'), subtext: `${tt('Total:')} ${formatInt(totalTimes)}`, }, legends: { visible: true, orient: 'left' }, label: { visible: true }, tooltip: { mark: { content: [ { key: (datum: Record) => datum?.type, value: (datum: Record) => formatInt(Number(datum?.value) || 0), }, ], }, }, color: { specified: modelColorMap }, 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' }, title: { visible: true, text: tt('Quota Distribution'), subtext: `${tt('Total:')} ${formatQuotaWithCurrency(totalQuotaRaw, { digitsLarge: 2, digitsSmall: 2, abbreviate: false, })}`, }, bar: { state: { hover: { stroke: '#000', lineWidth: 1 }, }, }, tooltip: { mark: { content: [ { key: (datum: Record) => datum?.Model, value: (datum: Record) => formatQuotaWithCurrency(Number(datum?.rawQuota) || 0, { digitsLarge: 4, digitsSmall: 4, abbreviate: false, }), }, ], }, dimension: { content: [ { key: (datum: Record) => datum?.Model, value: (datum: Record) => Number(datum?.rawQuota) || 0, }, ], updateContent: ( 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') 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 = formatQuotaWithCurrency(v, { digitsLarge: 4, digitsSmall: 4, abbreviate: false, }) } array.unshift({ key: tt('Total:'), value: formatQuotaWithCurrency(sum, { digitsLarge: 4, digitsSmall: 4, abbreviate: false, }), }) return array }, }, }, color: { specified: modelColorMap }, background: { fill: 'transparent' }, animation: true, }, spec_model_line: { type: 'line', data: [{ id: 'lineData', values: modelLineValues }], xField: 'Time', yField: 'Count', seriesField: 'Model', legends: { visible: true, selectMode: 'single' }, 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 }, }, }, color: { specified: modelColorMap }, 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' }, title: { visible: true, text: tt('Top Models'), 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), }, ], }, }, color: { specified: modelColorMap }, background: { fill: 'transparent' }, animation: true, }, } } 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) => formatQuotaWithCurrency(raw, { digitsLarge: 2, digitsSmall: 2, abbreviate: false, }) 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, }, } }