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