100 lines
2.7 KiB
TypeScript
100 lines
2.7 KiB
TypeScript
|
|
'use client';
|
|||
|
|
|
|||
|
|
import { useEffect, useRef, useState } from 'react';
|
|||
|
|
|
|||
|
|
const FA_DIGITS = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'] as const;
|
|||
|
|
|
|||
|
|
function toAscii(str: string) {
|
|||
|
|
return str.replace(/[۰-۹]/g, (d) =>
|
|||
|
|
String(FA_DIGITS.indexOf(d as (typeof FA_DIGITS)[number])),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function toFa(n: number) {
|
|||
|
|
return n.toString().replace(/\d/g, (d) => FA_DIGITS[Number(d)]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Parses a metric string like "18+", "۱۲ms", "99%", "۹۹٪" into a numeric
|
|||
|
|
* target plus a trailing suffix that survives the count animation.
|
|||
|
|
*/
|
|||
|
|
function parse(value: string) {
|
|||
|
|
const ascii = toAscii(value);
|
|||
|
|
const match = ascii.match(/^(\d+(?:\.\d+)?)(.*)$/);
|
|||
|
|
if (!match) return { target: 0, suffix: value, decimals: 0 };
|
|||
|
|
const target = parseFloat(match[1]);
|
|||
|
|
const decimals = match[1].includes('.') ? match[1].split('.')[1].length : 0;
|
|||
|
|
return { target, suffix: match[2], decimals };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);
|
|||
|
|
|
|||
|
|
type Props = {
|
|||
|
|
/** Final string, e.g. "18+", "۱۲ms", "99%" */
|
|||
|
|
value: string;
|
|||
|
|
/** Locale controls digit script in the rendered output */
|
|||
|
|
locale: 'fa' | 'en';
|
|||
|
|
/** Animation duration in ms */
|
|||
|
|
duration?: number;
|
|||
|
|
className?: string;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export function Counter({ value, locale, duration = 1600, className }: Props) {
|
|||
|
|
const { target, suffix, decimals } = parse(value);
|
|||
|
|
const [display, setDisplay] = useState(0);
|
|||
|
|
const elRef = useRef<HTMLSpanElement>(null);
|
|||
|
|
const started = useRef(false);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
const el = elRef.current;
|
|||
|
|
if (!el) return;
|
|||
|
|
|
|||
|
|
const start = () => {
|
|||
|
|
if (started.current) return;
|
|||
|
|
started.current = true;
|
|||
|
|
const t0 = performance.now();
|
|||
|
|
const tick = (now: number) => {
|
|||
|
|
const p = Math.min(1, (now - t0) / duration);
|
|||
|
|
const eased = easeOutCubic(p);
|
|||
|
|
setDisplay(target * eased);
|
|||
|
|
if (p < 1) requestAnimationFrame(tick);
|
|||
|
|
else setDisplay(target);
|
|||
|
|
};
|
|||
|
|
requestAnimationFrame(tick);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
if (typeof IntersectionObserver === 'undefined') {
|
|||
|
|
start();
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const io = new IntersectionObserver(
|
|||
|
|
(entries) => {
|
|||
|
|
for (const e of entries) {
|
|||
|
|
if (e.isIntersecting) {
|
|||
|
|
start();
|
|||
|
|
io.disconnect();
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
{ threshold: 0.4 },
|
|||
|
|
);
|
|||
|
|
io.observe(el);
|
|||
|
|
return () => io.disconnect();
|
|||
|
|
}, [target, duration]);
|
|||
|
|
|
|||
|
|
const formatted = decimals
|
|||
|
|
? display.toFixed(decimals)
|
|||
|
|
: Math.round(display).toString();
|
|||
|
|
const rendered = locale === 'fa' ? toFa(Number(formatted)) : formatted;
|
|||
|
|
const sfx = locale === 'fa' ? suffix : toAscii(suffix);
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<span ref={elRef} className={className}>
|
|||
|
|
{rendered}
|
|||
|
|
{sfx}
|
|||
|
|
</span>
|
|||
|
|
);
|
|||
|
|
}
|