charts.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877
  1. import { dataScheme as vchartDefaultDataScheme } from '@visactor/vchart/esm/theme/color-scheme/builtin/default'
  2. import { getCurrencyDisplay } from '@/lib/currency'
  3. import { formatChartTime, type TimeGranularity } from '@/lib/time'
  4. import { MAX_CHART_TREND_POINTS } from '@/features/dashboard/constants'
  5. import type {
  6. QuotaDataItem,
  7. ProcessedChartData,
  8. ProcessedUserChartData,
  9. } from '@/features/dashboard/types'
  10. type TFunction = (key: string) => string
  11. function getVChartDefaultColors(domainLength: number) {
  12. const scheme =
  13. vchartDefaultDataScheme.find(
  14. (item) => !item.maxDomainLength || domainLength <= item.maxDomainLength
  15. ) ?? vchartDefaultDataScheme[vchartDefaultDataScheme.length - 1]
  16. return scheme.scheme
  17. }
  18. function buildModelColorSpec(models: string[]) {
  19. const domain = Array.from(new Set(models))
  20. return {
  21. type: 'ordinal',
  22. domain,
  23. range: getVChartDefaultColors(domain.length),
  24. }
  25. }
  26. function renderQuotaCompat(rawQuota: number, digits = 4): string {
  27. const { config, meta } = getCurrencyDisplay()
  28. if (meta.kind === 'tokens') return rawQuota.toLocaleString()
  29. const usd = rawQuota / config.quotaPerUnit
  30. const rate = 'exchangeRate' in meta ? meta.exchangeRate : 1
  31. const symbol = 'symbol' in meta ? meta.symbol : '$'
  32. const value = usd * rate
  33. const fixed = value.toFixed(digits)
  34. if (parseFloat(fixed) === 0 && rawQuota > 0 && value > 0) {
  35. return symbol + Math.pow(10, -digits).toFixed(digits)
  36. }
  37. return symbol + fixed
  38. }
  39. /**
  40. * Process and aggregate chart data
  41. */
  42. export function processChartData(
  43. data: QuotaDataItem[],
  44. timeGranularity: TimeGranularity = 'day',
  45. t?: TFunction
  46. ): ProcessedChartData {
  47. const tt: TFunction = t ?? ((x) => x)
  48. const otherLabel = tt('Other')
  49. const formatInt = (value: number) =>
  50. Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(value)
  51. const formatQuotaValue = (value: number) => renderQuotaCompat(value, 4)
  52. const formatQuotaTotal = (value: number) => renderQuotaCompat(value, 2)
  53. const MAX_TOOLTIP_MODELS = 15
  54. const makeTooltipDimensionUpdateContent = () => {
  55. return (
  56. array: Array<{
  57. key: string
  58. value: string | number
  59. datum?: Record<string, unknown>
  60. }>
  61. ) => {
  62. array.sort((a, b) => (Number(b.value) || 0) - (Number(a.value) || 0))
  63. let sum = 0
  64. for (let i = 0; i < array.length; i++) {
  65. if (array[i].key === 'Other' || array[i].key === otherLabel) continue
  66. const v = Number(array[i].value) || 0
  67. if (
  68. array[i].datum &&
  69. (array[i].datum as Record<string, unknown>)?.TimeSum
  70. ) {
  71. sum =
  72. Number((array[i].datum as Record<string, unknown>)?.TimeSum) || sum
  73. }
  74. array[i].value = formatQuotaValue(v)
  75. }
  76. if (array.length > MAX_TOOLTIP_MODELS) {
  77. const visible = array.slice(0, MAX_TOOLTIP_MODELS)
  78. let otherSum = 0
  79. for (let i = MAX_TOOLTIP_MODELS; i < array.length; i++) {
  80. const raw = array[i].datum
  81. ? Number((array[i].datum as Record<string, unknown>)?.rawQuota) || 0
  82. : 0
  83. otherSum += raw
  84. }
  85. visible.push({
  86. key: otherLabel,
  87. value: formatQuotaValue(otherSum),
  88. })
  89. array = visible
  90. }
  91. array.unshift({
  92. key: tt('Total:'),
  93. value: formatQuotaValue(sum),
  94. })
  95. return array
  96. }
  97. }
  98. if (!data || data.length === 0) {
  99. return {
  100. spec_pie: {
  101. type: 'pie',
  102. data: [{ id: 'id0', values: [] }],
  103. outerRadius: 0.8,
  104. innerRadius: 0.5,
  105. padAngle: 0.6,
  106. valueField: 'value',
  107. categoryField: 'type',
  108. title: {
  109. visible: true,
  110. text: tt('Call Count Distribution'),
  111. subtext: tt('No data available'),
  112. },
  113. legends: { visible: false },
  114. label: { visible: false },
  115. tooltip: {
  116. mark: {
  117. content: [],
  118. },
  119. },
  120. },
  121. spec_line: {
  122. type: 'bar',
  123. data: [{ id: 'barData', values: [] }],
  124. xField: 'Time',
  125. yField: 'Usage',
  126. seriesField: 'Model',
  127. stack: true,
  128. legends: { visible: true, selectMode: 'single' },
  129. },
  130. spec_area: {
  131. type: 'area',
  132. data: [{ id: 'areaData', values: [] }],
  133. xField: 'Time',
  134. yField: 'Usage',
  135. seriesField: 'Model',
  136. stack: true,
  137. legends: { visible: true, selectMode: 'single' },
  138. },
  139. spec_model_line: {
  140. type: 'line',
  141. data: [{ id: 'lineData', values: [] }],
  142. xField: 'Time',
  143. yField: 'Count',
  144. seriesField: 'Model',
  145. legends: { visible: true, selectMode: 'single' },
  146. title: {
  147. visible: true,
  148. text: tt('Call Trend'),
  149. subtext: `${tt('Total:')} ${formatInt(0)}`,
  150. },
  151. },
  152. spec_rank_bar: {
  153. type: 'bar',
  154. data: [{ id: 'rankData', values: [] }],
  155. xField: 'Model',
  156. yField: 'Count',
  157. seriesField: 'Model',
  158. legends: { visible: true, selectMode: 'single' },
  159. title: {
  160. visible: true,
  161. text: tt('Call Count Ranking'),
  162. subtext: `${tt('Total:')} ${formatInt(0)}`,
  163. },
  164. },
  165. totalQuotaDisplay: formatQuotaTotal(0),
  166. }
  167. }
  168. const { config } = getCurrencyDisplay()
  169. const quotaPerUnit = config.quotaPerUnit
  170. // Aggregate all metrics by time and model
  171. const timeModelMap = new Map<
  172. string,
  173. Map<string, { quota: number; count: number; tokens: number }>
  174. >()
  175. const modelTotalsMap = new Map<
  176. string,
  177. { quota: number; count: number; tokens: number }
  178. >()
  179. data.forEach((item) => {
  180. const timestamp = Number(item.created_at)
  181. const timeKey = formatChartTime(timestamp, timeGranularity)
  182. const model = item.model_name || 'Unknown'
  183. const quota = Number(item.quota) || 0
  184. const count = Number(item.count) || 0
  185. const tokens = Number(item.token_used) || 0
  186. // Aggregate by time and model
  187. if (!timeModelMap.has(timeKey)) {
  188. timeModelMap.set(timeKey, new Map())
  189. }
  190. const modelMap = timeModelMap.get(timeKey)!
  191. const existing = modelMap.get(model) || { quota: 0, count: 0, tokens: 0 }
  192. modelMap.set(model, {
  193. quota: existing.quota + quota,
  194. count: existing.count + count,
  195. tokens: existing.tokens + tokens,
  196. })
  197. // Calculate totals
  198. const totalExisting = modelTotalsMap.get(model) || {
  199. quota: 0,
  200. count: 0,
  201. tokens: 0,
  202. }
  203. modelTotalsMap.set(model, {
  204. quota: totalExisting.quota + quota,
  205. count: totalExisting.count + count,
  206. tokens: totalExisting.tokens + tokens,
  207. })
  208. })
  209. const allModels = Array.from(modelTotalsMap.keys())
  210. const sortedTimes = Array.from(timeModelMap.keys()).sort()
  211. const sortedModels = [...allModels].sort()
  212. const modelColor = buildModelColorSpec([...sortedModels, otherLabel])
  213. // Pad time points if too few (default 7 points)
  214. const MAX_TREND_POINTS = MAX_CHART_TREND_POINTS
  215. const fillTimePoints = (times: string[]) => {
  216. if (times.length >= MAX_TREND_POINTS) return times
  217. const lastTime = Math.max(
  218. ...data.map((item) => Number(item.created_at) || 0)
  219. )
  220. const intervalSec =
  221. timeGranularity === 'week'
  222. ? 604800
  223. : timeGranularity === 'day'
  224. ? 86400
  225. : 3600
  226. const padded = Array.from({ length: MAX_TREND_POINTS }, (_, i) =>
  227. formatChartTime(
  228. lastTime - (MAX_TREND_POINTS - 1 - i) * intervalSec,
  229. timeGranularity
  230. )
  231. )
  232. return padded
  233. }
  234. const chartTimes = fillTimePoints(sortedTimes)
  235. const totalTimes = Array.from(modelTotalsMap.values()).reduce(
  236. (sum, x) => sum + (Number(x.count) || 0),
  237. 0
  238. )
  239. const totalQuotaRaw = Array.from(modelTotalsMap.values()).reduce(
  240. (sum, x) => sum + (Number(x.quota) || 0),
  241. 0
  242. )
  243. // Pie chart (model call count proportion)
  244. const pieValues = Array.from(modelTotalsMap.entries())
  245. .map(([model, stats]) => ({
  246. type: model,
  247. value: Number(stats.count) || 0,
  248. }))
  249. .sort((a, b) => b.value - a.value)
  250. // Stacked bar: model quota distribution (quota -> USD)
  251. const lineValues: Array<{
  252. Time: string
  253. Model: string
  254. rawQuota: number
  255. Usage: number
  256. TimeSum: number
  257. }> = []
  258. chartTimes.forEach((time) => {
  259. let timeData = sortedModels.map((model) => {
  260. const stats = timeModelMap.get(time)?.get(model)
  261. const rawQuota = Number(stats?.quota) || 0
  262. const usd = rawQuota ? rawQuota / quotaPerUnit : 0
  263. // Match legacy frontend getQuotaWithUnit(..., 4)
  264. const usage = usd ? Number(usd.toFixed(4)) : 0
  265. return {
  266. Time: time,
  267. Model: model,
  268. rawQuota,
  269. Usage: usage,
  270. TimeSum: 0,
  271. }
  272. })
  273. const timeSum = timeData.reduce((sum, item) => sum + item.rawQuota, 0)
  274. timeData.sort((a, b) => b.rawQuota - a.rawQuota)
  275. timeData = timeData.map((item) => ({ ...item, TimeSum: timeSum }))
  276. lineValues.push(...timeData)
  277. })
  278. lineValues.sort((a, b) => a.Time.localeCompare(b.Time))
  279. // Area chart: top models by quota + "Other" bucket (too many series = unreadable)
  280. const MAX_AREA_MODELS = 15
  281. const rankedQuotaModels = Array.from(modelTotalsMap.entries())
  282. .map(([model, stats]) => ({
  283. Model: model,
  284. Quota: Number(stats.quota) || 0,
  285. }))
  286. .sort((a, b) => b.Quota - a.Quota)
  287. const topAreaModels = new Set(
  288. rankedQuotaModels.slice(0, MAX_AREA_MODELS).map((m) => m.Model)
  289. )
  290. const areaValues: typeof lineValues = []
  291. chartTimes.forEach((time) => {
  292. const buckets = new Map<string, { rawQuota: number; usage: number }>()
  293. const modelMap = timeModelMap.get(time)
  294. let timeSum = 0
  295. sortedModels.forEach((model) => {
  296. const stats = modelMap?.get(model)
  297. const rawQuota = Number(stats?.quota) || 0
  298. const usd = rawQuota ? rawQuota / quotaPerUnit : 0
  299. const usage = usd ? Number(usd.toFixed(4)) : 0
  300. timeSum += rawQuota
  301. const key = topAreaModels.has(model) ? model : otherLabel
  302. const prev = buckets.get(key) || { rawQuota: 0, usage: 0 }
  303. buckets.set(key, {
  304. rawQuota: prev.rawQuota + rawQuota,
  305. usage: Number((prev.usage + usage).toFixed(4)),
  306. })
  307. })
  308. for (const [model, vals] of buckets) {
  309. areaValues.push({
  310. Time: time,
  311. Model: model,
  312. rawQuota: vals.rawQuota,
  313. Usage: vals.usage,
  314. TimeSum: timeSum,
  315. })
  316. }
  317. })
  318. areaValues.sort((a, b) => a.Time.localeCompare(b.Time))
  319. // Line chart: model call trend (top models + "Other" bucket)
  320. const MAX_TREND_MODELS = 20
  321. const rankedTrendModels = Array.from(modelTotalsMap.entries())
  322. .map(([model, stats]) => ({
  323. Model: model,
  324. Count: Number(stats.count) || 0,
  325. }))
  326. .sort((a, b) => b.Count - a.Count)
  327. const topTrendModels = rankedTrendModels
  328. .slice(0, MAX_TREND_MODELS)
  329. .map((item) => item.Model)
  330. const otherTrendModels = rankedTrendModels
  331. .slice(MAX_TREND_MODELS)
  332. .map((item) => item.Model)
  333. const modelLineValues: Array<{
  334. Time: string
  335. Model: string
  336. Count: number
  337. }> = []
  338. chartTimes.forEach((time) => {
  339. const timeData = topTrendModels.map((model) => {
  340. const stats = timeModelMap.get(time)?.get(model)
  341. return {
  342. Time: time,
  343. Model: model,
  344. Count: Number(stats?.count) || 0,
  345. }
  346. })
  347. if (otherTrendModels.length > 0) {
  348. const otherCount = otherTrendModels.reduce((sum, model) => {
  349. const stats = timeModelMap.get(time)?.get(model)
  350. return sum + (Number(stats?.count) || 0)
  351. }, 0)
  352. timeData.push({
  353. Time: time,
  354. Model: otherLabel,
  355. Count: otherCount,
  356. })
  357. }
  358. modelLineValues.push(...timeData)
  359. })
  360. modelLineValues.sort((a, b) => a.Time.localeCompare(b.Time))
  361. // Rank bar: model call count ranking (top 20 + "Other" bucket)
  362. const MAX_RANK_MODELS = 20
  363. const allRankValues = Array.from(modelTotalsMap.entries())
  364. .map(([model, stats]) => ({
  365. Model: model,
  366. Count: Number(stats.count) || 0,
  367. }))
  368. .sort((a, b) => b.Count - a.Count)
  369. let rankValues: typeof allRankValues
  370. if (allRankValues.length > MAX_RANK_MODELS) {
  371. const topModels = allRankValues.slice(0, MAX_RANK_MODELS)
  372. const otherCount = allRankValues
  373. .slice(MAX_RANK_MODELS)
  374. .reduce((sum, item) => sum + item.Count, 0)
  375. rankValues = [...topModels, { Model: otherLabel, Count: otherCount }]
  376. } else {
  377. rankValues = allRankValues
  378. }
  379. return {
  380. spec_pie: {
  381. type: 'pie',
  382. data: [{ id: 'id0', values: pieValues }],
  383. outerRadius: 0.8,
  384. innerRadius: 0.5,
  385. padAngle: 0.6,
  386. valueField: 'value',
  387. categoryField: 'type',
  388. pie: {
  389. style: { cornerRadius: 10 },
  390. state: {
  391. hover: { outerRadius: 0.85, stroke: '#000', lineWidth: 1 },
  392. selected: { outerRadius: 0.85, stroke: '#000', lineWidth: 1 },
  393. },
  394. },
  395. title: {
  396. visible: true,
  397. text: tt('Call Count Distribution'),
  398. subtext: `${tt('Total:')} ${formatInt(totalTimes)}`,
  399. },
  400. legends: { visible: true, orient: 'left' },
  401. label: { visible: true },
  402. color: modelColor,
  403. tooltip: {
  404. mark: {
  405. content: [
  406. {
  407. key: (datum: Record<string, unknown>) => datum?.type,
  408. value: (datum: Record<string, unknown>) =>
  409. formatInt(Number(datum?.value) || 0),
  410. },
  411. ],
  412. },
  413. },
  414. background: { fill: 'transparent' },
  415. animation: true,
  416. },
  417. spec_line: {
  418. type: 'bar',
  419. data: [{ id: 'barData', values: lineValues }],
  420. xField: 'Time',
  421. yField: 'Usage',
  422. seriesField: 'Model',
  423. stack: true,
  424. legends: { visible: true, selectMode: 'single' },
  425. color: modelColor,
  426. bar: {
  427. state: {
  428. hover: { stroke: '#000', lineWidth: 1 },
  429. },
  430. },
  431. tooltip: {
  432. mark: {
  433. content: [
  434. {
  435. key: (datum: Record<string, unknown>) => datum?.Model,
  436. value: (datum: Record<string, unknown>) =>
  437. formatQuotaValue(Number(datum?.rawQuota) || 0),
  438. },
  439. ],
  440. },
  441. dimension: {
  442. content: [
  443. {
  444. key: (datum: Record<string, unknown>) => datum?.Model,
  445. value: (datum: Record<string, unknown>) =>
  446. Number(datum?.rawQuota) || 0,
  447. },
  448. ],
  449. updateContent: makeTooltipDimensionUpdateContent(),
  450. },
  451. },
  452. background: { fill: 'transparent' },
  453. animation: true,
  454. },
  455. spec_area: {
  456. type: 'area',
  457. data: [{ id: 'areaData', values: areaValues }],
  458. xField: 'Time',
  459. yField: 'Usage',
  460. seriesField: 'Model',
  461. stack: false,
  462. legends: { visible: true, selectMode: 'single' },
  463. color: modelColor,
  464. tooltip: {
  465. mark: {
  466. content: [
  467. {
  468. key: (datum: Record<string, unknown>) => datum?.Model,
  469. value: (datum: Record<string, unknown>) =>
  470. formatQuotaValue(Number(datum?.rawQuota) || 0),
  471. },
  472. ],
  473. },
  474. dimension: {
  475. content: [
  476. {
  477. key: (datum: Record<string, unknown>) => datum?.Model,
  478. value: (datum: Record<string, unknown>) =>
  479. Number(datum?.rawQuota) || 0,
  480. },
  481. ],
  482. updateContent: makeTooltipDimensionUpdateContent(),
  483. },
  484. },
  485. area: {
  486. style: {
  487. fillOpacity: 0.08,
  488. curveType: 'monotone',
  489. },
  490. },
  491. line: {
  492. style: {
  493. lineWidth: 2,
  494. curveType: 'monotone',
  495. },
  496. },
  497. point: { visible: false },
  498. background: { fill: 'transparent' },
  499. animation: true,
  500. },
  501. spec_model_line: {
  502. type: 'area',
  503. data: [{ id: 'lineData', values: modelLineValues }],
  504. xField: 'Time',
  505. yField: 'Count',
  506. seriesField: 'Model',
  507. stack: false,
  508. legends: { visible: true, selectMode: 'single' },
  509. color: modelColor,
  510. title: {
  511. visible: true,
  512. text: tt('Call Trend'),
  513. subtext: `${tt('Total:')} ${formatInt(totalTimes)}`,
  514. },
  515. tooltip: {
  516. mark: {
  517. content: [
  518. {
  519. key: (datum: Record<string, unknown>) => datum?.Model,
  520. value: (datum: Record<string, unknown>) =>
  521. formatInt(Number(datum?.Count) || 0),
  522. },
  523. ],
  524. },
  525. dimension: {
  526. content: [
  527. {
  528. key: (datum: Record<string, unknown>) => datum?.Model,
  529. value: (datum: Record<string, unknown>) =>
  530. Number(datum?.Count) || 0,
  531. },
  532. ],
  533. updateContent: (
  534. array: Array<{
  535. key: string
  536. value: string | number
  537. }>
  538. ) => {
  539. array.sort(
  540. (a, b) => (Number(b.value) || 0) - (Number(a.value) || 0)
  541. )
  542. let sum = 0
  543. for (let i = 0; i < array.length; i++) {
  544. const v = Number(array[i].value) || 0
  545. sum += v
  546. array[i].value = formatInt(v)
  547. }
  548. array.unshift({
  549. key: tt('Total:'),
  550. value: formatInt(sum),
  551. })
  552. return array
  553. },
  554. },
  555. },
  556. area: {
  557. style: {
  558. fillOpacity: 0.08,
  559. curveType: 'monotone',
  560. },
  561. },
  562. line: {
  563. style: {
  564. lineWidth: 2,
  565. curveType: 'monotone',
  566. },
  567. },
  568. point: { visible: false },
  569. background: { fill: 'transparent' },
  570. animation: true,
  571. },
  572. spec_rank_bar: {
  573. type: 'bar',
  574. data: [{ id: 'rankData', values: rankValues }],
  575. xField: 'Model',
  576. yField: 'Count',
  577. seriesField: 'Model',
  578. legends: { visible: true, selectMode: 'single' },
  579. color: modelColor,
  580. title: {
  581. visible: true,
  582. text: tt('Call Count Ranking'),
  583. subtext: `${tt('Total:')} ${formatInt(totalTimes)}`,
  584. },
  585. bar: {
  586. state: {
  587. hover: { stroke: '#000', lineWidth: 1 },
  588. },
  589. },
  590. tooltip: {
  591. mark: {
  592. content: [
  593. {
  594. key: (datum: Record<string, unknown>) => datum?.Model,
  595. value: (datum: Record<string, unknown>) =>
  596. formatInt(Number(datum?.Count) || 0),
  597. },
  598. ],
  599. },
  600. },
  601. background: { fill: 'transparent' },
  602. animation: true,
  603. },
  604. totalQuotaDisplay: formatQuotaTotal(totalQuotaRaw),
  605. }
  606. }
  607. const USER_COLORS = [
  608. '#5B8FF9',
  609. '#5AD8A6',
  610. '#F6BD16',
  611. '#E8684A',
  612. '#6DC8EC',
  613. '#9270CA',
  614. '#FF9D4D',
  615. '#269A99',
  616. '#FF99C3',
  617. '#5D7092',
  618. ]
  619. export function processUserChartData(
  620. data: QuotaDataItem[],
  621. timeGranularity: TimeGranularity = 'day',
  622. t?: TFunction,
  623. limit = 10
  624. ): ProcessedUserChartData {
  625. const tt: TFunction = t ?? ((x) => x)
  626. const { config } = getCurrencyDisplay()
  627. const quotaPerUnit = config.quotaPerUnit
  628. const formatVal = (raw: number) => renderQuotaCompat(raw, 2)
  629. const emptyResult: ProcessedUserChartData = {
  630. spec_user_rank: {
  631. type: 'bar',
  632. data: [{ id: 'userRankData', values: [] }],
  633. xField: 'rawQuota',
  634. yField: 'User',
  635. seriesField: 'User',
  636. direction: 'horizontal',
  637. title: {
  638. visible: true,
  639. text: tt('User Consumption Ranking'),
  640. subtext: tt('No data available'),
  641. },
  642. legends: { visible: false },
  643. color: { type: 'ordinal', range: USER_COLORS },
  644. background: { fill: 'transparent' },
  645. },
  646. spec_user_trend: {
  647. type: 'area',
  648. data: [{ id: 'userTrendData', values: [] }],
  649. xField: 'Time',
  650. yField: 'rawQuota',
  651. seriesField: 'User',
  652. title: {
  653. visible: true,
  654. text: tt('User Consumption Trend'),
  655. subtext: tt('No data available'),
  656. },
  657. legends: { visible: true, selectMode: 'single' },
  658. color: { type: 'ordinal', range: USER_COLORS },
  659. point: { visible: false },
  660. background: { fill: 'transparent' },
  661. },
  662. }
  663. if (!data || data.length === 0) return emptyResult
  664. const userQuotaTotal = new Map<string, number>()
  665. data.forEach((item) => {
  666. const username = item.username || 'unknown'
  667. const prev = userQuotaTotal.get(username) || 0
  668. userQuotaTotal.set(username, prev + (Number(item.quota) || 0))
  669. })
  670. const sorted = Array.from(userQuotaTotal.entries()).sort(
  671. (a, b) => b[1] - a[1]
  672. )
  673. const topUsers = sorted.slice(0, limit).map(([u]) => u)
  674. const topUserSet = new Set(topUsers)
  675. const totalQuota = sorted.slice(0, limit).reduce((s, [, q]) => s + q, 0)
  676. const rankValues = sorted.slice(0, limit).map(([username, quota]) => ({
  677. User: username,
  678. rawQuota: quota,
  679. Usage: Number((quota / quotaPerUnit).toFixed(4)),
  680. }))
  681. const userColorMap = topUsers.reduce<Record<string, string>>(
  682. (acc, user, i) => {
  683. acc[user] = USER_COLORS[i % USER_COLORS.length]
  684. return acc
  685. },
  686. {}
  687. )
  688. const timeUserMap = new Map<string, Map<string, number>>()
  689. const allTimePoints = new Set<string>()
  690. data.forEach((item) => {
  691. const ts = Number(item.created_at)
  692. const timeKey = formatChartTime(ts, timeGranularity)
  693. allTimePoints.add(timeKey)
  694. const user = item.username || 'unknown'
  695. if (!topUserSet.has(user)) return
  696. if (!timeUserMap.has(timeKey)) timeUserMap.set(timeKey, new Map())
  697. const map = timeUserMap.get(timeKey)!
  698. map.set(user, (map.get(user) || 0) + (Number(item.quota) || 0))
  699. })
  700. const sortedTimePoints = Array.from(allTimePoints).sort()
  701. const trendValues: Array<{
  702. Time: string
  703. User: string
  704. rawQuota: number
  705. Usage: number
  706. }> = []
  707. sortedTimePoints.forEach((time) => {
  708. topUsers.forEach((user) => {
  709. const q = timeUserMap.get(time)?.get(user) || 0
  710. trendValues.push({
  711. Time: time,
  712. User: user,
  713. rawQuota: q,
  714. Usage: Number((q / quotaPerUnit).toFixed(4)),
  715. })
  716. })
  717. })
  718. return {
  719. spec_user_rank: {
  720. type: 'bar',
  721. data: [{ id: 'userRankData', values: rankValues }],
  722. xField: 'rawQuota',
  723. yField: 'User',
  724. seriesField: 'User',
  725. direction: 'horizontal',
  726. title: {
  727. visible: true,
  728. text: tt('User Consumption Ranking'),
  729. subtext: `${tt('Total:')} ${formatVal(totalQuota)}`,
  730. },
  731. legends: { visible: false },
  732. bar: {
  733. state: { hover: { stroke: '#000', lineWidth: 1 } },
  734. },
  735. label: {
  736. visible: true,
  737. position: 'outside',
  738. formatMethod: (value: number) => formatVal(value),
  739. style: { fontSize: 11 },
  740. },
  741. axes: [
  742. { orient: 'left', type: 'band' },
  743. { orient: 'bottom', type: 'linear', visible: false },
  744. ],
  745. tooltip: {
  746. mark: {
  747. content: [
  748. {
  749. key: (datum: Record<string, unknown>) => datum?.User,
  750. value: (datum: Record<string, unknown>) =>
  751. formatVal(Number(datum?.rawQuota) || 0),
  752. },
  753. ],
  754. },
  755. },
  756. color: { specified: userColorMap },
  757. background: { fill: 'transparent' },
  758. animation: true,
  759. },
  760. spec_user_trend: {
  761. type: 'area',
  762. data: [{ id: 'userTrendData', values: trendValues }],
  763. xField: 'Time',
  764. yField: 'rawQuota',
  765. seriesField: 'User',
  766. stack: false,
  767. title: {
  768. visible: true,
  769. text: tt('User Consumption Trend'),
  770. subtext: `${tt('Total:')} ${formatVal(totalQuota)}`,
  771. },
  772. legends: { visible: true, selectMode: 'single' },
  773. axes: [
  774. { orient: 'bottom', type: 'band' },
  775. {
  776. orient: 'left',
  777. type: 'linear',
  778. label: {
  779. formatMethod: (value: number) => formatVal(value),
  780. },
  781. },
  782. ],
  783. tooltip: {
  784. mark: {
  785. content: [
  786. {
  787. key: (datum: Record<string, unknown>) => datum?.User,
  788. value: (datum: Record<string, unknown>) =>
  789. formatVal(Number(datum?.rawQuota) || 0),
  790. },
  791. ],
  792. },
  793. dimension: {
  794. content: [
  795. {
  796. key: (datum: Record<string, unknown>) => datum?.User,
  797. value: (datum: Record<string, unknown>) =>
  798. Number(datum?.rawQuota) || 0,
  799. },
  800. ],
  801. updateContent: (
  802. array: Array<{
  803. key: string
  804. value: string | number
  805. }>
  806. ) => {
  807. array.sort(
  808. (a, b) => (Number(b.value) || 0) - (Number(a.value) || 0)
  809. )
  810. let sum = 0
  811. for (let i = 0; i < array.length; i++) {
  812. const v = Number(array[i].value) || 0
  813. sum += v
  814. array[i].value = formatVal(v)
  815. }
  816. array.unshift({
  817. key: tt('Total:'),
  818. value: formatVal(sum),
  819. })
  820. return array
  821. },
  822. },
  823. },
  824. area: { style: { fillOpacity: 0.15 } },
  825. line: { style: { lineWidth: 2 } },
  826. point: { visible: false },
  827. color: { specified: userColorMap },
  828. background: { fill: 'transparent' },
  829. animation: true,
  830. },
  831. }
  832. }