Fork of atp.tools as a universal profile for people on the ATmosphere
at main 242 lines 6.8 kB view raw
1import { 2 memo, 3 useEffect, 4 useCallback, 5 useRef, 6 useState, 7 CSSProperties, 8} from "react"; 9 10export interface AnimatedCounterProps { 11 value?: number; 12 incrementColor?: string; 13 decrementColor?: string; 14 includeDecimals?: boolean; 15 decimalPrecision?: number; 16 padNumber?: number; 17 showColorsWhenValueChanges?: boolean; 18 includeCommas?: boolean; 19 containerStyles?: CSSProperties; 20 digitStyles?: CSSProperties; 21 className?: string; 22} 23 24export interface NumberColumnProps { 25 digit: string; 26 delta: string | null; 27 incrementColor: string; 28 decrementColor: string; 29 digitStyles: CSSProperties; 30 showColorsWhenValueChanges?: boolean; 31} 32 33export interface DecimalColumnProps { 34 isComma: boolean; 35 digitStyles: CSSProperties; 36} 37 38// Decimal element component 39const DecimalColumn = ({ isComma, digitStyles }: DecimalColumnProps) => ( 40 <span className={`${isComma ? "ml-[-0.1rem]" : ""}`} style={digitStyles}> 41 {isComma ? "," : "."} 42 </span> 43); 44 45// Individual number element component 46const NumberColumn = memo( 47 ({ 48 digit, 49 delta, 50 incrementColor, 51 decrementColor, 52 digitStyles, 53 showColorsWhenValueChanges, 54 }: NumberColumnProps) => { 55 const [position, setPosition] = useState<number>(0); 56 const [animationClass, setAnimationClass] = useState<string | null>(null); 57 const [movementType, setMovementType] = useState< 58 "increment" | "decrement" | null 59 >(null); 60 const currentDigit = +digit; 61 const previousDigit = usePrevious(+currentDigit); 62 const columnContainer = useRef<HTMLDivElement>(null); 63 64 const setColumnToNumber = useCallback( 65 (number: string) => { 66 if (columnContainer.current) { 67 setPosition( 68 columnContainer.current.clientHeight * parseInt(number, 10), 69 ); 70 } 71 }, 72 [columnContainer.current?.clientHeight], 73 ); 74 75 useEffect(() => { 76 setAnimationClass(previousDigit !== currentDigit ? delta : ""); 77 if (!showColorsWhenValueChanges) return; 78 if (delta === "animate-moveUp") { 79 setMovementType("increment"); 80 } else if (delta === "animate-moveDown") { 81 setMovementType("decrement"); 82 } 83 }, [digit, delta, previousDigit, currentDigit]); 84 85 // reset movementType after 300ms 86 useEffect(() => { 87 setTimeout(() => { 88 setMovementType(null); 89 }, 300); 90 }, [movementType]); 91 92 useEffect(() => { 93 setColumnToNumber(digit); 94 }, [digit, setColumnToNumber]); 95 96 if (digit === "-") { 97 return <span>{digit}</span>; 98 } 99 100 return ( 101 <div 102 className="relative tabular-nums overflow-hidden" 103 ref={columnContainer} 104 style={ 105 { 106 maskImage: `linear-gradient(to top, transparent 0%, black min(0.5rem, 20%)), 107 linear-gradient(to bottom, transparent 0%, black min(0.5rem, 20%))`, 108 maskComposite: "intersect", 109 } as CSSProperties 110 } 111 > 112 <div 113 className={`absolute w-full flex flex-col ${animationClass} ${ 114 animationClass ? "animate-move" : "" 115 } transition-all duration-150 ease-in-out`} 116 style={ 117 { 118 transform: `translateY(-${position}px)`, 119 "--increment-color": incrementColor, 120 "--decrement-color": decrementColor, 121 color: `var(--${movementType}-color)`, 122 } as CSSProperties 123 } 124 > 125 {[9, 8, 7, 6, 5, 4, 3, 2, 1, 0].reverse().map((num) => ( 126 <div className="flex justify-center items-center" key={num}> 127 <span style={digitStyles}>{num}</span> 128 </div> 129 ))} 130 </div> 131 <span className="invisible">0</span> 132 </div> 133 ); 134 }, 135 (prevProps, nextProps) => 136 prevProps.digit === nextProps.digit && prevProps.delta === nextProps.delta, 137); 138 139// Main component 140const AnimatedCounter = ({ 141 value = 0, 142 incrementColor = "#32cd32", 143 decrementColor = "#fe6862", 144 includeDecimals = true, 145 decimalPrecision = 2, 146 includeCommas = false, 147 containerStyles = {}, 148 digitStyles = {}, 149 padNumber = 0, 150 className = "", 151 showColorsWhenValueChanges = true, 152}: AnimatedCounterProps) => { 153 const numArray = formatForDisplay( 154 Math.abs(value), 155 includeDecimals, 156 decimalPrecision, 157 includeCommas, 158 padNumber, 159 ); 160 const previousNumber = usePrevious(value); 161 const isNegative = value < 0; 162 163 let delta: string | null = null; 164 165 if (previousNumber !== null) { 166 if (value > previousNumber) { 167 delta = "animate-moveUp"; // Tailwind class for increase 168 } else if (value < previousNumber) { 169 delta = "animate-moveDown"; // Tailwind class for decrease 170 } 171 } 172 173 return ( 174 <div 175 className={`relative flex flex-wrap transition-all tabular-nums ${className}`} 176 style={{ ...containerStyles }} 177 > 178 {/* If number is negative, render '-' feedback */} 179 {isNegative && ( 180 <NumberColumn 181 key={"negative-feedback"} 182 digit={"-"} 183 delta={delta} 184 incrementColor={incrementColor} 185 decrementColor={decrementColor} 186 digitStyles={digitStyles} 187 showColorsWhenValueChanges={showColorsWhenValueChanges} 188 /> 189 )} 190 {/* Format integer to NumberColumn components */} 191 {numArray.map((number: string, index: number) => 192 number === "." || number === "," ? ( 193 <DecimalColumn 194 key={index} 195 isComma={number === ","} 196 digitStyles={digitStyles} 197 /> 198 ) : ( 199 <NumberColumn 200 key={index} 201 digit={number} 202 delta={delta} 203 incrementColor={incrementColor} 204 decrementColor={decrementColor} 205 digitStyles={digitStyles} 206 showColorsWhenValueChanges={showColorsWhenValueChanges} 207 /> 208 ), 209 )} 210 </div> 211 ); 212}; 213 214const formatForDisplay = ( 215 number: number, 216 includeDecimals: boolean, 217 decimalPrecision: number, 218 includeCommas: boolean, 219 padTo: number = 0, 220): string[] => { 221 const decimalCount = includeDecimals ? decimalPrecision : 0; 222 const parsedNumber = parseFloat(`${Math.max(number, 0)}`).toFixed( 223 decimalCount, 224 ); 225 const numberToFormat = includeCommas 226 ? parseFloat(parsedNumber).toLocaleString("en-US", { 227 minimumFractionDigits: includeDecimals ? decimalPrecision : 0, 228 }) 229 : parsedNumber; 230 return numberToFormat.padStart(padTo, "0").split(""); 231}; 232 233// Hook used to track previous value of primary number state in AnimatedCounter & individual digits in NumberColumn 234const usePrevious = (value: number | null) => { 235 const ref = useRef<number | null>(null); 236 useEffect(() => { 237 ref.current = value; 238 }, [value]); 239 return ref.current; 240}; 241 242export default AnimatedCounter;