avances en plantillas

This commit is contained in:
JACS 2026-05-01 18:15:40 -05:00
parent 0f84beacf1
commit da0530d79b
2062 changed files with 598814 additions and 22 deletions

View file

@ -0,0 +1,591 @@
import { CountUp, CountUpPlugin } from './countUp';
type IntersectionCallback = (entries: Partial<IntersectionObserverEntry>[]) => void;
class MockIntersectionObserver {
callback: IntersectionCallback;
elements: Element[] = [];
static instances: MockIntersectionObserver[] = [];
constructor(callback: IntersectionCallback) {
this.callback = callback;
MockIntersectionObserver.instances.push(this);
}
observe(el: Element) { this.elements.push(el); }
unobserve(el: Element) { this.elements = this.elements.filter(e => e !== el); }
disconnect() { this.elements = []; }
trigger(isIntersecting: boolean) {
this.callback(this.elements.map(target => ({ isIntersecting, target } as Partial<IntersectionObserverEntry>)));
}
}
describe('CountUp', () => {
let countUp;
let time;
const getTargetHtml = () => document.getElementById('target')?.innerHTML;
const resetRAF = () => {
time = 0;
jest.spyOn(window, 'requestAnimationFrame').mockImplementation(cb => {
time += 100;
if (time < 2500) {
return cb(time) as any;
}
});
};
beforeEach(() => {
document.body.innerHTML =
'<div>' +
' <h1 id="target"></h1>' +
'</div>';
(window as any).IntersectionObserver = MockIntersectionObserver;
MockIntersectionObserver.instances = [];
countUp = new CountUp('target', 100);
resetRAF();
});
describe('constructor', () => {
it('should create for a valid target, and print startVal', () => {
expect(countUp).toBeTruthy();
expect(countUp.error.length).toBe(0);
expect(getTargetHtml()).toEqual('0');
});
it('should set an error for a bad target', () => {
countUp = new CountUp('notThere', 100);
expect(countUp.error.length).toBeGreaterThan(0);
});
it('should set an error for a bad endVal', () => {
const endVal = '%' as any;
countUp = new CountUp('target', endVal);
expect(countUp.error.length).toBeGreaterThan(0);
});
it('should set an error for a bad startVal', () => {
const startVal = 'oops' as any;
countUp = new CountUp('target', 100, { startVal });
expect(countUp.error.length).toBeGreaterThan(0);
});
it('should return a value for version', () => {
expect(countUp.version).toBeTruthy();
});
it('should support getting endVal from the target element', () => {
document.body.innerHTML =
'<div>' +
' <h1 id="target">1,500</h1>' +
'</div>';
countUp = new CountUp('target');
expect(countUp.endVal).toBe(1500);
});
it('should set an error when endVal is omitted and not in target element', () => {
document.body.innerHTML =
'<div>' +
' <h1 id="target"></h1>' +
'</div>';
countUp = new CountUp('target');
expect(countUp.error.length).toBeGreaterThan(0);
});
it('should not call parse when an endVal is passed to the constructor', () => {
const parseSpy = jest.spyOn(CountUp.prototype, 'parse');
countUp = new CountUp('target', 0, { startVal: 100 });
expect(parseSpy).not.toHaveBeenCalled();
parseSpy.mockRestore();
});
});
describe('class methods', () => {
describe('# start', () => {
it('should count when start method is called', () => {
countUp.start();
expect(getTargetHtml()).toEqual('100');
});
it('should use a callback provided to start', () => {
const cb = jest.fn();
countUp.start(cb);
expect(getTargetHtml()).toEqual('100');
expect(cb).toHaveBeenCalled();
});
});
describe('# pauseResume', () => {
it('should pause when pauseResume is called', () => {
countUp.start();
countUp.pauseResume();
expect(countUp.paused).toBeTruthy();
});
});
describe('# reset', () => {
it('should reset when reset is called', () => {
countUp.start();
countUp.reset();
expect(getTargetHtml()).toEqual('0');
expect(countUp.paused).toBeTruthy();
});
});
describe('# update', () => {
it('should update when update is called', () => {
countUp.start();
expect(getTargetHtml()).toEqual('100');
resetRAF();
countUp.update(200);
expect(getTargetHtml()).toEqual('200');
});
});
describe('# onDestroy', () => {
it('should cancel a running animation', () => {
const cancelSpy = jest.spyOn(window, 'cancelAnimationFrame');
countUp.start();
countUp.onDestroy();
expect(cancelSpy).toHaveBeenCalled();
});
it('should set paused to true', () => {
countUp.start();
expect(countUp.paused).toBe(false);
countUp.onDestroy();
expect(countUp.paused).toBe(true);
});
it('should disconnect the observer', () => {
countUp = new CountUp('target', 100, { autoAnimate: true });
const observer = MockIntersectionObserver.instances[MockIntersectionObserver.instances.length - 1];
const disconnectSpy = jest.spyOn(observer, 'disconnect');
countUp.onDestroy();
expect(disconnectSpy).toHaveBeenCalled();
});
it('should clear onCompleteCallback', () => {
const cb = jest.fn();
countUp = new CountUp('target', 100, { onCompleteCallback: cb });
countUp.onDestroy();
expect(countUp.options.onCompleteCallback).toBeNull();
});
it('should clear onStartCallback', () => {
const cb = jest.fn();
countUp = new CountUp('target', 100, { onStartCallback: cb });
countUp.onDestroy();
expect(countUp.options.onStartCallback).toBeNull();
});
it('should prevent onCompleteCallback from firing after destroy', () => {
const cb = jest.fn();
countUp = new CountUp('target', 100, { onCompleteCallback: cb });
countUp.onDestroy();
resetRAF();
countUp.start();
expect(cb).not.toHaveBeenCalled();
});
it('should be safe to call on a fresh instance', () => {
countUp = new CountUp('target', 100);
expect(() => countUp.onDestroy()).not.toThrow();
expect(countUp.paused).toBe(true);
});
});
describe('# parse', () => {
it('should properly parse numbers', () => {
countUp = new CountUp('target', 0);
const result0 = countUp.parse('14,921.00123');
countUp = new CountUp('target', 0, { separator: '.', decimal: ',' });
const result1 = countUp.parse('1.500,0');
countUp = new CountUp('target', 0, { separator: ' ' });
const result2 = countUp.parse('2 800');
expect(result0).toEqual(14921.00123);
expect(result1).toEqual(1500);
expect(result2).toEqual(2800);
});
});
});
describe('various use-cases', () => {
it('should handle large numbers', () => {
countUp = new CountUp('target', 6000);
const spy = jest.spyOn(countUp, 'determineDirectionAndSmartEasing');
countUp.start();
expect(getTargetHtml()).toEqual('6,000');
expect(spy).toHaveBeenCalled();
});
it('should not use easing when specified with a large number (auto-smooth)', () => {
countUp = new CountUp('target', 6000, { useEasing: false });
const spy = jest.spyOn(countUp, 'easingFn');
countUp.start();
expect(getTargetHtml()).toEqual('6,000');
expect(spy).toHaveBeenCalledTimes(0);
});
it('should count down when endVal is less than startVal', () => {
countUp = new CountUp('target', 10, { startVal: 500 });
expect(getTargetHtml()).toEqual('500');
countUp.start();
expect(getTargetHtml()).toEqual('10');
});
it('should handle negative numbers', () => {
countUp = new CountUp('target', -500);
countUp.start();
expect(getTargetHtml()).toEqual('-500');
});
it('should properly handle a zero duration', () => {
countUp = new CountUp('target', 2000, { duration: 0 });
countUp.start();
expect(getTargetHtml()).toEqual('2,000');
});
it('should call the callback when finished if there is one', () => {
const cb = jest.fn();
countUp.start(cb);
expect(getTargetHtml()).toEqual('100');
expect(cb).toHaveBeenCalled();
});
});
describe('options', () => {
it('should respect the decimalPlaces option', () => {
countUp = new CountUp('target', 100, { decimalPlaces: 2 });
countUp.start();
expect(getTargetHtml()).toEqual('100.00');
});
it('should respect the duration option', () => {
countUp = new CountUp('target', 100, { duration: 1 });
countUp.start();
expect(getTargetHtml()).toEqual('100');
});
it('should respect the useEasing option', () => {
countUp = new CountUp('target', 100, { useEasing: false });
countUp.start();
expect(getTargetHtml()).toEqual('100');
});
it('should respect the useGrouping option', () => {
countUp = new CountUp('target', 100000, { useGrouping: false });
countUp.start();
expect(getTargetHtml()).toEqual('100000');
resetRAF();
countUp = new CountUp('target', 1000000, { useGrouping: true });
countUp.start();
expect(getTargetHtml()).toEqual('1,000,000');
});
it('should respect the useIndianSeparators option', () => {
countUp = new CountUp('target', 100000, { useIndianSeparators: true });
countUp.start();
expect(getTargetHtml()).toEqual('1,00,000');
resetRAF();
countUp = new CountUp('target', 10000000, { useIndianSeparators: true });
countUp.start();
expect(getTargetHtml()).toEqual('1,00,00,000');
});
it('should respect the separator option', () => {
countUp = new CountUp('target', 10000, { separator: ':' });
countUp.start();
expect(getTargetHtml()).toEqual('10:000');
});
it('should respect the decimal option', () => {
countUp = new CountUp('target', 100, { decimal: ',', decimalPlaces: 1 });
countUp.start();
expect(getTargetHtml()).toEqual('100,0');
});
it('should respect the easingFn option', () => {
const easeOutQuintic = jest.fn().mockReturnValue(100);
countUp = new CountUp('target', 100, { easingFn: easeOutQuintic });
countUp.start();
expect(easeOutQuintic).toHaveBeenCalled();
expect(getTargetHtml()).toEqual('100');
});
it('should respect the formattingFn option', () => {
const formatter = jest.fn().mockReturnValue('~100~');
countUp = new CountUp('target', 100, { formattingFn: formatter });
countUp.start();
expect(formatter).toHaveBeenCalled();
expect(getTargetHtml()).toEqual('~100~');
});
it('should respect the prefix option', () => {
countUp = new CountUp('target', 100, { prefix: '$' });
countUp.start();
expect(getTargetHtml()).toEqual('$100');
});
it('should respect the suffix option', () => {
countUp = new CountUp('target', 100, { suffix: '!' });
countUp.start();
expect(getTargetHtml()).toEqual('100!');
});
it('should respect the numerals option', () => {
const numerals = [')', '!', '@', '#', '$', '%', '^', '&', '*', '('];
countUp = new CountUp('target', 100, { numerals });
countUp.start();
expect(getTargetHtml()).toEqual('!))');
});
it('should respect the onCompleteCallback option', () => {
const options = { onCompleteCallback: jest.fn() };
const callbackSpy = jest.spyOn(options, 'onCompleteCallback');
countUp = new CountUp('target', 100, options);
countUp.start();
expect(getTargetHtml()).toEqual('100');
expect(callbackSpy).toHaveBeenCalled();
});
it('should respect the onStartCallback option', () => {
const options = { onStartCallback: jest.fn() };
const callbackSpy = jest.spyOn(options, 'onStartCallback');
countUp = new CountUp('target', 100, options);
countUp.start();
expect(callbackSpy).toHaveBeenCalled();
expect(getTargetHtml()).toEqual('100');
});
it('should respect the plugin option', () => {
const plugin: CountUpPlugin = {
render: (el, result) => {
el.innerHTML = result;
}
};
countUp = new CountUp('target', 1000, {
plugin,
useGrouping: true
});
countUp.start();
expect(getTargetHtml()).toEqual('1,000');
});
});
describe('autoAnimate (IntersectionObserver)', () => {
beforeEach(() => {
jest.useFakeTimers({ doNotFake: ['requestAnimationFrame'] });
});
afterEach(() => {
jest.useRealTimers();
});
it('should create an IntersectionObserver when autoAnimate is true', () => {
countUp = new CountUp('target', 100, { autoAnimate: true });
expect(MockIntersectionObserver.instances.length).toBe(1);
expect(MockIntersectionObserver.instances[0].elements).toContain(countUp.el);
});
it('should not create an observer when autoAnimate is false', () => {
MockIntersectionObserver.instances = [];
countUp = new CountUp('target', 100);
expect(MockIntersectionObserver.instances.length).toBe(0);
});
it('should start animation when element becomes visible', () => {
countUp = new CountUp('target', 100, { autoAnimate: true, autoAnimateDelay: 0 });
resetRAF();
const observer = MockIntersectionObserver.instances[0];
observer.trigger(true);
jest.advanceTimersByTime(0);
expect(getTargetHtml()).toEqual('100');
});
it('should respect autoAnimateDelay before starting', () => {
countUp = new CountUp('target', 100, { autoAnimate: true, autoAnimateDelay: 500 });
resetRAF();
const startSpy = jest.spyOn(countUp, 'start');
const observer = MockIntersectionObserver.instances[0];
observer.trigger(true);
expect(startSpy).not.toHaveBeenCalled();
jest.advanceTimersByTime(500);
expect(startSpy).toHaveBeenCalled();
});
it('should reset when element goes out of view', () => {
countUp = new CountUp('target', 100, { autoAnimate: true, autoAnimateDelay: 0 });
resetRAF();
const observer = MockIntersectionObserver.instances[0];
observer.trigger(true);
jest.advanceTimersByTime(0);
expect(getTargetHtml()).toEqual('100');
observer.trigger(false);
expect(countUp.paused).toBe(true);
expect(getTargetHtml()).toEqual('0');
});
it('should disconnect observer when autoAnimateOnce is true', () => {
countUp = new CountUp('target', 100, { autoAnimate: true, autoAnimateOnce: true, autoAnimateDelay: 0 });
const observer = MockIntersectionObserver.instances[0];
const disconnectSpy = jest.spyOn(observer, 'disconnect');
observer.trigger(true);
jest.advanceTimersByTime(0);
expect(disconnectSpy).toHaveBeenCalled();
expect(countUp.once).toBe(true);
});
it('should not disconnect observer when autoAnimateOnce is false', () => {
countUp = new CountUp('target', 100, { autoAnimate: true, autoAnimateOnce: false, autoAnimateDelay: 0 });
const observer = MockIntersectionObserver.instances[0];
const disconnectSpy = jest.spyOn(observer, 'disconnect');
observer.trigger(true);
jest.advanceTimersByTime(0);
expect(disconnectSpy).not.toHaveBeenCalled();
});
it('should not re-animate after first run when autoAnimateOnce is true', () => {
countUp = new CountUp('target', 100, { autoAnimate: true, autoAnimateOnce: true, autoAnimateDelay: 0 });
resetRAF();
const observer = MockIntersectionObserver.instances[MockIntersectionObserver.instances.length - 1];
observer.trigger(true);
jest.advanceTimersByTime(0);
expect(getTargetHtml()).toEqual('100');
// observer was disconnected so subsequent triggers process no entries
observer.trigger(false);
expect(getTargetHtml()).toEqual('100');
observer.trigger(true);
jest.advanceTimersByTime(0);
expect(getTargetHtml()).toEqual('100');
});
it('should allow re-animation after manual reset when autoAnimateOnce is true', () => {
countUp = new CountUp('target', 100, { autoAnimate: true, autoAnimateOnce: true, autoAnimateDelay: 0 });
resetRAF();
const observer = MockIntersectionObserver.instances[MockIntersectionObserver.instances.length - 1];
observer.trigger(true);
jest.advanceTimersByTime(0);
expect(getTargetHtml()).toEqual('100');
expect(countUp.once).toBe(true);
// manual reset clears the once flag
countUp.reset();
expect(getTargetHtml()).toEqual('0');
expect(countUp.once).toBe(false);
// re-observe and trigger — animation should play again
observer.observe(countUp.el);
resetRAF();
observer.trigger(true);
jest.advanceTimersByTime(0);
expect(getTargetHtml()).toEqual('100');
});
it('should support multiple independent instances', () => {
document.body.innerHTML =
'<h1 id="target1"></h1>' +
'<h1 id="target2"></h1>';
MockIntersectionObserver.instances = [];
const cu1 = new CountUp('target1', 50, { autoAnimate: true, autoAnimateDelay: 0 });
const cu2 = new CountUp('target2', 200, { autoAnimate: true, autoAnimateDelay: 0 });
expect(MockIntersectionObserver.instances.length).toBe(2);
const obs1 = MockIntersectionObserver.instances[0];
const obs2 = MockIntersectionObserver.instances[1];
expect(obs1.elements).toContain(cu1.el);
expect(obs2.elements).toContain(cu2.el);
expect(obs1).not.toBe(obs2);
resetRAF();
obs1.trigger(true);
jest.advanceTimersByTime(0);
expect(document.getElementById('target1')!.innerHTML).toEqual('50');
expect(cu2.paused).toBe(true);
});
it('should allow cleanup via unobserve()', () => {
countUp = new CountUp('target', 100, { autoAnimate: true });
const observer = MockIntersectionObserver.instances[0];
const disconnectSpy = jest.spyOn(observer, 'disconnect');
countUp.unobserve();
expect(disconnectSpy).toHaveBeenCalled();
});
it('should map deprecated enableScrollSpy to autoAnimate', () => {
countUp = new CountUp('target', 100, { enableScrollSpy: true });
expect(countUp.options.autoAnimate).toBe(true);
expect(MockIntersectionObserver.instances.length).toBe(1);
});
});
});

View file

@ -0,0 +1,425 @@
export interface CountUpOptions {
/** Number to start at @default 0 */
startVal?: number;
/** Number of decimal places @default 0 */
decimalPlaces?: number;
/** Animation duration in seconds @default 2 */
duration?: number;
/** Example: 1,000 vs 1000 @default true */
useGrouping?: boolean;
/** Example: 1,00,000 vs 100,000 @default false */
useIndianSeparators?: boolean;
/** Ease animation @default true */
useEasing?: boolean;
/** Smooth easing for large numbers above this if useEasing @default 999 */
smartEasingThreshold?: number;
/** Amount to be eased for numbers above threshold @default 333 */
smartEasingAmount?: number;
/** Grouping separator @default ',' */
separator?: string;
/** Decimal character @default '.' */
decimal?: string;
/** Easing function for animation @default easeOutExpo */
easingFn?: (t: number, b: number, c: number, d: number) => number;
/** Custom function to format the result */
formattingFn?: (n: number) => string;
/** Text prepended to result */
prefix?: string;
/** Text appended to result */
suffix?: string;
/** Numeral glyph substitution */
numerals?: string[];
/** Callback called when animation completes */
onCompleteCallback?: () => any;
/** Callback called when animation starts */
onStartCallback?: () => any;
/** Plugin for alternate animations */
plugin?: CountUpPlugin;
/** Trigger animation when target becomes visible @default false */
autoAnimate?: boolean;
/** Animation delay in ms after auto-animate triggers @default 200 */
autoAnimateDelay?: number;
/** Run animation only once for auto-animate triggers @default false */
autoAnimateOnce?: boolean;
/** @deprecated Please use autoAnimate instead */
enableScrollSpy?: boolean;
/** @deprecated Please use autoAnimateDelay instead */
scrollSpyDelay?: number;
/** @deprecated Please use autoAnimateOnce instead */
scrollSpyOnce?: boolean;
}
export declare interface CountUpPlugin {
render(elem: HTMLElement, formatted: string): void;
}
/**
* Animates a number by counting to it.
* playground: stackblitz.com/edit/countup-typescript
*
* @param target - id of html element, input, svg text element, or DOM element reference where counting occurs.
* @param endVal - the value you want to arrive at.
* @param options - optional configuration object for fine-grain control
*/
export class CountUp {
version = '2.10.0';
private static observedElements = new WeakMap<HTMLElement, CountUp>();
private defaults: CountUpOptions = {
startVal: 0,
decimalPlaces: 0,
duration: 2,
useEasing: true,
useGrouping: true,
useIndianSeparators: false,
smartEasingThreshold: 999,
smartEasingAmount: 333,
separator: ',',
decimal: '.',
prefix: '',
suffix: '',
autoAnimate: false,
autoAnimateDelay: 200,
autoAnimateOnce: false,
};
private rAF: any;
private autoAnimateTimeout: any;
private startTime: number;
private remaining: number;
private finalEndVal: number = null; // for smart easing
private useEasing = true;
private countDown = false;
private observer: IntersectionObserver;
el: HTMLElement | HTMLInputElement;
formattingFn: (num: number) => string;
easingFn?: (t: number, b: number, c: number, d: number) => number;
error = '';
startVal = 0;
duration: number;
paused = true;
frameVal: number;
once = false;
constructor(
target: string | HTMLElement | HTMLInputElement,
private endVal?: number | null,
public options?: CountUpOptions
) {
this.options = {
...this.defaults,
...options
};
if (this.options.enableScrollSpy) {
this.options.autoAnimate = true;
}
if (this.options.scrollSpyDelay !== undefined) {
this.options.autoAnimateDelay = this.options.scrollSpyDelay;
}
if (this.options.scrollSpyOnce) {
this.options.autoAnimateOnce = true;
}
this.formattingFn = (this.options.formattingFn) ?
this.options.formattingFn : this.formatNumber;
this.easingFn = (this.options.easingFn) ?
this.options.easingFn : this.easeOutExpo;
this.el = (typeof target === 'string') ? document.getElementById(target) : target;
endVal = endVal == null ? this.parse(this.el.innerHTML) : endVal;
this.startVal = this.validateValue(this.options.startVal);
this.frameVal = this.startVal;
this.endVal = this.validateValue(endVal);
this.options.decimalPlaces = Math.max(0 || this.options.decimalPlaces);
this.resetDuration();
this.options.separator = String(this.options.separator);
this.useEasing = this.options.useEasing;
if (this.options.separator === '') {
this.options.useGrouping = false;
}
if (this.el) {
this.printValue(this.startVal);
} else {
this.error = '[CountUp] target is null or undefined';
}
if (typeof window !== 'undefined' && this.options.autoAnimate) {
if (!this.error && typeof IntersectionObserver !== 'undefined') {
this.setupObserver();
} else {
if (this.error) {
console.error(this.error, target);
} else {
console.error('IntersectionObserver is not supported by this browser');
}
}
}
}
/** Set up an IntersectionObserver to auto-animate when the target element appears. */
private setupObserver(): void {
const existing = CountUp.observedElements.get(this.el as HTMLElement);
if (existing) {
existing.unobserve();
}
CountUp.observedElements.set(this.el as HTMLElement, this);
this.observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting && this.paused && !this.once) {
this.paused = false;
this.autoAnimateTimeout = setTimeout(() => this.start(), this.options.autoAnimateDelay);
if (this.options.autoAnimateOnce) {
this.once = true;
this.observer.disconnect();
}
} else if (!entry.isIntersecting && !this.paused) {
clearTimeout(this.autoAnimateTimeout);
this.reset();
}
}
}, { threshold: 0 });
this.observer.observe(this.el);
}
/** Disconnect the IntersectionObserver and stop watching this element. */
unobserve(): void {
clearTimeout(this.autoAnimateTimeout);
this.observer?.disconnect();
CountUp.observedElements.delete(this.el as HTMLElement);
}
/** Teardown: cancel animation, disconnect observer, clear callbacks. */
onDestroy(): void {
clearTimeout(this.autoAnimateTimeout);
cancelAnimationFrame(this.rAF);
this.paused = true;
this.unobserve();
this.options.onCompleteCallback = null;
this.options.onStartCallback = null;
}
/**
* Smart easing works by breaking the animation into 2 parts, the second part being the
* smartEasingAmount and first part being the total amount minus the smartEasingAmount. It works
* by disabling easing for the first part and enabling it on the second part. It is used if
* useEasing is true and the total animation amount exceeds the smartEasingThreshold.
*/
private determineDirectionAndSmartEasing(): void {
const end = (this.finalEndVal) ? this.finalEndVal : this.endVal;
this.countDown = (this.startVal > end);
const animateAmount = end - this.startVal;
if (Math.abs(animateAmount) > this.options.smartEasingThreshold && this.options.useEasing) {
this.finalEndVal = end;
const up = (this.countDown) ? 1 : -1;
this.endVal = end + (up * this.options.smartEasingAmount);
this.duration = this.duration / 2;
} else {
this.endVal = end;
this.finalEndVal = null;
}
if (this.finalEndVal !== null) {
// setting finalEndVal indicates smart easing
this.useEasing = false;
} else {
this.useEasing = this.options.useEasing;
}
}
/** Start the animation. Optionally pass a callback that fires on completion. */
start(callback?: (args?: any) => any): void {
if (this.error) {
return;
}
if (this.options.onStartCallback) {
this.options.onStartCallback();
}
if (callback) {
this.options.onCompleteCallback = callback;
}
if (this.duration > 0) {
this.determineDirectionAndSmartEasing();
this.paused = false;
this.rAF = requestAnimationFrame(this.count);
} else {
this.printValue(this.endVal);
}
}
/** Toggle pause/resume on the animation. */
pauseResume(): void {
if (!this.paused) {
cancelAnimationFrame(this.rAF);
} else {
this.startTime = null;
this.duration = this.remaining;
this.startVal = this.frameVal;
this.determineDirectionAndSmartEasing();
this.rAF = requestAnimationFrame(this.count);
}
this.paused = !this.paused;
}
/** Reset to startVal so the animation can be run again. */
reset(): void {
clearTimeout(this.autoAnimateTimeout);
cancelAnimationFrame(this.rAF);
this.paused = true;
this.once = false;
this.resetDuration();
this.startVal = this.validateValue(this.options.startVal);
this.frameVal = this.startVal;
this.printValue(this.startVal);
}
/** Pass a new endVal and start the animation. */
update(newEndVal: string | number): void {
cancelAnimationFrame(this.rAF);
this.startTime = null;
this.endVal = this.validateValue(newEndVal);
if (this.endVal === this.frameVal) {
return;
}
this.startVal = this.frameVal;
if (this.finalEndVal == null) {
this.resetDuration();
}
this.finalEndVal = null;
this.determineDirectionAndSmartEasing();
this.rAF = requestAnimationFrame(this.count);
}
/** Animation frame callback — advances the value each frame. */
count = (timestamp: number): void => {
if (!this.startTime) { this.startTime = timestamp; }
const progress = timestamp - this.startTime;
this.remaining = this.duration - progress;
// to ease or not to ease
if (this.useEasing) {
if (this.countDown) {
this.frameVal = this.startVal - this.easingFn(progress, 0, this.startVal - this.endVal, this.duration);
} else {
this.frameVal = this.easingFn(progress, this.startVal, this.endVal - this.startVal, this.duration);
}
} else {
this.frameVal = this.startVal + (this.endVal - this.startVal) * (progress / this.duration);
}
// don't go past endVal since progress can exceed duration in the last frame
const wentPast = this.countDown ? this.frameVal < this.endVal : this.frameVal > this.endVal;
this.frameVal = wentPast ? this.endVal : this.frameVal;
// decimal
this.frameVal = Number(this.frameVal.toFixed(this.options.decimalPlaces));
// format and print value
this.printValue(this.frameVal);
// whether to continue
if (progress < this.duration) {
this.rAF = requestAnimationFrame(this.count);
} else if (this.finalEndVal !== null) {
// smart easing
this.update(this.finalEndVal);
} else {
if (this.options.onCompleteCallback) {
this.options.onCompleteCallback();
}
}
}
/** Format and render the given value to the target element. */
printValue(val: number): void {
if (!this.el) return;
const result = this.formattingFn(val);
if (this.options.plugin?.render) {
this.options.plugin.render(this.el, result);
return;
}
if (this.el.tagName === 'INPUT') {
const input = this.el as HTMLInputElement;
input.value = result;
} else if (this.el.tagName === 'text' || this.el.tagName === 'tspan') {
this.el.textContent = result;
} else {
this.el.innerHTML = result;
}
}
/** Return true if the value is a finite number. */
ensureNumber(n: any): boolean {
return (typeof n === 'number' && !isNaN(n));
}
/** Validate and convert a value to a number, setting an error if invalid. */
validateValue(value: string | number): number {
const newValue = Number(value);
if (!this.ensureNumber(newValue)) {
this.error = `[CountUp] invalid start or end value: ${value}`;
return null;
} else {
return newValue;
}
}
/** Reset startTime, duration, and remaining to their initial values. */
private resetDuration(): void {
this.startTime = null;
this.duration = Number(this.options.duration) * 1000;
this.remaining = this.duration;
}
/** Default number formatter with grouping, decimals, prefix/suffix, and numeral substitution. */
formatNumber = (num: number): string => {
const neg = (num < 0) ? '-' : '';
let result: string, x1: string, x2: string, x3: string;
result = Math.abs(num).toFixed(this.options.decimalPlaces);
result += '';
const x = result.split('.');
x1 = x[0];
x2 = x.length > 1 ? this.options.decimal + x[1] : '';
if (this.options.useGrouping) {
x3 = '';
let factor = 3, j = 0;
for (let i = 0, len = x1.length; i < len; ++i) {
if (this.options.useIndianSeparators && i === 4) {
factor = 2;
j = 1;
}
if (i !== 0 && (j % factor) === 0) {
x3 = this.options.separator + x3;
}
j++;
x3 = x1[len - i - 1] + x3;
}
x1 = x3;
}
// optional numeral substitution
if (this.options.numerals && this.options.numerals.length) {
x1 = x1.replace(/[0-9]/g, (w) => this.options.numerals[+w]);
x2 = x2.replace(/[0-9]/g, (w) => this.options.numerals[+w]);
}
return neg + this.options.prefix + x1 + x2 + this.options.suffix;
}
/**
* Default easing function (easeOutExpo).
* @param t current time
* @param b beginning value
* @param c change in value
* @param d duration
*/
easeOutExpo = (t: number, b: number, c: number, d: number): number =>
c * (-Math.pow(2, -10 * t / d) + 1) * 1024 / 1023 + b;
/** Parse a formatted string back to a number using the current separator/decimal options. */
parse(number: string): number {
// eslint-disable-next-line no-irregular-whitespace
const escapeRegExp = (s: string) => s.replace(/([.,'  ])/g, '\\$1');
const sep = escapeRegExp(this.options.separator);
const dec = escapeRegExp(this.options.decimal);
const num = number.replace(new RegExp(sep, 'g'), '').replace(new RegExp(dec, 'g'), '.');
return parseFloat(num)
}
}