/ src / components / primitives / FormattedNumber.tsx
FormattedNumber.tsx
  1  import { normalizeBN, valueToBigNumber } from '@aave/math-utils';
  2  import { Typography } from '@mui/material';
  3  import { Variant } from '@mui/material/styles/createTypography';
  4  import type {
  5    TypographyProps,
  6    TypographyPropsVariantOverrides,
  7  } from '@mui/material/Typography/Typography';
  8  import type { OverridableStringUnion } from '@mui/types';
  9  import type { ElementType } from 'react';
 10  
 11  interface CompactNumberProps {
 12    value: string | number;
 13    visibleDecimals?: number;
 14    roundDown?: boolean;
 15    compactThreshold?: number;
 16  }
 17  
 18  const POSTFIXES = ['', 'K', 'M', 'B', 'T', 'P', 'E', 'Z', 'Y'];
 19  
 20  export const compactNumber = ({
 21    value,
 22    visibleDecimals = 2,
 23    roundDown,
 24    compactThreshold,
 25  }: CompactNumberProps) => {
 26    const bnValue = valueToBigNumber(value);
 27  
 28    let integerPlaces = bnValue.toFixed(0).length;
 29    if (compactThreshold && Number(value) <= compactThreshold) {
 30      integerPlaces = 0;
 31    }
 32    const significantDigitsGroup = Math.min(
 33      Math.floor(integerPlaces ? (integerPlaces - 1) / 3 : 0),
 34      POSTFIXES.length - 1
 35    );
 36    const postfix = POSTFIXES[significantDigitsGroup];
 37    let formattedValue = normalizeBN(bnValue, 3 * significantDigitsGroup).toNumber();
 38    if (roundDown) {
 39      // Truncates decimals after the visible decimal point, i.e. 10.237 with 2 decimals becomes 10.23
 40      formattedValue =
 41        Math.trunc(Number(formattedValue) * 10 ** visibleDecimals) / 10 ** visibleDecimals;
 42    }
 43    const prefix = new Intl.NumberFormat('en-US', {
 44      maximumFractionDigits: visibleDecimals,
 45      minimumFractionDigits: visibleDecimals,
 46    }).format(formattedValue);
 47  
 48    return { prefix, postfix };
 49  };
 50  
 51  function CompactNumber({ value, visibleDecimals, roundDown }: CompactNumberProps) {
 52    const { prefix, postfix } = compactNumber({ value, visibleDecimals, roundDown });
 53  
 54    return (
 55      <>
 56        {prefix}
 57        {postfix}
 58      </>
 59    );
 60  }
 61  
 62  export type FormattedNumberProps = TypographyProps<ElementType, { component?: ElementType }> & {
 63    value: string | number;
 64    symbol?: string;
 65    visibleDecimals?: number;
 66    compact?: boolean;
 67    percent?: boolean;
 68    symbolsColor?: string;
 69    symbolsVariant?: OverridableStringUnion<Variant | 'inherit', TypographyPropsVariantOverrides>;
 70    roundDown?: boolean;
 71    compactThreshold?: number;
 72  };
 73  
 74  export function FormattedNumber({
 75    value,
 76    symbol,
 77    visibleDecimals,
 78    compact,
 79    percent,
 80    symbolsVariant,
 81    symbolsColor,
 82    roundDown,
 83    compactThreshold,
 84    ...rest
 85  }: FormattedNumberProps) {
 86    const number = percent ? Number(value) * 100 : Number(value);
 87  
 88    let decimals: number = visibleDecimals ?? 0;
 89    if (number === 0) {
 90      decimals = 0;
 91    } else if (visibleDecimals === undefined) {
 92      if (number >= 1 || percent || symbol === 'USD') {
 93        decimals = 2;
 94      } else {
 95        decimals = 7;
 96      }
 97    }
 98  
 99    const minValue = 10 ** -(decimals as number);
100    const isSmallerThanMin = number !== 0 && Math.abs(number) < Math.abs(minValue);
101    let formattedNumber = isSmallerThanMin ? minValue : number;
102    const forceCompact = compact !== false && (compact || number > 99_999);
103  
104    // rounding occurs inside of CompactNumber as the prefix, not base number is rounded
105    if (roundDown && !forceCompact) {
106      formattedNumber = Math.trunc(Number(formattedNumber) * 10 ** decimals) / 10 ** decimals;
107    }
108  
109    return (
110      <Typography
111        {...rest}
112        sx={{
113          display: 'inline-flex',
114          flexDirection: 'row',
115          alignItems: 'center',
116          position: 'relative',
117          ...rest.sx,
118        }}
119        noWrap
120      >
121        {isSmallerThanMin && (
122          <Typography
123            component="span"
124            sx={{ mr: 0.5 }}
125            variant={symbolsVariant || rest.variant}
126            color={symbolsColor || 'text.secondary'}
127          >
128            {'<'}
129          </Typography>
130        )}
131        {symbol?.toLowerCase() === 'usd' && !percent && (
132          <Typography
133            component="span"
134            sx={{ mr: 0.5 }}
135            variant={symbolsVariant || rest.variant}
136            color={symbolsColor || 'text.secondary'}
137          >
138            $
139          </Typography>
140        )}
141  
142        {!forceCompact ? (
143          new Intl.NumberFormat('en-US', {
144            maximumFractionDigits: decimals,
145            minimumFractionDigits: decimals,
146          }).format(formattedNumber)
147        ) : (
148          <CompactNumber
149            value={formattedNumber}
150            visibleDecimals={decimals}
151            roundDown={roundDown}
152            compactThreshold={compactThreshold}
153          />
154        )}
155  
156        {percent && (
157          <Typography
158            component="span"
159            sx={{ ml: 0.5 }}
160            variant={symbolsVariant || rest.variant}
161            color={symbolsColor || 'text.secondary'}
162          >
163            %
164          </Typography>
165        )}
166        {symbol?.toLowerCase() !== 'usd' && typeof symbol !== 'undefined' && (
167          <Typography
168            component="span"
169            sx={{ ml: 0.5 }}
170            variant={symbolsVariant || rest.variant}
171            color={symbolsColor || 'text.secondary'}
172          >
173            {symbol}
174          </Typography>
175        )}
176      </Typography>
177    );
178  }