mirror of
https://github.com/HolgerHatGarKeineNode/einundzwanzig-nostr.git
synced 2026-01-28 07:43:18 +00:00
📈 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:
178
videos/src/components/SparklineChart.test.tsx
Normal file
178
videos/src/components/SparklineChart.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
276
videos/src/components/SparklineChart.tsx
Normal file
276
videos/src/components/SparklineChart.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user