| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316 |
- 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<string, unknown>[]
- 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<T extends Record<string, unknown>>({
- 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<string, unknown> = { ...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<any>['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 }) => (
- <ResponsiveContainer
- className={className}
- height={minHeight}
- minHeight={minHeight}
- width="100%"
- >
- {children}
- </ResponsiveContainer>
- ),
- [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<StackedChartProps<any>, 'xAxisKey' | 'yAxisKey' | 'stackKey'> & {
- variant?: 'values' | 'percentages'
- } & Pick<CommonChartProps<Record<string, number>>, '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<string, Record<string, number>>
- )
- 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 }
- }
|