From 6a8578494b07e22757228a945650645a07e3608e Mon Sep 17 00:00:00 2001 From: HolgerHatGarKeineNode Date: Sat, 24 Jan 2026 12:55:01 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=88=20Add=20SparklineChart=20component?= =?UTF-8?q?=20with=20animated=20SVG=20line=20drawing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- videos/src/components/SparklineChart.test.tsx | 178 +++++++++++ videos/src/components/SparklineChart.tsx | 276 ++++++++++++++++++ 2 files changed, 454 insertions(+) create mode 100644 videos/src/components/SparklineChart.test.tsx create mode 100644 videos/src/components/SparklineChart.tsx diff --git a/videos/src/components/SparklineChart.test.tsx b/videos/src/components/SparklineChart.test.tsx new file mode 100644 index 0000000..8ff77cd --- /dev/null +++ b/videos/src/components/SparklineChart.test.tsx @@ -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(); + expect(container).toBeInTheDocument(); + }); + + it("renders an SVG element", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + expect(svg).toBeInTheDocument(); + }); + + it("renders with default dimensions", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + expect(svg).toHaveAttribute("width", "100"); + expect(svg).toHaveAttribute("height", "30"); + }); + + it("renders with custom dimensions", () => { + const { container } = render( + + ); + 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(); + 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( + + ); + const path = container.querySelector('path[stroke="#00ff00"]'); + expect(path).toBeInTheDocument(); + }); + + it("applies custom stroke width", () => { + const { container } = render( + + ); + const path = container.querySelector('path[stroke-width="4"]'); + expect(path).toBeInTheDocument(); + }); + + it("returns null for empty data", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + expect(svg).toBeNull(); + }); + + it("handles single data point", () => { + const { container } = render(); + 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( + + ); + const linearGradient = container.querySelector("linearGradient"); + expect(linearGradient).toBeInTheDocument(); + }); + + it("renders glow filter when showGlow is true (default)", () => { + const { container } = render(); + const filter = container.querySelector("filter"); + expect(filter).toBeInTheDocument(); + }); + + it("does not render glow filter when showGlow is false", () => { + const { container } = render( + + ); + const filter = container.querySelector("filter"); + expect(filter).toBeNull(); + }); + + it("uses stroke-dasharray for animation", () => { + const { container } = render(); + 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(); + const path = container.querySelector('path[stroke="#f7931a"]'); + expect(path).toBeInTheDocument(); + }); + + it("renders with correct viewBox", () => { + const { container } = render( + + ); + const svg = container.querySelector("svg"); + expect(svg).toHaveAttribute("viewBox", "0 0 150 40"); + }); + + it("handles constant data (all same values)", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + expect(svg).toBeInTheDocument(); + const path = container.querySelector("path"); + expect(path).toBeInTheDocument(); + }); + + it("handles negative values", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + expect(svg).toBeInTheDocument(); + }); + + it("renders two paths when showFill is enabled", () => { + const { container } = render( + + ); + 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(); + const path = container.querySelector('path[stroke-linecap="round"]'); + expect(path).toBeInTheDocument(); + }); + + it("sets stroke-linejoin to round", () => { + const { container } = render(); + const path = container.querySelector('path[stroke-linejoin="round"]'); + expect(path).toBeInTheDocument(); + }); +}); diff --git a/videos/src/components/SparklineChart.tsx b/videos/src/components/SparklineChart.tsx new file mode 100644 index 0000000..37c8378 --- /dev/null +++ b/videos/src/components/SparklineChart.tsx @@ -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 = ({ + 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 ( + + + {/* Gradient for fill area */} + + + + + + {/* Glow filter */} + {showGlow && ( + + + + + + + + )} + + + {/* Fill area (animated separately) */} + {showFill && fillPathData && ( + + )} + + {/* Main line with draw animation */} + + + ); +};