📈 Add SparklineChart component with animated SVG line drawing

Implement SparklineChart for visualizing data trends with animated line
drawing using stroke-dasharray/dashoffset technique. Features include
configurable dimensions, spring animations, optional fill gradient,
glow effects, and support for delay/duration parameters.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
HolgerHatGarKeineNode
2026-01-24 12:55:01 +01:00
parent d7966580f5
commit 6a8578494b
2 changed files with 454 additions and 0 deletions

View File

@@ -0,0 +1,178 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render } from "@testing-library/react";
import { SparklineChart } from "./SparklineChart";
// Mock Remotion hooks
vi.mock("remotion", () => ({
useCurrentFrame: vi.fn(() => 60), // Midway through animation
useVideoConfig: vi.fn(() => ({ fps: 30, width: 1920, height: 1080 })),
interpolate: vi.fn((value, inputRange, outputRange, options) => {
// Simple linear interpolation mock
const [inMin, inMax] = inputRange;
const [outMin, outMax] = outputRange;
let progress = (value - inMin) / (inMax - inMin);
if (options?.extrapolateLeft === "clamp") {
progress = Math.max(0, progress);
}
if (options?.extrapolateRight === "clamp") {
progress = Math.min(1, progress);
}
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("SparklineChart", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.resetAllMocks();
});
it("renders without errors", () => {
const { container } = render(<SparklineChart data={[1, 2, 3, 4, 5]} />);
expect(container).toBeInTheDocument();
});
it("renders an SVG element", () => {
const { container } = render(<SparklineChart data={[1, 2, 3, 4, 5]} />);
const svg = container.querySelector("svg");
expect(svg).toBeInTheDocument();
});
it("renders with default dimensions", () => {
const { container } = render(<SparklineChart data={[1, 2, 3, 4, 5]} />);
const svg = container.querySelector("svg");
expect(svg).toHaveAttribute("width", "100");
expect(svg).toHaveAttribute("height", "30");
});
it("renders with custom dimensions", () => {
const { container } = render(
<SparklineChart data={[1, 2, 3, 4, 5]} width={200} height={50} />
);
const svg = container.querySelector("svg");
expect(svg).toHaveAttribute("width", "200");
expect(svg).toHaveAttribute("height", "50");
});
it("renders a path element for the line", () => {
const { container } = render(<SparklineChart data={[1, 2, 3, 4, 5]} />);
const paths = container.querySelectorAll("path");
// At least one path for the main line
expect(paths.length).toBeGreaterThanOrEqual(1);
});
it("applies custom stroke color", () => {
const { container } = render(
<SparklineChart data={[1, 2, 3, 4, 5]} color="#00ff00" />
);
const path = container.querySelector('path[stroke="#00ff00"]');
expect(path).toBeInTheDocument();
});
it("applies custom stroke width", () => {
const { container } = render(
<SparklineChart data={[1, 2, 3, 4, 5]} strokeWidth={4} />
);
const path = container.querySelector('path[stroke-width="4"]');
expect(path).toBeInTheDocument();
});
it("returns null for empty data", () => {
const { container } = render(<SparklineChart data={[]} />);
const svg = container.querySelector("svg");
expect(svg).toBeNull();
});
it("handles single data point", () => {
const { container } = render(<SparklineChart data={[42]} />);
const svg = container.querySelector("svg");
expect(svg).toBeInTheDocument();
const path = container.querySelector("path");
expect(path).toBeInTheDocument();
});
it("renders fill gradient when showFill is true", () => {
const { container } = render(
<SparklineChart data={[1, 2, 3, 4, 5]} showFill />
);
const linearGradient = container.querySelector("linearGradient");
expect(linearGradient).toBeInTheDocument();
});
it("renders glow filter when showGlow is true (default)", () => {
const { container } = render(<SparklineChart data={[1, 2, 3, 4, 5]} />);
const filter = container.querySelector("filter");
expect(filter).toBeInTheDocument();
});
it("does not render glow filter when showGlow is false", () => {
const { container } = render(
<SparklineChart data={[1, 2, 3, 4, 5]} showGlow={false} />
);
const filter = container.querySelector("filter");
expect(filter).toBeNull();
});
it("uses stroke-dasharray for animation", () => {
const { container } = render(<SparklineChart data={[1, 2, 3, 4, 5]} />);
const path = container.querySelector('path[fill="none"]');
expect(path).toHaveAttribute("stroke-dasharray");
expect(path).toHaveAttribute("stroke-dashoffset");
});
it("uses default Bitcoin orange color", () => {
const { container } = render(<SparklineChart data={[1, 2, 3, 4, 5]} />);
const path = container.querySelector('path[stroke="#f7931a"]');
expect(path).toBeInTheDocument();
});
it("renders with correct viewBox", () => {
const { container } = render(
<SparklineChart data={[1, 2, 3, 4, 5]} width={150} height={40} />
);
const svg = container.querySelector("svg");
expect(svg).toHaveAttribute("viewBox", "0 0 150 40");
});
it("handles constant data (all same values)", () => {
const { container } = render(<SparklineChart data={[5, 5, 5, 5, 5]} />);
const svg = container.querySelector("svg");
expect(svg).toBeInTheDocument();
const path = container.querySelector("path");
expect(path).toBeInTheDocument();
});
it("handles negative values", () => {
const { container } = render(<SparklineChart data={[-5, -2, 0, 3, 5]} />);
const svg = container.querySelector("svg");
expect(svg).toBeInTheDocument();
});
it("renders two paths when showFill is enabled", () => {
const { container } = render(
<SparklineChart data={[1, 2, 3, 4, 5]} showFill />
);
const paths = container.querySelectorAll("path");
expect(paths.length).toBe(2); // One for fill, one for line
});
it("sets stroke-linecap to round", () => {
const { container } = render(<SparklineChart data={[1, 2, 3, 4, 5]} />);
const path = container.querySelector('path[stroke-linecap="round"]');
expect(path).toBeInTheDocument();
});
it("sets stroke-linejoin to round", () => {
const { container } = render(<SparklineChart data={[1, 2, 3, 4, 5]} />);
const path = container.querySelector('path[stroke-linejoin="round"]');
expect(path).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,276 @@
import {
useCurrentFrame,
useVideoConfig,
interpolate,
spring,
Easing,
} from "remotion";
import { useMemo, useId } from "react";
export interface SparklineChartProps {
/** Data points for the sparkline (array of numbers) */
data: number[];
/** Width of the chart in pixels (default: 100) */
width?: number;
/** Height of the chart in pixels (default: 30) */
height?: number;
/** Stroke color (default: #f7931a - Bitcoin orange) */
color?: string;
/** Stroke width in pixels (default: 2) */
strokeWidth?: number;
/** Delay in frames before animation starts (default: 0) */
delay?: number;
/** Duration of the draw animation in frames (default: 45) */
duration?: number;
/** Whether to use spring animation (default: true) */
useSpring?: boolean;
/** Whether to show a fill gradient below the line (default: false) */
showFill?: boolean;
/** Fill opacity (0-1, default: 0.2) */
fillOpacity?: number;
/** Whether to show a glow effect (default: true) */
showGlow?: boolean;
/** Padding inside the chart (default: 2) */
padding?: number;
}
/**
* Generates SVG path data for a sparkline from data points
*/
function generatePath(
data: number[],
width: number,
height: number,
padding: number
): string {
if (data.length === 0) return "";
if (data.length === 1) {
const y = height / 2;
return `M ${padding} ${y} L ${width - padding} ${y}`;
}
const min = Math.min(...data);
const max = Math.max(...data);
const range = max - min || 1;
const innerWidth = width - padding * 2;
const innerHeight = height - padding * 2;
const points = data.map((value, index) => {
const x = padding + (index / (data.length - 1)) * innerWidth;
const normalizedValue = (value - min) / range;
// Invert Y axis (SVG 0,0 is top-left)
const y = padding + (1 - normalizedValue) * innerHeight;
return { x, y };
});
// Create path with smooth curves (Catmull-Rom to Bezier conversion)
const pathParts = points.map((point, i) => {
if (i === 0) {
return `M ${point.x} ${point.y}`;
}
return `L ${point.x} ${point.y}`;
});
return pathParts.join(" ");
}
/**
* Generates a closed path for the fill area below the line
*/
function generateFillPath(
data: number[],
width: number,
height: number,
padding: number
): string {
if (data.length === 0) return "";
const linePath = generatePath(data, width, height, padding);
if (!linePath) return "";
// Close the path at the bottom
const firstX = padding;
const lastX = width - padding;
const bottomY = height - padding;
return `${linePath} L ${lastX} ${bottomY} L ${firstX} ${bottomY} Z`;
}
/**
* Calculate the total length of an SVG path
*/
function calculatePathLength(
data: number[],
width: number,
height: number,
padding: number
): number {
if (data.length < 2) return 0;
const min = Math.min(...data);
const max = Math.max(...data);
const range = max - min || 1;
const innerWidth = width - padding * 2;
const innerHeight = height - padding * 2;
let totalLength = 0;
for (let i = 1; i < data.length; i++) {
const x1 = padding + ((i - 1) / (data.length - 1)) * innerWidth;
const x2 = padding + (i / (data.length - 1)) * innerWidth;
const y1 = padding + (1 - (data[i - 1] - min) / range) * innerHeight;
const y2 = padding + (1 - (data[i] - min) / range) * innerHeight;
const dx = x2 - x1;
const dy = y2 - y1;
totalLength += Math.sqrt(dx * dx + dy * dy);
}
return totalLength;
}
export const SparklineChart: React.FC<SparklineChartProps> = ({
data,
width = 100,
height = 30,
color = "#f7931a",
strokeWidth = 2,
delay = 0,
duration = 45,
useSpring: useSpringAnimation = true,
showFill = false,
fillOpacity = 0.2,
showGlow = true,
padding = 2,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Generate path data
const pathData = useMemo(
() => generatePath(data, width, height, padding),
[data, width, height, padding]
);
const fillPathData = useMemo(
() => (showFill ? generateFillPath(data, width, height, padding) : ""),
[data, width, height, padding, showFill]
);
// Calculate path length for stroke-dasharray animation
const pathLength = useMemo(
() => calculatePathLength(data, width, height, padding),
[data, width, height, padding]
);
// Calculate animation progress
const adjustedFrame = Math.max(0, frame - delay);
let progress: number;
if (useSpringAnimation) {
const springValue = spring({
frame: adjustedFrame,
fps,
config: {
damping: 20,
stiffness: 80,
mass: 1,
},
durationInFrames: duration,
});
progress = springValue;
} else {
progress = interpolate(adjustedFrame, [0, duration], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
easing: Easing.out(Easing.cubic),
});
}
// Calculate stroke-dashoffset for line draw animation
const dashOffset = interpolate(progress, [0, 1], [pathLength, 0]);
// Opacity animation for entrance
const opacity = interpolate(progress, [0, 0.2], [0, 1], {
extrapolateRight: "clamp",
});
// Fill opacity animation (slightly delayed)
const fillProgress = interpolate(progress, [0.3, 1], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
// Glow intensity
const glowIntensity = interpolate(progress, [0.5, 1], [0.3, 0.6], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
// Generate unique ID for gradient using React's useId for deterministic IDs
const reactId = useId();
const gradientId = `sparkline-gradient-${reactId.replace(/:/g, "")}`;
if (data.length === 0) {
return null;
}
return (
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
style={{ opacity }}
>
<defs>
{/* Gradient for fill area */}
<linearGradient id={gradientId} x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor={color} stopOpacity={fillOpacity} />
<stop offset="100%" stopColor={color} stopOpacity={0} />
</linearGradient>
{/* Glow filter */}
{showGlow && (
<filter id={`${gradientId}-glow`} x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur
stdDeviation={2 * glowIntensity}
result="coloredBlur"
/>
<feMerge>
<feMergeNode in="coloredBlur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
)}
</defs>
{/* Fill area (animated separately) */}
{showFill && fillPathData && (
<path
d={fillPathData}
fill={`url(#${gradientId})`}
style={{
opacity: fillProgress * fillOpacity,
}}
/>
)}
{/* Main line with draw animation */}
<path
d={pathData}
fill="none"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
strokeDasharray={pathLength}
strokeDashoffset={dashOffset}
filter={showGlow ? `url(#${gradientId}-glow)` : undefined}
/>
</svg>
);
};