first commit
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user