Charts.utils.tsx 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  1. import dayjs from 'dayjs'
  2. import { useMemo, type FC, type ReactElement } from 'react'
  3. import { ResponsiveContainer } from 'recharts'
  4. import { DateTimeFormats } from './Charts.constants'
  5. import type { CommonChartProps, StackedChartProps } from './Charts.types'
  6. /**
  7. * Auto formats a number to a default precision if it is a float
  8. *
  9. * @example
  10. * numberFormatter(123) // "123"
  11. * numberFormatter(123.123) // "123.12"
  12. * numberFormatter(123, 2) // "123.00"
  13. */
  14. export const numberFormatter = (num: number, precision = 2) => {
  15. return isFloat(num) ? precisionFormatter(num, precision) : num.toLocaleString()
  16. }
  17. /**
  18. * Tests if a number is a float.
  19. *
  20. * @example
  21. * isFloat(123) // false
  22. * isFloat(123.123) // true
  23. */
  24. export const isFloat = (num: number) => String(num).includes('.')
  25. /**
  26. * Formats a number to a particular precision.
  27. *
  28. * @example
  29. * precisionFormatter(123, 2) // "123.00"
  30. * precisionFormatter(123.123, 2) // "123.12"
  31. * precisionFormatter(0.00123, 2) // "<0.01"
  32. * precisionFormatter(-0.00123, 2) // ">-0.01"
  33. */
  34. export const precisionFormatter = (num: number, precision: number): string => {
  35. if (precision === 0) {
  36. return String(Math.round(num))
  37. }
  38. // Handle small numbers that would display as 0.00
  39. const threshold = 1 / Math.pow(10, precision)
  40. if (num > 0 && num < threshold) {
  41. return `<${threshold.toFixed(precision)}`
  42. }
  43. if (num < 0 && num > -threshold) {
  44. return `>-${threshold.toFixed(precision)}`
  45. }
  46. if (isFloat(num)) {
  47. const [head, tail] = String(num).split('.')
  48. return Number(head).toLocaleString() + '.' + tail.slice(0, precision)
  49. } else {
  50. // pad int with 0
  51. return num.toLocaleString() + '.' + '0'.repeat(precision)
  52. }
  53. }
  54. /**
  55. * Formats a number compactly for Y-axis ticks by abbreviating large values.
  56. * Prevents long numbers like 1,000,000 from overflowing the Y-axis width.
  57. *
  58. * @example
  59. * compactNumberFormatter(999) // "999"
  60. * compactNumberFormatter(1000) // "1K"
  61. * compactNumberFormatter(1500) // "1.5K"
  62. * compactNumberFormatter(1000000) // "1M"
  63. * compactNumberFormatter(2500000) // "2.5M"
  64. */
  65. export const compactNumberFormatter = (num: number): string => {
  66. return new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 }).format(num)
  67. }
  68. /**
  69. * Formats a percentage, trimming decimals at 100.
  70. *
  71. * @example
  72. * formatPercentage(100, 2) // "100%"
  73. * formatPercentage(99.99, 2) // "99.99%"
  74. */
  75. export const formatPercentage = (value: number, precision = 2) => {
  76. const isHundred = Math.abs(value - 100) < 1e-6
  77. if (isHundred) return '100%'
  78. if (Number.isInteger(value)) return `${value}%`
  79. const formatted = precisionFormatter(value, precision)
  80. if (formatted.startsWith('<') || formatted.startsWith('>')) {
  81. return `${formatted}%`
  82. }
  83. if (formatted.includes('.')) {
  84. const [head, tail = ''] = formatted.split('.')
  85. return `${head}.${tail.padEnd(precision, '0')}%`
  86. }
  87. return `${formatted}%`
  88. }
  89. /**
  90. * Formats a timestamp.
  91. * Optionally formats the string to UTC
  92. * @param value
  93. * @param format
  94. * @param utc
  95. * @returns
  96. */
  97. export const timestampFormatter = (
  98. value: string,
  99. format: string = DateTimeFormats.FULL,
  100. utc: boolean = false
  101. ) => {
  102. if (utc) {
  103. return dayjs.utc(value).format(format)
  104. }
  105. return dayjs(value).format(format)
  106. }
  107. /**
  108. * Computes the Y-axis domain for a ComposedChart that may contain stacked Bar components
  109. * and an optional max-value reference Line.
  110. *
  111. * Recharts' `['auto', 'auto']` domain does not correctly include a Line component's values
  112. * when stacked Bars are present — the domain is derived only from the bar data, so the
  113. * reference line (e.g. Max IOPS) and any bars that exceed it get visually clipped.
  114. * This function returns an explicit `[0, max]` domain when a visible reference line exists.
  115. *
  116. * @example
  117. * // Max IOPS reference line at 25 000, bars reach up to 25 403
  118. * computeYAxisDomain({ maxAttributeKey: 'disk_iops_max', showMaxLine: true, ... })
  119. * // → [0, 25403]
  120. *
  121. * // Percentage chart zoomed in (no max line toggle)
  122. * computeYAxisDomain({ isPercentage: true, showMaxValue: false, yMaxFromVisible: 75, ... })
  123. * // → [0, 75]
  124. *
  125. * // No max reference line — let Recharts auto-scale
  126. * computeYAxisDomain({ maxAttributeKey: undefined, ... })
  127. * // → ['auto', 'auto']
  128. */
  129. export function computeYAxisDomain({
  130. isPercentage,
  131. showMaxValue,
  132. yMaxFromVisible,
  133. maxAttributeKey,
  134. showMaxLine,
  135. data,
  136. visibleAttributeNames,
  137. }: {
  138. isPercentage: boolean
  139. showMaxValue: boolean
  140. yMaxFromVisible: number
  141. maxAttributeKey: string | undefined
  142. showMaxLine: boolean
  143. data: Record<string, unknown>[]
  144. visibleAttributeNames: string[]
  145. }): [number, number] | ['auto', 'auto'] {
  146. if (isPercentage && !showMaxValue) return [0, yMaxFromVisible]
  147. if (!maxAttributeKey || !showMaxLine) return ['auto', 'auto']
  148. const maxRefValue = data.reduce((max, point) => {
  149. const val = point[maxAttributeKey]
  150. return typeof val === 'number' ? Math.max(max, val) : max
  151. }, 0)
  152. if (maxRefValue <= 0) return ['auto', 'auto']
  153. const maxStackedTotal = data.reduce((max, point) => {
  154. const total = visibleAttributeNames.reduce((sum, name) => {
  155. const val = point[name]
  156. return sum + (typeof val === 'number' ? val : 0)
  157. }, 0)
  158. return Math.max(max, total)
  159. }, 0)
  160. return [0, Math.max(maxRefValue, maxStackedTotal)]
  161. }
  162. export function normalizeStackedSeriesData<T extends Record<string, unknown>>({
  163. data,
  164. attributeNames,
  165. totalTarget = 100,
  166. }: {
  167. data: T[]
  168. attributeNames: string[]
  169. totalTarget?: number
  170. }): T[] {
  171. return data.map((point) => {
  172. const values = attributeNames.map((name) => ({
  173. name,
  174. value: typeof point[name] === 'number' ? point[name] : 0,
  175. }))
  176. const total = values.reduce((sum, entry) => sum + entry.value, 0)
  177. if (total <= 0) return point
  178. const largestEntry = values.reduce((largest, entry) =>
  179. entry.value > largest.value ? entry : largest
  180. )
  181. let normalizedTotal = 0
  182. const nextPoint: Record<string, unknown> = { ...point }
  183. values.forEach(({ name, value }) => {
  184. if (name === largestEntry.name) return
  185. const normalizedValue = (value / total) * totalTarget
  186. nextPoint[name] = normalizedValue
  187. normalizedTotal += normalizedValue
  188. })
  189. nextPoint[largestEntry.name] = Math.max(0, totalTarget - normalizedTotal)
  190. return nextPoint as T
  191. })
  192. }
  193. /**
  194. * Hook to create common wrapping components, perform data transformations
  195. * returns a Container component and the minHeight set
  196. */
  197. export const useChartSize = (
  198. size: CommonChartProps<any>['size'] = 'normal',
  199. sizeMap: {
  200. tiny: number
  201. small: number
  202. normal: number
  203. large: number
  204. } = {
  205. tiny: 76,
  206. small: 96,
  207. normal: 160,
  208. large: 280,
  209. }
  210. ) => {
  211. const minHeight = sizeMap[size]
  212. const Container: FC<{ children: ReactElement; className?: string }> = useMemo(
  213. () =>
  214. ({ className, children }) => (
  215. <ResponsiveContainer
  216. className={className}
  217. height={minHeight}
  218. minHeight={minHeight}
  219. width="100%"
  220. >
  221. {children}
  222. </ResponsiveContainer>
  223. ),
  224. [size]
  225. )
  226. return {
  227. Container,
  228. minHeight,
  229. }
  230. }
  231. /**
  232. * Transforms data points into a stacked data structure that can be consumed by recharts
  233. */
  234. export const useStacked = ({
  235. data,
  236. xAxisKey,
  237. yAxisKey,
  238. stackKey,
  239. variant = 'values',
  240. }: Pick<StackedChartProps<any>, 'xAxisKey' | 'yAxisKey' | 'stackKey'> & {
  241. variant?: 'values' | 'percentages'
  242. } & Pick<CommonChartProps<Record<string, number>>, 'data'>) => {
  243. const stackedData = useMemo(() => {
  244. if (!data) return []
  245. const mapping = data.reduce(
  246. (acc, datum) => {
  247. const x = datum[xAxisKey]
  248. const y = datum[yAxisKey]
  249. const s = datum[stackKey]
  250. if (!acc[x]) {
  251. acc[x] = {}
  252. }
  253. acc[x][s] = y
  254. return acc
  255. },
  256. {} as Record<string, Record<string, number>>
  257. )
  258. const flattened = Object.entries(mapping).map(([x, sMap]) => ({
  259. ...sMap,
  260. [xAxisKey]: Number.isNaN(Number(x)) ? x : Number(x),
  261. }))
  262. return flattened
  263. }, [JSON.stringify(data)])
  264. const dataKeys = useMemo(() => {
  265. return Object.keys(stackedData[0] || {})
  266. .filter((k) => k !== xAxisKey && k !== yAxisKey)
  267. .sort()
  268. }, [JSON.stringify(stackedData[0] || {})])
  269. const percentagesStackedData = useMemo(() => {
  270. if (variant !== 'percentages') return
  271. return stackedData.map((stack) => {
  272. const entries = Object.entries(stack) as Array<[string, number]>
  273. let map
  274. const sum = entries
  275. .filter(([key, _value]) => dataKeys.includes(key))
  276. .reduce((acc, [_key, value]) => acc + value, 0)
  277. map = entries.reduce((acc, [key, value]) => {
  278. if (!dataKeys.includes(key)) {
  279. return { ...acc, [key]: value }
  280. }
  281. return { ...acc, [key]: value !== 0 ? value / sum : 0 }
  282. }, {} as any)
  283. return map
  284. })
  285. }, [JSON.stringify(stackedData)])
  286. return { dataKeys, stackedData, percentagesStackedData }
  287. }