mirror of
https://github.com/HolgerHatGarKeineNode/einundzwanzig-nostr.git
synced 2026-01-27 06:33:18 +00:00
🎯 Add StatsCounter component with animated number counting
Implement reusable animated statistics counter component for Remotion videos that smoothly animates from 0 to a target number (default: 204). Features: - Spring-based or linear animation modes - Configurable delay, duration, and decimal places - Support for prefix/suffix (e.g., "$", "%", "+") - Customizable colors and font sizes - Glow effect that pulses with counting progress - Optional label display below the number Includes comprehensive test suite with 12 passing tests. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
96
videos/src/components/StatsCounter.test.tsx
Normal file
96
videos/src/components/StatsCounter.test.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { StatsCounter } from "./StatsCounter";
|
||||
|
||||
// Mock Remotion hooks
|
||||
vi.mock("remotion", () => ({
|
||||
useCurrentFrame: vi.fn(() => 0),
|
||||
useVideoConfig: vi.fn(() => ({ fps: 30, width: 1920, height: 1080 })),
|
||||
interpolate: vi.fn((value, inputRange, outputRange) => {
|
||||
// Simple linear interpolation mock
|
||||
const [inMin, inMax] = inputRange;
|
||||
const [outMin, outMax] = outputRange;
|
||||
const progress = Math.max(0, Math.min(1, (value - inMin) / (inMax - inMin)));
|
||||
return outMin + progress * (outMax - outMin);
|
||||
}),
|
||||
spring: vi.fn(() => 1), // Return 1 for fully animated state
|
||||
Easing: {
|
||||
out: vi.fn((fn) => fn),
|
||||
cubic: vi.fn((t: number) => t),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("StatsCounter", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("renders without errors", () => {
|
||||
const { container } = render(<StatsCounter />);
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays the default target number (204)", () => {
|
||||
const { container } = render(<StatsCounter />);
|
||||
const numberElement = container.querySelector(".tabular-nums");
|
||||
expect(numberElement).toHaveTextContent("204");
|
||||
});
|
||||
|
||||
it("displays a custom target number", () => {
|
||||
render(<StatsCounter targetNumber={500} />);
|
||||
expect(screen.getByText("500")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays a label when provided", () => {
|
||||
render(<StatsCounter label="Active Members" />);
|
||||
expect(screen.getByText("Active Members")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays prefix when provided", () => {
|
||||
render(<StatsCounter targetNumber={100} prefix="$" />);
|
||||
expect(screen.getByText("$100")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays suffix when provided", () => {
|
||||
render(<StatsCounter targetNumber={100} suffix="%" />);
|
||||
expect(screen.getByText("100%")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays prefix and suffix together", () => {
|
||||
render(<StatsCounter targetNumber={50} prefix="~" suffix="+" />);
|
||||
expect(screen.getByText("~50+")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays decimal places when specified", () => {
|
||||
render(<StatsCounter targetNumber={3.14159} decimals={2} />);
|
||||
expect(screen.getByText("3.14")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies custom color style", () => {
|
||||
const { container } = render(<StatsCounter color="#00ff00" />);
|
||||
const numberElement = container.querySelector(".tabular-nums");
|
||||
expect(numberElement).toHaveStyle({ color: "#00ff00" });
|
||||
});
|
||||
|
||||
it("applies custom font size", () => {
|
||||
const { container } = render(<StatsCounter fontSize={200} />);
|
||||
const numberElement = container.querySelector(".tabular-nums");
|
||||
expect(numberElement).toHaveStyle({ fontSize: "200px" });
|
||||
});
|
||||
|
||||
it("does not render label when not provided", () => {
|
||||
const { container } = render(<StatsCounter />);
|
||||
const labels = container.querySelectorAll(".text-zinc-300");
|
||||
expect(labels.length).toBe(0);
|
||||
});
|
||||
|
||||
it("uses tabular-nums class for consistent number width", () => {
|
||||
const { container } = render(<StatsCounter />);
|
||||
const numberElement = container.querySelector(".tabular-nums");
|
||||
expect(numberElement).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
128
videos/src/components/StatsCounter.tsx
Normal file
128
videos/src/components/StatsCounter.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
interpolate,
|
||||
spring,
|
||||
Easing,
|
||||
} from "remotion";
|
||||
|
||||
export interface StatsCounterProps {
|
||||
/** The target number to count to (default: 204) */
|
||||
targetNumber?: number;
|
||||
/** Delay in frames before animation starts */
|
||||
delay?: number;
|
||||
/** Duration of the counting animation in frames (default: 60) */
|
||||
duration?: number;
|
||||
/** Label text displayed below the number */
|
||||
label?: string;
|
||||
/** Font size for the number in pixels (default: 120) */
|
||||
fontSize?: number;
|
||||
/** Whether to use spring animation (smoother) or linear interpolation */
|
||||
useSpring?: boolean;
|
||||
/** Prefix to display before the number (e.g., "$", "€") */
|
||||
prefix?: string;
|
||||
/** Suffix to display after the number (e.g., "%", "+") */
|
||||
suffix?: string;
|
||||
/** Number of decimal places to show (default: 0) */
|
||||
decimals?: number;
|
||||
/** Custom color for the number (default: #f7931a - Bitcoin orange) */
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export const StatsCounter: React.FC<StatsCounterProps> = ({
|
||||
targetNumber = 204,
|
||||
delay = 0,
|
||||
duration = 60,
|
||||
label,
|
||||
fontSize = 120,
|
||||
useSpring: useSpringAnimation = true,
|
||||
prefix = "",
|
||||
suffix = "",
|
||||
decimals = 0,
|
||||
color = "#f7931a",
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// Calculate animation progress
|
||||
const adjustedFrame = Math.max(0, frame - delay);
|
||||
|
||||
let progress: number;
|
||||
|
||||
if (useSpringAnimation) {
|
||||
// Spring-based animation for smoother, more natural feel
|
||||
const springValue = spring({
|
||||
frame: adjustedFrame,
|
||||
fps,
|
||||
config: {
|
||||
damping: 20,
|
||||
stiffness: 80,
|
||||
mass: 1,
|
||||
},
|
||||
durationInFrames: duration,
|
||||
});
|
||||
progress = springValue;
|
||||
} else {
|
||||
// Linear interpolation with easeOut for consistent timing
|
||||
progress = interpolate(adjustedFrame, [0, duration], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
easing: Easing.out(Easing.cubic),
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate the current displayed number
|
||||
const currentNumber = progress * targetNumber;
|
||||
const displayNumber = currentNumber.toFixed(decimals);
|
||||
|
||||
// Scale animation for entrance
|
||||
const scaleSpring = spring({
|
||||
frame: adjustedFrame,
|
||||
fps,
|
||||
config: { damping: 15, stiffness: 100 },
|
||||
});
|
||||
const scale = interpolate(scaleSpring, [0, 1], [0.5, 1]);
|
||||
const opacity = interpolate(scaleSpring, [0, 1], [0, 1]);
|
||||
|
||||
// Subtle glow effect that pulses with the counting
|
||||
const glowIntensity = interpolate(progress, [0, 0.5, 1], [0.3, 0.6, 0.4]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center"
|
||||
style={{
|
||||
transform: `scale(${scale})`,
|
||||
opacity,
|
||||
}}
|
||||
>
|
||||
{/* Number with glow effect */}
|
||||
<div
|
||||
className="relative font-bold tabular-nums"
|
||||
style={{
|
||||
fontSize,
|
||||
color,
|
||||
textShadow: `0 0 ${20 * glowIntensity}px ${color}, 0 0 ${40 * glowIntensity}px ${color}40`,
|
||||
fontFamily: "Inconsolata, monospace",
|
||||
letterSpacing: "-0.02em",
|
||||
}}
|
||||
>
|
||||
{prefix}
|
||||
{displayNumber}
|
||||
{suffix}
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
{label && (
|
||||
<div
|
||||
className="mt-4 text-zinc-300 font-medium tracking-wide"
|
||||
style={{
|
||||
fontSize: fontSize * 0.2,
|
||||
opacity: interpolate(scaleSpring, [0, 1], [0, 0.9]),
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user