Fork of atp.tools as a universal profile for people on the ATmosphere
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;