Charts.utils.tsx316 lines · main
| 1 | import dayjs from 'dayjs' |
| 2 | import { useMemo, type FC, type ReactElement } from 'react' |
| 3 | import { ResponsiveContainer } from 'recharts' |
| 4 | |
| 5 | import { DateTimeFormats } from './Charts.constants' |
| 6 | import 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 | */ |
| 16 | export 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 | */ |
| 27 | export 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 | */ |
| 38 | export 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 | */ |
| 72 | export 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 | */ |
| 83 | export 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 | */ |
| 106 | export 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 | */ |
| 139 | export 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 | |
| 177 | export 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 | */ |
| 220 | export 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 | */ |
| 258 | export 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 | } |