import dayjs from 'dayjs' import { useMemo, type FC, type ReactElement } from 'react' import { ResponsiveContainer } from 'recharts' import { DateTimeFormats } from './Charts.constants' import type { CommonChartProps, StackedChartProps } from './Charts.types' /** * Auto formats a number to a default precision if it is a float * * @example * numberFormatter(123) // "123" * numberFormatter(123.123) // "123.12" * numberFormatter(123, 2) // "123.00" */ export const numberFormatter = (num: number, precision = 2) => { return isFloat(num) ? precisionFormatter(num, precision) : num.toLocaleString() } /** * Tests if a number is a float. * * @example * isFloat(123) // false * isFloat(123.123) // true */ export const isFloat = (num: number) => String(num).includes('.') /** * Formats a number to a particular precision. * * @example * precisionFormatter(123, 2) // "123.00" * precisionFormatter(123.123, 2) // "123.12" * precisionFormatter(0.00123, 2) // "<0.01" * precisionFormatter(-0.00123, 2) // ">-0.01" */ export const precisionFormatter = (num: number, precision: number): string => { if (precision === 0) { return String(Math.round(num)) } // Handle small numbers that would display as 0.00 const threshold = 1 / Math.pow(10, precision) if (num > 0 && num < threshold) { return `<${threshold.toFixed(precision)}` } if (num < 0 && num > -threshold) { return `>-${threshold.toFixed(precision)}` } if (isFloat(num)) { const [head, tail] = String(num).split('.') return Number(head).toLocaleString() + '.' + tail.slice(0, precision) } else { // pad int with 0 return num.toLocaleString() + '.' + '0'.repeat(precision) } } /** * Formats a number compactly for Y-axis ticks by abbreviating large values. * Prevents long numbers like 1,000,000 from overflowing the Y-axis width. * * @example * compactNumberFormatter(999) // "999" * compactNumberFormatter(1000) // "1K" * compactNumberFormatter(1500) // "1.5K" * compactNumberFormatter(1000000) // "1M" * compactNumberFormatter(2500000) // "2.5M" */ export const compactNumberFormatter = (num: number): string => { return new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 }).format(num) } /** * Formats a percentage, trimming decimals at 100. * * @example * formatPercentage(100, 2) // "100%" * formatPercentage(99.99, 2) // "99.99%" */ export const formatPercentage = (value: number, precision = 2) => { const isHundred = Math.abs(value - 100) < 1e-6 if (isHundred) return '100%' if (Number.isInteger(value)) return `${value}%` const formatted = precisionFormatter(value, precision) if (formatted.startsWith('<') || formatted.startsWith('>')) { return `${formatted}%` } if (formatted.includes('.')) { const [head, tail = ''] = formatted.split('.') return `${head}.${tail.padEnd(precision, '0')}%` } return `${formatted}%` } /** * Formats a timestamp. * Optionally formats the string to UTC * @param value * @param format * @param utc * @returns */ export const timestampFormatter = ( value: string, format: string = DateTimeFormats.FULL, utc: boolean = false ) => { if (utc) { return dayjs.utc(value).format(format) } return dayjs(value).format(format) } /** * Computes the Y-axis domain for a ComposedChart that may contain stacked Bar components * and an optional max-value reference Line. * * Recharts' `['auto', 'auto']` domain does not correctly include a Line component's values * when stacked Bars are present — the domain is derived only from the bar data, so the * reference line (e.g. Max IOPS) and any bars that exceed it get visually clipped. * This function returns an explicit `[0, max]` domain when a visible reference line exists. * * @example * // Max IOPS reference line at 25 000, bars reach up to 25 403 * computeYAxisDomain({ maxAttributeKey: 'disk_iops_max', showMaxLine: true, ... }) * // → [0, 25403] * * // Percentage chart zoomed in (no max line toggle) * computeYAxisDomain({ isPercentage: true, showMaxValue: false, yMaxFromVisible: 75, ... }) * // → [0, 75] * * // No max reference line — let Recharts auto-scale * computeYAxisDomain({ maxAttributeKey: undefined, ... }) * // → ['auto', 'auto'] */ export function computeYAxisDomain({ isPercentage, showMaxValue, yMaxFromVisible, maxAttributeKey, showMaxLine, data, visibleAttributeNames, }: { isPercentage: boolean showMaxValue: boolean yMaxFromVisible: number maxAttributeKey: string | undefined showMaxLine: boolean data: Record[] visibleAttributeNames: string[] }): [number, number] | ['auto', 'auto'] { if (isPercentage && !showMaxValue) return [0, yMaxFromVisible] if (!maxAttributeKey || !showMaxLine) return ['auto', 'auto'] const maxRefValue = data.reduce((max, point) => { const val = point[maxAttributeKey] return typeof val === 'number' ? Math.max(max, val) : max }, 0) if (maxRefValue <= 0) return ['auto', 'auto'] const maxStackedTotal = data.reduce((max, point) => { const total = visibleAttributeNames.reduce((sum, name) => { const val = point[name] return sum + (typeof val === 'number' ? val : 0) }, 0) return Math.max(max, total) }, 0) return [0, Math.max(maxRefValue, maxStackedTotal)] } export function normalizeStackedSeriesData>({ data, attributeNames, totalTarget = 100, }: { data: T[] attributeNames: string[] totalTarget?: number }): T[] { return data.map((point) => { const values = attributeNames.map((name) => ({ name, value: typeof point[name] === 'number' ? point[name] : 0, })) const total = values.reduce((sum, entry) => sum + entry.value, 0) if (total <= 0) return point const largestEntry = values.reduce((largest, entry) => entry.value > largest.value ? entry : largest ) let normalizedTotal = 0 const nextPoint: Record = { ...point } values.forEach(({ name, value }) => { if (name === largestEntry.name) return const normalizedValue = (value / total) * totalTarget nextPoint[name] = normalizedValue normalizedTotal += normalizedValue }) nextPoint[largestEntry.name] = Math.max(0, totalTarget - normalizedTotal) return nextPoint as T }) } /** * Hook to create common wrapping components, perform data transformations * returns a Container component and the minHeight set */ export const useChartSize = ( size: CommonChartProps['size'] = 'normal', sizeMap: { tiny: number small: number normal: number large: number } = { tiny: 76, small: 96, normal: 160, large: 280, } ) => { const minHeight = sizeMap[size] const Container: FC<{ children: ReactElement; className?: string }> = useMemo( () => ({ className, children }) => ( {children} ), [size] ) return { Container, minHeight, } } /** * Transforms data points into a stacked data structure that can be consumed by recharts */ export const useStacked = ({ data, xAxisKey, yAxisKey, stackKey, variant = 'values', }: Pick, 'xAxisKey' | 'yAxisKey' | 'stackKey'> & { variant?: 'values' | 'percentages' } & Pick>, 'data'>) => { const stackedData = useMemo(() => { if (!data) return [] const mapping = data.reduce( (acc, datum) => { const x = datum[xAxisKey] const y = datum[yAxisKey] const s = datum[stackKey] if (!acc[x]) { acc[x] = {} } acc[x][s] = y return acc }, {} as Record> ) const flattened = Object.entries(mapping).map(([x, sMap]) => ({ ...sMap, [xAxisKey]: Number.isNaN(Number(x)) ? x : Number(x), })) return flattened }, [JSON.stringify(data)]) const dataKeys = useMemo(() => { return Object.keys(stackedData[0] || {}) .filter((k) => k !== xAxisKey && k !== yAxisKey) .sort() }, [JSON.stringify(stackedData[0] || {})]) const percentagesStackedData = useMemo(() => { if (variant !== 'percentages') return return stackedData.map((stack) => { const entries = Object.entries(stack) as Array<[string, number]> let map const sum = entries .filter(([key, _value]) => dataKeys.includes(key)) .reduce((acc, [_key, value]) => acc + value, 0) map = entries.reduce((acc, [key, value]) => { if (!dataKeys.includes(key)) { return { ...acc, [key]: value } } return { ...acc, [key]: value !== 0 ? value / sum : 0 } }, {} as any) return map }) }, [JSON.stringify(stackedData)]) return { dataKeys, stackedData, percentagesStackedData } }