Initial release — OpenMontage: the first open-source agentic video production system
11 production pipelines, 47 tools, 124 agent skills. Supports cloud APIs (fal.ai, OpenAI, ElevenLabs, Suno, HeyGen, Runway) and free local providers (diffusers, Piper TTS, WAN 2.1, Hunyuan, CogVideo). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
import {
|
||||
AbsoluteFill,
|
||||
Audio,
|
||||
Img,
|
||||
OffthreadVideo,
|
||||
Sequence,
|
||||
interpolate,
|
||||
spring,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
} from "remotion";
|
||||
import { TextCard } from "./components/TextCard";
|
||||
import { StatCard } from "./components/StatCard";
|
||||
import { CalloutBox } from "./components/CalloutBox";
|
||||
import { ComparisonCard } from "./components/ComparisonCard";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types — aligned with edit_decisions artifact schema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface Cut {
|
||||
id: string;
|
||||
source: string;
|
||||
in_seconds: number;
|
||||
out_seconds: number;
|
||||
layer?: string;
|
||||
type?: string;
|
||||
// Component-specific props
|
||||
text?: string;
|
||||
stat?: string;
|
||||
subtitle?: string;
|
||||
callout_type?: "info" | "warning" | "tip" | "quote";
|
||||
title?: string;
|
||||
// Comparison props
|
||||
leftLabel?: string;
|
||||
rightLabel?: string;
|
||||
leftValue?: string;
|
||||
rightValue?: string;
|
||||
// Animation & transitions
|
||||
animation?: string;
|
||||
transition_in?: string;
|
||||
transition_out?: string;
|
||||
transform?: {
|
||||
animation?: string;
|
||||
scale?: number;
|
||||
position?: { x: number; y: number };
|
||||
};
|
||||
}
|
||||
|
||||
interface AudioLayer {
|
||||
src: string;
|
||||
volume?: number;
|
||||
}
|
||||
|
||||
interface SfxCue {
|
||||
src: string;
|
||||
start_seconds: number;
|
||||
volume?: number;
|
||||
}
|
||||
|
||||
interface AudioConfig {
|
||||
narration?: AudioLayer;
|
||||
music?: AudioLayer;
|
||||
sfx?: SfxCue[];
|
||||
}
|
||||
|
||||
interface ExplainerProps {
|
||||
cuts: Cut[];
|
||||
subtitles?: { enabled: boolean; src?: string };
|
||||
audio?: AudioConfig;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Image scene — spring / interpolate animations (replaces FFmpeg Ken Burns)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".bmp", ".tiff", ".tif", ".webp"];
|
||||
const VIDEO_EXTENSIONS = [".mp4", ".mov", ".webm", ".avi", ".mkv"];
|
||||
|
||||
function isImage(source: string): boolean {
|
||||
const lower = source.toLowerCase();
|
||||
return IMAGE_EXTENSIONS.some((ext) => lower.endsWith(ext));
|
||||
}
|
||||
|
||||
function isVideo(source: string): boolean {
|
||||
const lower = source.toLowerCase();
|
||||
return VIDEO_EXTENSIONS.some((ext) => lower.endsWith(ext));
|
||||
}
|
||||
|
||||
const ImageScene: React.FC<{ src: string; animation?: string }> = ({
|
||||
src,
|
||||
animation,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps, durationInFrames } = useVideoConfig();
|
||||
|
||||
// Smooth fade-in on entrance
|
||||
const fadeIn = spring({ frame, fps, config: { damping: 20 } });
|
||||
|
||||
let scale = 1;
|
||||
let translateX = 0;
|
||||
let translateY = 0;
|
||||
const anim = animation || "zoom-in";
|
||||
|
||||
if (anim === "zoom-in") {
|
||||
scale = interpolate(frame, [0, durationInFrames], [1, 1.15], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
} else if (anim === "zoom-out") {
|
||||
scale = interpolate(frame, [0, durationInFrames], [1.15, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
} else if (anim === "pan-left") {
|
||||
translateX = interpolate(frame, [0, durationInFrames], [30, -30], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
scale = 1.15;
|
||||
} else if (anim === "pan-right") {
|
||||
translateX = interpolate(frame, [0, durationInFrames], [-30, 30], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
scale = 1.15;
|
||||
} else if (anim === "ken-burns") {
|
||||
scale = interpolate(frame, [0, durationInFrames], [1, 1.2], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
translateX = interpolate(frame, [0, durationInFrames], [0, -20], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
translateY = interpolate(frame, [0, durationInFrames], [0, -10], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
}
|
||||
// "static" or "none" → no motion, just fade in
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ overflow: "hidden", backgroundColor: "#000" }}>
|
||||
<Img
|
||||
src={src}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
opacity: fadeIn,
|
||||
transform: `scale(${scale}) translate(${translateX}px, ${translateY}px)`,
|
||||
}}
|
||||
/>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Video scene — OffthreadVideo for frame-accurate rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const VideoScene: React.FC<{ src: string; startFrom?: number }> = ({
|
||||
src,
|
||||
startFrom = 0,
|
||||
}) => {
|
||||
const { fps } = useVideoConfig();
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
<OffthreadVideo
|
||||
src={src}
|
||||
startFrom={Math.round(startFrom * fps)}
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
/>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fade transition wrapper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FadeIn: React.FC<{
|
||||
children: React.ReactNode;
|
||||
durationFrames?: number;
|
||||
}> = ({ children, durationFrames = 10 }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const opacity = interpolate(frame, [0, durationFrames], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
return <AbsoluteFill style={{ opacity }}>{children}</AbsoluteFill>;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scene renderer — maps cut type / source to the right component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SceneRenderer: React.FC<{ cut: Cut }> = ({ cut }) => {
|
||||
// Explicit component types
|
||||
if (cut.type === "text_card" && cut.text) {
|
||||
return <TextCard text={cut.text} />;
|
||||
}
|
||||
if (cut.type === "stat_card" && cut.stat) {
|
||||
return <StatCard stat={cut.stat} subtitle={cut.subtitle} />;
|
||||
}
|
||||
if (cut.type === "callout" && cut.text) {
|
||||
return (
|
||||
<CalloutBox text={cut.text} type={cut.callout_type} title={cut.title} />
|
||||
);
|
||||
}
|
||||
if (
|
||||
cut.type === "comparison" &&
|
||||
cut.leftLabel &&
|
||||
cut.rightLabel &&
|
||||
cut.leftValue &&
|
||||
cut.rightValue
|
||||
) {
|
||||
return (
|
||||
<ComparisonCard
|
||||
leftLabel={cut.leftLabel}
|
||||
rightLabel={cut.rightLabel}
|
||||
leftValue={cut.leftValue}
|
||||
rightValue={cut.rightValue}
|
||||
title={cut.title}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Auto-detect from source file extension
|
||||
const animation = cut.animation || cut.transform?.animation;
|
||||
|
||||
if (isImage(cut.source)) {
|
||||
return <ImageScene src={cut.source} animation={animation} />;
|
||||
}
|
||||
|
||||
if (isVideo(cut.source)) {
|
||||
return <VideoScene src={cut.source} startFrom={cut.in_seconds} />;
|
||||
}
|
||||
|
||||
// Fallback: treat as image
|
||||
return <ImageScene src={cut.source} animation={animation} />;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main composition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const Explainer: React.FC<ExplainerProps> = ({ cuts, audio }) => {
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor: "#000" }}>
|
||||
{/* Visual layers */}
|
||||
{cuts.map((cut) => {
|
||||
const from = Math.round(cut.in_seconds * fps);
|
||||
const duration = Math.round(
|
||||
(cut.out_seconds - cut.in_seconds) * fps
|
||||
);
|
||||
|
||||
const scene = <SceneRenderer cut={cut} />;
|
||||
const wrapped =
|
||||
cut.transition_in === "fade" ? (
|
||||
<FadeIn>{scene}</FadeIn>
|
||||
) : (
|
||||
scene
|
||||
);
|
||||
|
||||
return (
|
||||
<Sequence key={cut.id} from={from} durationInFrames={duration}>
|
||||
<AbsoluteFill>{wrapped}</AbsoluteFill>
|
||||
</Sequence>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Audio layers */}
|
||||
{audio?.narration?.src && (
|
||||
<Audio src={audio.narration.src} volume={audio.narration.volume ?? 1} />
|
||||
)}
|
||||
{audio?.music?.src && (
|
||||
<Audio
|
||||
src={audio.music.src}
|
||||
volume={audio.music.volume ?? 0.06}
|
||||
/>
|
||||
)}
|
||||
{audio?.sfx?.map((cue, i) => (
|
||||
<Sequence
|
||||
key={`sfx-${i}`}
|
||||
from={Math.round(cue.start_seconds * fps)}
|
||||
>
|
||||
<Audio src={cue.src} volume={cue.volume ?? 0.5} />
|
||||
</Sequence>
|
||||
))}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Composition } from "remotion";
|
||||
import { Explainer } from "./Explainer";
|
||||
|
||||
export const Root: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<Composition
|
||||
id="Explainer"
|
||||
component={Explainer}
|
||||
durationInFrames={30 * 60} // default 60s at 30fps, overridden by props
|
||||
fps={30}
|
||||
width={1920}
|
||||
height={1080}
|
||||
defaultProps={{
|
||||
cuts: [],
|
||||
subtitles: { enabled: false },
|
||||
audio: {},
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,213 @@
|
||||
import {
|
||||
AbsoluteFill,
|
||||
interpolate,
|
||||
spring,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
} from "remotion";
|
||||
|
||||
type CalloutType = "info" | "warning" | "tip" | "quote";
|
||||
|
||||
interface CalloutBoxProps {
|
||||
text: string;
|
||||
type?: CalloutType;
|
||||
icon?: string;
|
||||
title?: string;
|
||||
borderColor?: string;
|
||||
backgroundColor?: string;
|
||||
textColor?: string;
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
titleFontSize?: number;
|
||||
containerBackgroundColor?: string;
|
||||
}
|
||||
|
||||
const TYPE_DEFAULTS: Record<
|
||||
CalloutType,
|
||||
{ icon: string; border: string; bg: string }
|
||||
> = {
|
||||
info: { icon: "\u2139\uFE0F", border: "#2563EB", bg: "#EFF6FF" },
|
||||
warning: { icon: "\u26A0\uFE0F", border: "#F59E0B", bg: "#FFFBEB" },
|
||||
tip: { icon: "\uD83D\uDCA1", border: "#10B981", bg: "#ECFDF5" },
|
||||
quote: { icon: "\u201C", border: "#9CA3AF", bg: "#F9FAFB" },
|
||||
};
|
||||
|
||||
export const CalloutBox: React.FC<CalloutBoxProps> = ({
|
||||
text,
|
||||
type = "info",
|
||||
icon,
|
||||
title,
|
||||
borderColor,
|
||||
backgroundColor,
|
||||
textColor = "#1F2937",
|
||||
fontFamily = "Inter, system-ui, sans-serif",
|
||||
fontSize = 32,
|
||||
titleFontSize = 38,
|
||||
containerBackgroundColor = "#FFFFFF",
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const defaults = TYPE_DEFAULTS[type];
|
||||
const resolvedBorder = borderColor || defaults.border;
|
||||
const resolvedBg = backgroundColor || defaults.bg;
|
||||
const resolvedIcon = icon || defaults.icon;
|
||||
|
||||
// Slide-in from left with slight bounce
|
||||
const slideX = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: { damping: 13, stiffness: 90 },
|
||||
from: -80,
|
||||
to: 0,
|
||||
});
|
||||
|
||||
const opacity = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: { damping: 18 },
|
||||
});
|
||||
|
||||
// Scale bounce (subtle overshoot)
|
||||
const scale = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: { damping: 11, stiffness: 100 },
|
||||
from: 0.96,
|
||||
to: 1,
|
||||
});
|
||||
|
||||
// Icon entrance — slightly delayed
|
||||
const iconScale = spring({
|
||||
frame: frame - 5,
|
||||
fps,
|
||||
config: { damping: 10, stiffness: 120 },
|
||||
from: 0.5,
|
||||
to: 1,
|
||||
});
|
||||
const iconOpacity = spring({
|
||||
frame: frame - 5,
|
||||
fps,
|
||||
config: { damping: 20 },
|
||||
});
|
||||
|
||||
// Text fade in — staggered after box
|
||||
const textOpacity = spring({
|
||||
frame: frame - 8,
|
||||
fps,
|
||||
config: { damping: 20 },
|
||||
});
|
||||
|
||||
// Border accent draw (height grows top to bottom)
|
||||
const borderDraw = spring({
|
||||
frame: frame - 3,
|
||||
fps,
|
||||
config: { damping: 14, stiffness: 80 },
|
||||
});
|
||||
|
||||
// Quote type uses italic styling
|
||||
const isQuote = type === "quote";
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
backgroundColor: containerBackgroundColor,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
opacity,
|
||||
transform: `translateX(${slideX}px) scale(${scale})`,
|
||||
width: "72%",
|
||||
maxWidth: 1380,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{/* Main box */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
backgroundColor: resolvedBg,
|
||||
borderRadius: 12,
|
||||
padding: "40px 48px",
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
boxShadow: "0 2px 12px rgba(0,0,0,0.06)",
|
||||
}}
|
||||
>
|
||||
{/* Left border accent */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 6,
|
||||
height: `${borderDraw * 100}%`,
|
||||
backgroundColor: resolvedBorder,
|
||||
borderRadius: "12px 0 0 12px",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Icon */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: isQuote ? 72 : 48,
|
||||
lineHeight: 1,
|
||||
marginRight: 28,
|
||||
flexShrink: 0,
|
||||
opacity: iconOpacity,
|
||||
transform: `scale(${iconScale})`,
|
||||
color: isQuote ? resolvedBorder : undefined,
|
||||
fontFamily: isQuote ? "Georgia, serif" : undefined,
|
||||
fontWeight: isQuote ? 700 : undefined,
|
||||
marginTop: isQuote ? -12 : 0,
|
||||
}}
|
||||
>
|
||||
{resolvedIcon}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 12,
|
||||
flex: 1,
|
||||
opacity: textOpacity,
|
||||
}}
|
||||
>
|
||||
{title && (
|
||||
<div
|
||||
style={{
|
||||
fontFamily,
|
||||
fontWeight: 700,
|
||||
fontSize: titleFontSize,
|
||||
color: resolvedBorder,
|
||||
lineHeight: 1.3,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
fontFamily: isQuote ? "Georgia, serif" : fontFamily,
|
||||
fontWeight: isQuote ? 400 : 400,
|
||||
fontStyle: isQuote ? "italic" : "normal",
|
||||
fontSize,
|
||||
color: textColor,
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,360 @@
|
||||
import {
|
||||
AbsoluteFill,
|
||||
interpolate,
|
||||
spring,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
} from "remotion";
|
||||
|
||||
type ChangeDirection = "up" | "down" | "neutral";
|
||||
|
||||
interface ComparisonCardProps {
|
||||
leftLabel: string;
|
||||
rightLabel: string;
|
||||
leftValue: string;
|
||||
rightValue: string;
|
||||
leftColor?: string;
|
||||
rightColor?: string;
|
||||
title?: string;
|
||||
changeIndicator?: string;
|
||||
changeDirection?: ChangeDirection;
|
||||
backgroundColor?: string;
|
||||
cardBackgroundColor?: string;
|
||||
textColor?: string;
|
||||
fontFamily?: string;
|
||||
titleFontSize?: number;
|
||||
labelFontSize?: number;
|
||||
valueFontSize?: number;
|
||||
}
|
||||
|
||||
export const ComparisonCard: React.FC<ComparisonCardProps> = ({
|
||||
leftLabel,
|
||||
rightLabel,
|
||||
leftValue,
|
||||
rightValue,
|
||||
leftColor = "#2563EB",
|
||||
rightColor = "#10B981",
|
||||
title,
|
||||
changeIndicator,
|
||||
changeDirection = "neutral",
|
||||
backgroundColor = "#FFFFFF",
|
||||
cardBackgroundColor = "#F3F4F6",
|
||||
textColor = "#1F2937",
|
||||
fontFamily = "Inter, system-ui, sans-serif",
|
||||
titleFontSize = 44,
|
||||
labelFontSize = 28,
|
||||
valueFontSize = 72,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// Phase 1: Title + left side appears
|
||||
const titleOpacity = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: { damping: 20 },
|
||||
});
|
||||
|
||||
const leftOpacity = spring({
|
||||
frame: frame - 6,
|
||||
fps,
|
||||
config: { damping: 18 },
|
||||
});
|
||||
const leftSlide = spring({
|
||||
frame: frame - 6,
|
||||
fps,
|
||||
config: { damping: 14, stiffness: 90 },
|
||||
from: -40,
|
||||
to: 0,
|
||||
});
|
||||
const leftScale = spring({
|
||||
frame: frame - 6,
|
||||
fps,
|
||||
config: { damping: 12, stiffness: 100 },
|
||||
from: 0.9,
|
||||
to: 1,
|
||||
});
|
||||
|
||||
// Phase 2: Divider draws in (vertical line)
|
||||
const dividerDraw = spring({
|
||||
frame: frame - 16,
|
||||
fps,
|
||||
config: { damping: 14, stiffness: 80 },
|
||||
});
|
||||
|
||||
// Phase 3: Right side appears
|
||||
const rightOpacity = spring({
|
||||
frame: frame - 24,
|
||||
fps,
|
||||
config: { damping: 18 },
|
||||
});
|
||||
const rightSlide = spring({
|
||||
frame: frame - 24,
|
||||
fps,
|
||||
config: { damping: 14, stiffness: 90 },
|
||||
from: 40,
|
||||
to: 0,
|
||||
});
|
||||
const rightScale = spring({
|
||||
frame: frame - 24,
|
||||
fps,
|
||||
config: { damping: 12, stiffness: 100 },
|
||||
from: 0.9,
|
||||
to: 1,
|
||||
});
|
||||
|
||||
// Phase 4: Change indicator
|
||||
const indicatorOpacity = spring({
|
||||
frame: frame - 32,
|
||||
fps,
|
||||
config: { damping: 15 },
|
||||
});
|
||||
const indicatorScale = spring({
|
||||
frame: frame - 32,
|
||||
fps,
|
||||
config: { damping: 10, stiffness: 130 },
|
||||
from: 0.6,
|
||||
to: 1,
|
||||
});
|
||||
|
||||
// Arrow glyph based on direction
|
||||
const directionArrow =
|
||||
changeDirection === "up"
|
||||
? "\u2191"
|
||||
: changeDirection === "down"
|
||||
? "\u2193"
|
||||
: "\u2194";
|
||||
const directionColor =
|
||||
changeDirection === "up"
|
||||
? "#10B981"
|
||||
: changeDirection === "down"
|
||||
? "#EF4444"
|
||||
: "#9CA3AF";
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
backgroundColor,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
width: "80%",
|
||||
maxWidth: 1540,
|
||||
gap: 32,
|
||||
}}
|
||||
>
|
||||
{/* Title */}
|
||||
{title && (
|
||||
<div
|
||||
style={{
|
||||
fontFamily,
|
||||
fontWeight: 700,
|
||||
fontSize: titleFontSize,
|
||||
color: textColor,
|
||||
textAlign: "center",
|
||||
opacity: titleOpacity,
|
||||
letterSpacing: "-0.02em",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comparison container */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "stretch",
|
||||
width: "100%",
|
||||
borderRadius: 16,
|
||||
backgroundColor: cardBackgroundColor,
|
||||
overflow: "hidden",
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
|
||||
minHeight: 280,
|
||||
}}
|
||||
>
|
||||
{/* Left side */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: "48px 32px",
|
||||
opacity: leftOpacity,
|
||||
transform: `translateX(${leftSlide}px) scale(${leftScale})`,
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
{/* Left color accent bar */}
|
||||
<div
|
||||
style={{
|
||||
width: 48,
|
||||
height: 4,
|
||||
backgroundColor: leftColor,
|
||||
borderRadius: 2,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontFamily,
|
||||
fontWeight: 600,
|
||||
fontSize: labelFontSize,
|
||||
color: textColor,
|
||||
opacity: 0.7,
|
||||
textTransform: "uppercase" as const,
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
{leftLabel}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily,
|
||||
fontWeight: 800,
|
||||
fontSize: valueFontSize,
|
||||
color: leftColor,
|
||||
lineHeight: 1.1,
|
||||
}}
|
||||
>
|
||||
{leftValue}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center divider + change indicator */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 80,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{/* Vertical divider line */}
|
||||
<div
|
||||
style={{
|
||||
width: 2,
|
||||
height: `${dividerDraw * 100}%`,
|
||||
backgroundColor: "#D1D5DB",
|
||||
position: "absolute",
|
||||
top: `${((1 - dividerDraw) / 2) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Change indicator badge */}
|
||||
{changeIndicator && (
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
zIndex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
opacity: indicatorOpacity,
|
||||
transform: `scale(${indicatorScale})`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
backgroundColor,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
boxShadow: "0 1px 4px rgba(0,0,0,0.1)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily,
|
||||
fontWeight: 700,
|
||||
fontSize: 24,
|
||||
color: directionColor,
|
||||
}}
|
||||
>
|
||||
{directionArrow}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily,
|
||||
fontWeight: 700,
|
||||
fontSize: 18,
|
||||
color: directionColor,
|
||||
whiteSpace: "nowrap" as const,
|
||||
}}
|
||||
>
|
||||
{changeIndicator}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right side */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: "48px 32px",
|
||||
opacity: rightOpacity,
|
||||
transform: `translateX(${rightSlide}px) scale(${rightScale})`,
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
{/* Right color accent bar */}
|
||||
<div
|
||||
style={{
|
||||
width: 48,
|
||||
height: 4,
|
||||
backgroundColor: rightColor,
|
||||
borderRadius: 2,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontFamily,
|
||||
fontWeight: 600,
|
||||
fontSize: labelFontSize,
|
||||
color: textColor,
|
||||
opacity: 0.7,
|
||||
textTransform: "uppercase" as const,
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
{rightLabel}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily,
|
||||
fontWeight: 800,
|
||||
fontSize: valueFontSize,
|
||||
color: rightColor,
|
||||
lineHeight: 1.1,
|
||||
}}
|
||||
>
|
||||
{rightValue}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,284 @@
|
||||
import {
|
||||
AbsoluteFill,
|
||||
interpolate,
|
||||
spring,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
} from "remotion";
|
||||
|
||||
type ProgressAnimationStyle = "fill" | "pulse" | "step";
|
||||
|
||||
interface ProgressSegment {
|
||||
value: number;
|
||||
color?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
interface ProgressBarProps {
|
||||
progress: number;
|
||||
label?: string;
|
||||
color?: string;
|
||||
backgroundColor?: string;
|
||||
trackColor?: string;
|
||||
showPercentage?: boolean;
|
||||
animationStyle?: ProgressAnimationStyle;
|
||||
segments?: ProgressSegment[];
|
||||
height?: number;
|
||||
borderRadius?: number;
|
||||
fontFamily?: string;
|
||||
textColor?: string;
|
||||
labelFontSize?: number;
|
||||
percentageFontSize?: number;
|
||||
}
|
||||
|
||||
export const ProgressBar: React.FC<ProgressBarProps> = ({
|
||||
progress,
|
||||
label,
|
||||
color = "#2563EB",
|
||||
backgroundColor = "#FFFFFF",
|
||||
trackColor = "#E5E7EB",
|
||||
showPercentage = true,
|
||||
animationStyle = "fill",
|
||||
segments,
|
||||
height = 32,
|
||||
borderRadius = 8,
|
||||
fontFamily = "Inter, system-ui, sans-serif",
|
||||
textColor = "#1F2937",
|
||||
labelFontSize = 36,
|
||||
percentageFontSize = 28,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps, durationInFrames } = useVideoConfig();
|
||||
|
||||
const clampedProgress = Math.max(0, Math.min(100, progress));
|
||||
|
||||
// Container entrance animation
|
||||
const containerOpacity = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: { damping: 20 },
|
||||
});
|
||||
const containerScale = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: { damping: 15, stiffness: 100 },
|
||||
from: 0.95,
|
||||
to: 1,
|
||||
});
|
||||
|
||||
// Build fill width based on animation style
|
||||
let fillFraction: number;
|
||||
|
||||
if (animationStyle === "fill") {
|
||||
fillFraction = interpolate(
|
||||
frame,
|
||||
[10, Math.max(30, durationInFrames * 0.5)],
|
||||
[0, clampedProgress / 100],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
} else if (animationStyle === "pulse") {
|
||||
const base = interpolate(
|
||||
frame,
|
||||
[10, Math.max(30, durationInFrames * 0.4)],
|
||||
[0, clampedProgress / 100],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
// Subtle pulse once fill completes
|
||||
const pulsePhase = interpolate(
|
||||
frame,
|
||||
[durationInFrames * 0.4, durationInFrames * 0.9],
|
||||
[0, Math.PI * 4],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
const pulseScale =
|
||||
base >= clampedProgress / 100 - 0.01
|
||||
? 1 + Math.sin(pulsePhase) * 0.015
|
||||
: 1;
|
||||
fillFraction = base * pulseScale;
|
||||
} else {
|
||||
// step — discrete jumps
|
||||
const stepCount = segments ? segments.length : 5;
|
||||
const rawProgress = interpolate(
|
||||
frame,
|
||||
[10, Math.max(30, durationInFrames * 0.6)],
|
||||
[0, clampedProgress / 100],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
fillFraction =
|
||||
Math.floor(rawProgress * stepCount) / stepCount;
|
||||
}
|
||||
|
||||
// Percentage label spring (appears after fill starts)
|
||||
const percentOpacity = spring({
|
||||
frame: frame - 15,
|
||||
fps,
|
||||
config: { damping: 20 },
|
||||
});
|
||||
|
||||
// Rendered percentage tracks fill
|
||||
const displayedPercent = Math.round(fillFraction * 100);
|
||||
|
||||
// Segmented variant
|
||||
const isSegmented = segments && segments.length > 0;
|
||||
|
||||
// Bar track dimensions (centered, 70% canvas width)
|
||||
const trackWidth = 1344; // 70% of 1920
|
||||
const trackLeft = (1920 - trackWidth) / 2;
|
||||
const trackTop = label ? 520 : 500;
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
backgroundColor,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
opacity: containerOpacity,
|
||||
transform: `scale(${containerScale})`,
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 24,
|
||||
}}
|
||||
>
|
||||
{/* Label */}
|
||||
{label && (
|
||||
<div
|
||||
style={{
|
||||
fontFamily,
|
||||
fontWeight: 700,
|
||||
fontSize: labelFontSize,
|
||||
color: textColor,
|
||||
textAlign: "center",
|
||||
opacity: spring({
|
||||
frame,
|
||||
fps,
|
||||
config: { damping: 20 },
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Track */}
|
||||
<div
|
||||
style={{
|
||||
width: `${(trackWidth / 1920) * 100}%`,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height,
|
||||
backgroundColor: trackColor,
|
||||
borderRadius,
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
}}
|
||||
>
|
||||
{isSegmented ? (
|
||||
// Segmented bars
|
||||
segments!.map((seg, i) => {
|
||||
const segDelay = 10 + i * 8;
|
||||
const segProgress = spring({
|
||||
frame: frame - segDelay,
|
||||
fps,
|
||||
config: { damping: 14, stiffness: 80 },
|
||||
});
|
||||
const segWidth = (seg.value / 100) * 100;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
width: `${segWidth}%`,
|
||||
height: "100%",
|
||||
backgroundColor: seg.color || color,
|
||||
transform: `scaleX(${segProgress})`,
|
||||
transformOrigin: "left",
|
||||
borderRight:
|
||||
i < segments!.length - 1
|
||||
? `2px solid ${backgroundColor}`
|
||||
: "none",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
// Single fill bar
|
||||
<div
|
||||
style={{
|
||||
width: `${fillFraction * 100}%`,
|
||||
height: "100%",
|
||||
backgroundColor: color,
|
||||
borderRadius,
|
||||
transition:
|
||||
animationStyle === "step"
|
||||
? "width 0.15s ease"
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Segment labels below track */}
|
||||
{isSegmented && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
{segments!.map((seg, i) => {
|
||||
const segDelay = 10 + i * 8;
|
||||
const labelOp = spring({
|
||||
frame: frame - segDelay - 6,
|
||||
fps,
|
||||
config: { damping: 20 },
|
||||
});
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
width: `${(seg.value / 100) * 100}%`,
|
||||
fontFamily,
|
||||
fontWeight: 500,
|
||||
fontSize: 18,
|
||||
color: textColor,
|
||||
textAlign: "center",
|
||||
opacity: labelOp,
|
||||
}}
|
||||
>
|
||||
{seg.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Percentage */}
|
||||
{showPercentage && !isSegmented && (
|
||||
<div
|
||||
style={{
|
||||
fontFamily,
|
||||
fontWeight: 800,
|
||||
fontSize: percentageFontSize,
|
||||
color,
|
||||
opacity: percentOpacity,
|
||||
}}
|
||||
>
|
||||
{displayedPercent}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import { AbsoluteFill, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
|
||||
interface StatCardProps {
|
||||
stat: string;
|
||||
subtitle?: string;
|
||||
statFontSize?: number;
|
||||
subtitleFontSize?: number;
|
||||
color?: string;
|
||||
accentColor?: string;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
export const StatCard: React.FC<StatCardProps> = ({
|
||||
stat,
|
||||
subtitle,
|
||||
statFontSize = 128,
|
||||
subtitleFontSize = 36,
|
||||
color = "#FFFFFF",
|
||||
accentColor = "#F59E0B",
|
||||
backgroundColor = "#1F2937",
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const scale = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: { damping: 12, stiffness: 120 },
|
||||
from: 0.8,
|
||||
to: 1,
|
||||
});
|
||||
|
||||
const subtitleOpacity = spring({
|
||||
frame: frame - 8,
|
||||
fps,
|
||||
config: { damping: 20 },
|
||||
});
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor,
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<div
|
||||
style={{
|
||||
transform: `scale(${scale})`,
|
||||
fontSize: statFontSize,
|
||||
color: accentColor,
|
||||
fontFamily: "Inter, system-ui, sans-serif",
|
||||
fontWeight: 800,
|
||||
lineHeight: 1.1,
|
||||
}}
|
||||
>
|
||||
{stat}
|
||||
</div>
|
||||
{subtitle && (
|
||||
<div
|
||||
style={{
|
||||
opacity: subtitleOpacity,
|
||||
fontSize: subtitleFontSize,
|
||||
color,
|
||||
fontFamily: "Inter, system-ui, sans-serif",
|
||||
fontWeight: 400,
|
||||
marginTop: 16,
|
||||
}}
|
||||
>
|
||||
{subtitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { AbsoluteFill, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
|
||||
interface TextCardProps {
|
||||
text: string;
|
||||
fontSize?: number;
|
||||
color?: string;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
export const TextCard: React.FC<TextCardProps> = ({
|
||||
text,
|
||||
fontSize = 64,
|
||||
color = "#FFFFFF",
|
||||
backgroundColor = "#1F2937",
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const opacity = spring({ frame, fps, config: { damping: 20 } });
|
||||
const scale = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: { damping: 15, stiffness: 100 },
|
||||
from: 0.95,
|
||||
to: 1,
|
||||
});
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
opacity,
|
||||
transform: `scale(${scale})`,
|
||||
fontSize,
|
||||
color,
|
||||
fontFamily: "Inter, system-ui, sans-serif",
|
||||
fontWeight: 700,
|
||||
textAlign: "center",
|
||||
maxWidth: "80%",
|
||||
lineHeight: 1.3,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,284 @@
|
||||
import {
|
||||
AbsoluteFill,
|
||||
interpolate,
|
||||
spring,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
} from "remotion";
|
||||
|
||||
interface BarDatum {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
type BarAnimationStyle = "grow-up" | "slide-in" | "pop";
|
||||
|
||||
interface BarChartProps {
|
||||
data: BarDatum[];
|
||||
title?: string;
|
||||
colors?: string[];
|
||||
fontFamily?: string;
|
||||
textColor?: string;
|
||||
backgroundColor?: string;
|
||||
gridColor?: string;
|
||||
showGrid?: boolean;
|
||||
showValues?: boolean;
|
||||
animationStyle?: BarAnimationStyle;
|
||||
barGap?: number;
|
||||
}
|
||||
|
||||
export const BarChart: React.FC<BarChartProps> = ({
|
||||
data,
|
||||
title,
|
||||
colors = ["#2563EB", "#F59E0B", "#10B981", "#EC4899", "#06B6D4", "#8B5CF6"],
|
||||
fontFamily = "Inter, system-ui, sans-serif",
|
||||
textColor = "#1F2937",
|
||||
backgroundColor = "#FFFFFF",
|
||||
gridColor = "#E5E7EB",
|
||||
showGrid = true,
|
||||
showValues = true,
|
||||
animationStyle = "grow-up",
|
||||
barGap = 12,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps, durationInFrames } = useVideoConfig();
|
||||
|
||||
const maxValue = Math.max(...data.map((d) => d.value), 1);
|
||||
|
||||
// Chart layout constants (within 1920x1080 canvas)
|
||||
const chartLeft = 140;
|
||||
const chartRight = 1780;
|
||||
const chartTop = title ? 160 : 80;
|
||||
const chartBottom = 920;
|
||||
const chartWidth = chartRight - chartLeft;
|
||||
const chartHeight = chartBottom - chartTop;
|
||||
|
||||
const barCount = data.length;
|
||||
const totalGap = barGap * (barCount + 1);
|
||||
const barWidth = Math.min(
|
||||
(chartWidth - totalGap) / barCount,
|
||||
120
|
||||
);
|
||||
const actualTotalWidth = barCount * barWidth + (barCount + 1) * barGap;
|
||||
const offsetX = chartLeft + (chartWidth - actualTotalWidth) / 2;
|
||||
|
||||
// Grid lines
|
||||
const gridLineCount = 5;
|
||||
const gridLines = Array.from({ length: gridLineCount + 1 }, (_, i) => {
|
||||
const value = (maxValue / gridLineCount) * i;
|
||||
const y = chartBottom - (i / gridLineCount) * chartHeight;
|
||||
return { value, y };
|
||||
});
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
backgroundColor,
|
||||
justifyContent: "flex-start",
|
||||
alignItems: "center",
|
||||
padding: 40,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 1920 1080"
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
>
|
||||
{/* Title */}
|
||||
{title && (
|
||||
<text
|
||||
x={960}
|
||||
y={80}
|
||||
textAnchor="middle"
|
||||
fill={textColor}
|
||||
fontFamily={fontFamily}
|
||||
fontWeight={700}
|
||||
fontSize={48}
|
||||
opacity={spring({ frame, fps, config: { damping: 20 } })}
|
||||
>
|
||||
{title}
|
||||
</text>
|
||||
)}
|
||||
|
||||
{/* Grid lines */}
|
||||
{showGrid &&
|
||||
gridLines.map((line, i) => {
|
||||
const gridOpacity = interpolate(
|
||||
frame,
|
||||
[0, 10],
|
||||
[0, 0.6],
|
||||
{ extrapolateRight: "clamp" }
|
||||
);
|
||||
return (
|
||||
<g key={`grid-${i}`}>
|
||||
<line
|
||||
x1={chartLeft}
|
||||
y1={line.y}
|
||||
x2={chartRight}
|
||||
y2={line.y}
|
||||
stroke={gridColor}
|
||||
strokeWidth={1}
|
||||
opacity={gridOpacity}
|
||||
/>
|
||||
<text
|
||||
x={chartLeft - 12}
|
||||
y={line.y + 5}
|
||||
textAnchor="end"
|
||||
fill={textColor}
|
||||
fontFamily={fontFamily}
|
||||
fontWeight={400}
|
||||
fontSize={20}
|
||||
opacity={gridOpacity}
|
||||
>
|
||||
{formatNumber(line.value)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Axis lines */}
|
||||
<line
|
||||
x1={chartLeft}
|
||||
y1={chartTop}
|
||||
x2={chartLeft}
|
||||
y2={chartBottom}
|
||||
stroke={gridColor}
|
||||
strokeWidth={2}
|
||||
opacity={interpolate(frame, [0, 8], [0, 1], {
|
||||
extrapolateRight: "clamp",
|
||||
})}
|
||||
/>
|
||||
<line
|
||||
x1={chartLeft}
|
||||
y1={chartBottom}
|
||||
x2={chartRight}
|
||||
y2={chartBottom}
|
||||
stroke={gridColor}
|
||||
strokeWidth={2}
|
||||
opacity={interpolate(frame, [0, 8], [0, 1], {
|
||||
extrapolateRight: "clamp",
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* Bars */}
|
||||
{data.map((datum, i) => {
|
||||
const color = colors[i % colors.length];
|
||||
const barX = offsetX + barGap + i * (barWidth + barGap);
|
||||
const barHeightFull = (datum.value / maxValue) * chartHeight;
|
||||
const staggerDelay = i * 4;
|
||||
|
||||
let barProgress: number;
|
||||
let barOpacity: number;
|
||||
|
||||
if (animationStyle === "grow-up") {
|
||||
barProgress = spring({
|
||||
frame: frame - staggerDelay,
|
||||
fps,
|
||||
config: { damping: 14, stiffness: 80 },
|
||||
});
|
||||
barOpacity = interpolate(
|
||||
frame,
|
||||
[staggerDelay, staggerDelay + 6],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
} else if (animationStyle === "slide-in") {
|
||||
barProgress = interpolate(
|
||||
frame,
|
||||
[staggerDelay + 5, staggerDelay + 25],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
barOpacity = interpolate(
|
||||
frame,
|
||||
[staggerDelay + 5, staggerDelay + 12],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
} else {
|
||||
// pop
|
||||
const s = spring({
|
||||
frame: frame - staggerDelay,
|
||||
fps,
|
||||
config: { damping: 8, stiffness: 150, mass: 0.6 },
|
||||
});
|
||||
barProgress = s;
|
||||
barOpacity = interpolate(
|
||||
frame,
|
||||
[staggerDelay, staggerDelay + 3],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
}
|
||||
|
||||
const animatedHeight = barHeightFull * barProgress;
|
||||
const barY = chartBottom - animatedHeight;
|
||||
|
||||
// Fade out near end
|
||||
const fadeOut = interpolate(
|
||||
frame,
|
||||
[durationInFrames - 15, durationInFrames],
|
||||
[1, 0],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
return (
|
||||
<g key={datum.label} opacity={fadeOut}>
|
||||
{/* Bar */}
|
||||
<rect
|
||||
x={barX}
|
||||
y={barY}
|
||||
width={barWidth}
|
||||
height={Math.max(animatedHeight, 0)}
|
||||
fill={color}
|
||||
rx={4}
|
||||
opacity={barOpacity}
|
||||
/>
|
||||
|
||||
{/* Value label */}
|
||||
{showValues && (
|
||||
<text
|
||||
x={barX + barWidth / 2}
|
||||
y={barY - 12}
|
||||
textAnchor="middle"
|
||||
fill={textColor}
|
||||
fontFamily={fontFamily}
|
||||
fontWeight={600}
|
||||
fontSize={22}
|
||||
opacity={interpolate(
|
||||
barProgress,
|
||||
[0.7, 1],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
)}
|
||||
>
|
||||
{formatNumber(datum.value)}
|
||||
</text>
|
||||
)}
|
||||
|
||||
{/* Label */}
|
||||
<text
|
||||
x={barX + barWidth / 2}
|
||||
y={chartBottom + 40}
|
||||
textAnchor="middle"
|
||||
fill={textColor}
|
||||
fontFamily={fontFamily}
|
||||
fontWeight={500}
|
||||
fontSize={20}
|
||||
opacity={barOpacity}
|
||||
>
|
||||
{datum.label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
||||
if (Number.isInteger(n)) return String(n);
|
||||
return n.toFixed(1);
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
import {
|
||||
AbsoluteFill,
|
||||
interpolate,
|
||||
spring,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
} from "remotion";
|
||||
|
||||
interface Metric {
|
||||
label: string;
|
||||
value: number;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
change?: number; // percentage change, positive = up, negative = down
|
||||
icon?: string; // emoji or text glyph
|
||||
}
|
||||
|
||||
type KPIAnimationStyle = "count-up" | "pop" | "cascade";
|
||||
|
||||
interface KPIGridProps {
|
||||
metrics: Metric[];
|
||||
title?: string;
|
||||
columns?: 2 | 3 | 4;
|
||||
colors?: string[];
|
||||
fontFamily?: string;
|
||||
textColor?: string;
|
||||
backgroundColor?: string;
|
||||
cardBackgroundColor?: string;
|
||||
positiveColor?: string;
|
||||
negativeColor?: string;
|
||||
animationStyle?: KPIAnimationStyle;
|
||||
}
|
||||
|
||||
export const KPIGrid: React.FC<KPIGridProps> = ({
|
||||
metrics,
|
||||
title,
|
||||
columns = 3,
|
||||
colors = ["#2563EB", "#F59E0B", "#10B981", "#EC4899"],
|
||||
fontFamily = "Inter, system-ui, sans-serif",
|
||||
textColor = "#1F2937",
|
||||
backgroundColor = "#FFFFFF",
|
||||
cardBackgroundColor = "#F9FAFB",
|
||||
positiveColor = "#10B981",
|
||||
negativeColor = "#EF4444",
|
||||
animationStyle = "count-up",
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps, durationInFrames } = useVideoConfig();
|
||||
|
||||
const cols = Math.min(columns, metrics.length);
|
||||
const rows = Math.ceil(metrics.length / cols);
|
||||
|
||||
// Grid layout constants (within 1920x1080)
|
||||
const gridPadding = 100;
|
||||
const cardGap = 28;
|
||||
const titleHeight = title ? 120 : 0;
|
||||
const gridTop = 80 + titleHeight;
|
||||
const gridWidth = 1920 - gridPadding * 2;
|
||||
const gridHeight = 1080 - gridTop - 80;
|
||||
const cardWidth = (gridWidth - cardGap * (cols - 1)) / cols;
|
||||
const cardHeight = Math.min(
|
||||
(gridHeight - cardGap * (rows - 1)) / rows,
|
||||
320
|
||||
);
|
||||
|
||||
// Center grid vertically
|
||||
const totalGridHeight = rows * cardHeight + (rows - 1) * cardGap;
|
||||
const gridTopOffset = gridTop + (gridHeight - totalGridHeight) / 2;
|
||||
|
||||
// Fade out near end
|
||||
const fadeOut = interpolate(
|
||||
frame,
|
||||
[durationInFrames - 15, durationInFrames],
|
||||
[1, 0],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
backgroundColor,
|
||||
justifyContent: "flex-start",
|
||||
alignItems: "center",
|
||||
fontFamily,
|
||||
}}
|
||||
>
|
||||
{/* Title */}
|
||||
{title && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 60,
|
||||
left: 0,
|
||||
right: 0,
|
||||
textAlign: "center",
|
||||
fontSize: 48,
|
||||
fontWeight: 700,
|
||||
color: textColor,
|
||||
fontFamily,
|
||||
opacity:
|
||||
spring({ frame, fps, config: { damping: 20 } }) * fadeOut,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cards */}
|
||||
{metrics.map((metric, idx) => {
|
||||
const col = idx % cols;
|
||||
const row = Math.floor(idx / cols);
|
||||
const left = gridPadding + col * (cardWidth + cardGap);
|
||||
const top = gridTopOffset + row * (cardHeight + cardGap);
|
||||
const accentColor = colors[idx % colors.length];
|
||||
|
||||
const staggerDelay =
|
||||
animationStyle === "cascade" ? idx * 5 : 0;
|
||||
|
||||
// Card entrance
|
||||
let cardScale: number;
|
||||
let cardOpacity: number;
|
||||
|
||||
if (animationStyle === "pop") {
|
||||
cardScale = spring({
|
||||
frame: frame - idx * 4,
|
||||
fps,
|
||||
config: { damping: 10, stiffness: 150, mass: 0.5 },
|
||||
from: 0.7,
|
||||
to: 1,
|
||||
});
|
||||
cardOpacity = interpolate(
|
||||
frame,
|
||||
[idx * 4, idx * 4 + 5],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
} else if (animationStyle === "cascade") {
|
||||
cardScale = 1;
|
||||
const slideY = interpolate(
|
||||
frame,
|
||||
[staggerDelay, staggerDelay + 15],
|
||||
[30, 0],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
cardOpacity = interpolate(
|
||||
frame,
|
||||
[staggerDelay, staggerDelay + 12],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
// We'll use slideY via transform below
|
||||
return (
|
||||
<div
|
||||
key={metric.label}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left,
|
||||
top,
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
backgroundColor: cardBackgroundColor,
|
||||
borderRadius: 12,
|
||||
borderLeft: `4px solid ${accentColor}`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: 24,
|
||||
opacity: cardOpacity * fadeOut,
|
||||
transform: `translateY(${slideY}px)`,
|
||||
boxShadow: "0 2px 12px rgba(0,0,0,0.06)",
|
||||
}}
|
||||
>
|
||||
<KPICardContent
|
||||
metric={metric}
|
||||
accentColor={accentColor}
|
||||
textColor={textColor}
|
||||
fontFamily={fontFamily}
|
||||
positiveColor={positiveColor}
|
||||
negativeColor={negativeColor}
|
||||
frame={frame}
|
||||
fps={fps}
|
||||
staggerDelay={staggerDelay}
|
||||
animationStyle={animationStyle}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
// count-up — no special card animation
|
||||
cardScale = 1;
|
||||
cardOpacity = spring({
|
||||
frame: frame - idx * 3,
|
||||
fps,
|
||||
config: { damping: 20 },
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={metric.label}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left,
|
||||
top,
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
backgroundColor: cardBackgroundColor,
|
||||
borderRadius: 12,
|
||||
borderLeft: `4px solid ${accentColor}`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: 24,
|
||||
opacity: cardOpacity * fadeOut,
|
||||
transform: `scale(${cardScale})`,
|
||||
boxShadow: "0 2px 12px rgba(0,0,0,0.06)",
|
||||
}}
|
||||
>
|
||||
<KPICardContent
|
||||
metric={metric}
|
||||
accentColor={accentColor}
|
||||
textColor={textColor}
|
||||
fontFamily={fontFamily}
|
||||
positiveColor={positiveColor}
|
||||
negativeColor={negativeColor}
|
||||
frame={frame}
|
||||
fps={fps}
|
||||
staggerDelay={idx * 3}
|
||||
animationStyle={animationStyle}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
interface KPICardContentProps {
|
||||
metric: Metric;
|
||||
accentColor: string;
|
||||
textColor: string;
|
||||
fontFamily: string;
|
||||
positiveColor: string;
|
||||
negativeColor: string;
|
||||
frame: number;
|
||||
fps: number;
|
||||
staggerDelay: number;
|
||||
animationStyle: KPIAnimationStyle;
|
||||
}
|
||||
|
||||
const KPICardContent: React.FC<KPICardContentProps> = ({
|
||||
metric,
|
||||
accentColor,
|
||||
textColor,
|
||||
fontFamily,
|
||||
positiveColor,
|
||||
negativeColor,
|
||||
frame,
|
||||
fps,
|
||||
staggerDelay,
|
||||
animationStyle,
|
||||
}) => {
|
||||
// Count-up animation
|
||||
const countProgress =
|
||||
animationStyle === "count-up"
|
||||
? spring({
|
||||
frame: frame - staggerDelay - 5,
|
||||
fps,
|
||||
config: { damping: 22, stiffness: 40 },
|
||||
})
|
||||
: spring({
|
||||
frame: frame - staggerDelay - 3,
|
||||
fps,
|
||||
config: { damping: 18, stiffness: 60 },
|
||||
});
|
||||
|
||||
const displayValue = Math.round(metric.value * countProgress);
|
||||
const formattedValue = formatDisplayValue(displayValue, metric.value);
|
||||
|
||||
// Change indicator animation
|
||||
const changeOpacity = interpolate(
|
||||
frame,
|
||||
[staggerDelay + 18, staggerDelay + 25],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Icon */}
|
||||
{metric.icon && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 36,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{metric.icon}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Value */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 56,
|
||||
fontWeight: 800,
|
||||
color: accentColor,
|
||||
fontFamily,
|
||||
lineHeight: 1.1,
|
||||
}}
|
||||
>
|
||||
{metric.prefix || ""}
|
||||
{formattedValue}
|
||||
{metric.suffix || ""}
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 22,
|
||||
fontWeight: 500,
|
||||
color: textColor,
|
||||
fontFamily,
|
||||
marginTop: 8,
|
||||
opacity: 0.8,
|
||||
}}
|
||||
>
|
||||
{metric.label}
|
||||
</div>
|
||||
|
||||
{/* Change indicator */}
|
||||
{metric.change !== undefined && metric.change !== 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
marginTop: 10,
|
||||
fontSize: 20,
|
||||
fontWeight: 600,
|
||||
fontFamily,
|
||||
color: metric.change > 0 ? positiveColor : negativeColor,
|
||||
opacity: changeOpacity,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 18 }}>
|
||||
{metric.change > 0 ? "\u25B2" : "\u25BC"}
|
||||
</span>
|
||||
{Math.abs(metric.change).toFixed(1)}%
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Format the animated counter value to match the scale of the target value.
|
||||
*/
|
||||
function formatDisplayValue(current: number, target: number): string {
|
||||
if (target >= 1_000_000) {
|
||||
return `${(current / 1_000_000).toFixed(1)}M`;
|
||||
}
|
||||
if (target >= 1_000) {
|
||||
return `${(current / 1_000).toFixed(1)}K`;
|
||||
}
|
||||
return String(current);
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
import {
|
||||
AbsoluteFill,
|
||||
interpolate,
|
||||
spring,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
} from "remotion";
|
||||
|
||||
interface DataPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface Series {
|
||||
label: string;
|
||||
data: DataPoint[];
|
||||
color?: string;
|
||||
}
|
||||
|
||||
type LineAnimationStyle = "draw" | "fade-in";
|
||||
|
||||
interface LineChartProps {
|
||||
series: Series[];
|
||||
title?: string;
|
||||
colors?: string[];
|
||||
fontFamily?: string;
|
||||
textColor?: string;
|
||||
backgroundColor?: string;
|
||||
gridColor?: string;
|
||||
showGrid?: boolean;
|
||||
showMarkers?: boolean;
|
||||
showLegend?: boolean;
|
||||
xLabel?: string;
|
||||
yLabel?: string;
|
||||
animationStyle?: LineAnimationStyle;
|
||||
strokeWidth?: number;
|
||||
}
|
||||
|
||||
export const LineChart: React.FC<LineChartProps> = ({
|
||||
series,
|
||||
title,
|
||||
colors = ["#2563EB", "#F59E0B", "#10B981", "#EC4899", "#06B6D4", "#8B5CF6"],
|
||||
fontFamily = "Inter, system-ui, sans-serif",
|
||||
textColor = "#1F2937",
|
||||
backgroundColor = "#FFFFFF",
|
||||
gridColor = "#E5E7EB",
|
||||
showGrid = true,
|
||||
showMarkers = true,
|
||||
showLegend = true,
|
||||
xLabel,
|
||||
yLabel,
|
||||
animationStyle = "draw",
|
||||
strokeWidth = 3,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps, durationInFrames } = useVideoConfig();
|
||||
|
||||
// Chart layout
|
||||
const chartLeft = 160;
|
||||
const chartRight = 1760;
|
||||
const chartTop = title ? 160 : 100;
|
||||
const chartBottom = showLegend ? 880 : 940;
|
||||
const chartWidth = chartRight - chartLeft;
|
||||
const chartHeight = chartBottom - chartTop;
|
||||
|
||||
// Compute data bounds across all series
|
||||
const allPoints = series.flatMap((s) => s.data);
|
||||
const xMin = Math.min(...allPoints.map((p) => p.x));
|
||||
const xMax = Math.max(...allPoints.map((p) => p.x));
|
||||
const yMin = 0;
|
||||
const yMax = Math.max(...allPoints.map((p) => p.y)) * 1.1; // 10% headroom
|
||||
|
||||
const toSvgX = (x: number) =>
|
||||
chartLeft + ((x - xMin) / (xMax - xMin || 1)) * chartWidth;
|
||||
const toSvgY = (y: number) =>
|
||||
chartBottom - ((y - yMin) / (yMax - yMin || 1)) * chartHeight;
|
||||
|
||||
// Grid
|
||||
const gridLineCountY = 5;
|
||||
const gridLinesY = Array.from({ length: gridLineCountY + 1 }, (_, i) => {
|
||||
const value = (yMax / gridLineCountY) * i;
|
||||
const y = toSvgY(value);
|
||||
return { value, y };
|
||||
});
|
||||
|
||||
const gridLineCountX = Math.min(allPoints.length - 1, 6);
|
||||
const gridLinesX = Array.from({ length: gridLineCountX + 1 }, (_, i) => {
|
||||
const value = xMin + ((xMax - xMin) / gridLineCountX) * i;
|
||||
const x = toSvgX(value);
|
||||
return { value, x };
|
||||
});
|
||||
|
||||
// Fade out near end
|
||||
const fadeOut = interpolate(
|
||||
frame,
|
||||
[durationInFrames - 15, durationInFrames],
|
||||
[1, 0],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
backgroundColor,
|
||||
justifyContent: "flex-start",
|
||||
alignItems: "center",
|
||||
padding: 40,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 1920 1080"
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
>
|
||||
{/* Title */}
|
||||
{title && (
|
||||
<text
|
||||
x={960}
|
||||
y={80}
|
||||
textAnchor="middle"
|
||||
fill={textColor}
|
||||
fontFamily={fontFamily}
|
||||
fontWeight={700}
|
||||
fontSize={48}
|
||||
opacity={spring({ frame, fps, config: { damping: 20 } })}
|
||||
>
|
||||
{title}
|
||||
</text>
|
||||
)}
|
||||
|
||||
{/* Grid */}
|
||||
{showGrid && (
|
||||
<g
|
||||
opacity={interpolate(frame, [0, 10], [0, 0.5], {
|
||||
extrapolateRight: "clamp",
|
||||
})}
|
||||
>
|
||||
{/* Horizontal grid */}
|
||||
{gridLinesY.map((line, i) => (
|
||||
<g key={`gy-${i}`}>
|
||||
<line
|
||||
x1={chartLeft}
|
||||
y1={line.y}
|
||||
x2={chartRight}
|
||||
y2={line.y}
|
||||
stroke={gridColor}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<text
|
||||
x={chartLeft - 14}
|
||||
y={line.y + 6}
|
||||
textAnchor="end"
|
||||
fill={textColor}
|
||||
fontFamily={fontFamily}
|
||||
fontSize={18}
|
||||
fontWeight={400}
|
||||
>
|
||||
{formatNumber(line.value)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{/* Vertical grid */}
|
||||
{gridLinesX.map((line, i) => (
|
||||
<g key={`gx-${i}`}>
|
||||
<line
|
||||
x1={line.x}
|
||||
y1={chartTop}
|
||||
x2={line.x}
|
||||
y2={chartBottom}
|
||||
stroke={gridColor}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<text
|
||||
x={line.x}
|
||||
y={chartBottom + 36}
|
||||
textAnchor="middle"
|
||||
fill={textColor}
|
||||
fontFamily={fontFamily}
|
||||
fontSize={18}
|
||||
fontWeight={400}
|
||||
>
|
||||
{formatNumber(line.value)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* Axes */}
|
||||
<line
|
||||
x1={chartLeft}
|
||||
y1={chartTop}
|
||||
x2={chartLeft}
|
||||
y2={chartBottom}
|
||||
stroke={gridColor}
|
||||
strokeWidth={2}
|
||||
opacity={interpolate(frame, [0, 8], [0, 1], {
|
||||
extrapolateRight: "clamp",
|
||||
})}
|
||||
/>
|
||||
<line
|
||||
x1={chartLeft}
|
||||
y1={chartBottom}
|
||||
x2={chartRight}
|
||||
y2={chartBottom}
|
||||
stroke={gridColor}
|
||||
strokeWidth={2}
|
||||
opacity={interpolate(frame, [0, 8], [0, 1], {
|
||||
extrapolateRight: "clamp",
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* Axis labels */}
|
||||
{xLabel && (
|
||||
<text
|
||||
x={chartLeft + chartWidth / 2}
|
||||
y={chartBottom + 70}
|
||||
textAnchor="middle"
|
||||
fill={textColor}
|
||||
fontFamily={fontFamily}
|
||||
fontSize={22}
|
||||
fontWeight={500}
|
||||
opacity={interpolate(frame, [5, 15], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
})}
|
||||
>
|
||||
{xLabel}
|
||||
</text>
|
||||
)}
|
||||
{yLabel && (
|
||||
<text
|
||||
x={40}
|
||||
y={chartTop + chartHeight / 2}
|
||||
textAnchor="middle"
|
||||
fill={textColor}
|
||||
fontFamily={fontFamily}
|
||||
fontSize={22}
|
||||
fontWeight={500}
|
||||
transform={`rotate(-90, 40, ${chartTop + chartHeight / 2})`}
|
||||
opacity={interpolate(frame, [5, 15], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
})}
|
||||
>
|
||||
{yLabel}
|
||||
</text>
|
||||
)}
|
||||
|
||||
{/* Series lines */}
|
||||
{series.map((s, seriesIdx) => {
|
||||
const color = s.color || colors[seriesIdx % colors.length];
|
||||
const sorted = [...s.data].sort((a, b) => a.x - b.x);
|
||||
if (sorted.length < 2) return null;
|
||||
|
||||
const pathD = sorted
|
||||
.map((p, i) => {
|
||||
const sx = toSvgX(p.x);
|
||||
const sy = toSvgY(p.y);
|
||||
return i === 0 ? `M ${sx} ${sy}` : `L ${sx} ${sy}`;
|
||||
})
|
||||
.join(" ");
|
||||
|
||||
// Approximate path length for dash animation
|
||||
let pathLength = 0;
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const dx = toSvgX(sorted[i].x) - toSvgX(sorted[i - 1].x);
|
||||
const dy = toSvgY(sorted[i].y) - toSvgY(sorted[i - 1].y);
|
||||
pathLength += Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
const staggerDelay = seriesIdx * 8;
|
||||
|
||||
let drawProgress: number;
|
||||
let lineOpacity: number;
|
||||
|
||||
if (animationStyle === "draw") {
|
||||
drawProgress = spring({
|
||||
frame: frame - staggerDelay - 8,
|
||||
fps,
|
||||
config: { damping: 20, stiffness: 40 },
|
||||
});
|
||||
lineOpacity = interpolate(
|
||||
frame,
|
||||
[staggerDelay + 5, staggerDelay + 10],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
} else {
|
||||
// fade-in
|
||||
drawProgress = 1;
|
||||
lineOpacity = spring({
|
||||
frame: frame - staggerDelay - 5,
|
||||
fps,
|
||||
config: { damping: 20 },
|
||||
});
|
||||
}
|
||||
|
||||
const dashOffset = pathLength * (1 - drawProgress);
|
||||
|
||||
return (
|
||||
<g key={s.label} opacity={fadeOut}>
|
||||
{/* Line */}
|
||||
<path
|
||||
d={pathD}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeDasharray={pathLength}
|
||||
strokeDashoffset={dashOffset}
|
||||
opacity={lineOpacity}
|
||||
/>
|
||||
|
||||
{/* Markers */}
|
||||
{showMarkers &&
|
||||
sorted.map((p, pIdx) => {
|
||||
const markerProgress = interpolate(
|
||||
drawProgress,
|
||||
[pIdx / sorted.length, Math.min((pIdx + 1) / sorted.length, 1)],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
return (
|
||||
<circle
|
||||
key={`${s.label}-p-${pIdx}`}
|
||||
cx={toSvgX(p.x)}
|
||||
cy={toSvgY(p.y)}
|
||||
r={5}
|
||||
fill={backgroundColor}
|
||||
stroke={color}
|
||||
strokeWidth={2.5}
|
||||
opacity={markerProgress * lineOpacity}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Legend */}
|
||||
{showLegend && series.length > 1 && (
|
||||
<g
|
||||
opacity={interpolate(frame, [15, 25], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
})}
|
||||
>
|
||||
{series.map((s, i) => {
|
||||
const color = s.color || colors[i % colors.length];
|
||||
const legendX = 960 - (series.length * 160) / 2 + i * 160;
|
||||
return (
|
||||
<g key={`legend-${i}`}>
|
||||
<rect
|
||||
x={legendX}
|
||||
y={960}
|
||||
width={24}
|
||||
height={4}
|
||||
rx={2}
|
||||
fill={color}
|
||||
/>
|
||||
<text
|
||||
x={legendX + 32}
|
||||
y={966}
|
||||
fill={textColor}
|
||||
fontFamily={fontFamily}
|
||||
fontSize={20}
|
||||
fontWeight={500}
|
||||
>
|
||||
{s.label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
||||
if (Number.isInteger(n)) return String(n);
|
||||
return n.toFixed(1);
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import {
|
||||
AbsoluteFill,
|
||||
interpolate,
|
||||
spring,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
} from "remotion";
|
||||
|
||||
interface PieDatum {
|
||||
label: string;
|
||||
value: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
type PieAnimationStyle = "spin" | "expand" | "sequential";
|
||||
|
||||
interface PieChartProps {
|
||||
data: PieDatum[];
|
||||
title?: string;
|
||||
colors?: string[];
|
||||
fontFamily?: string;
|
||||
textColor?: string;
|
||||
backgroundColor?: string;
|
||||
donut?: boolean;
|
||||
centerLabel?: string;
|
||||
centerValue?: string;
|
||||
showLegend?: boolean;
|
||||
animationStyle?: PieAnimationStyle;
|
||||
}
|
||||
|
||||
export const PieChart: React.FC<PieChartProps> = ({
|
||||
data,
|
||||
title,
|
||||
colors = ["#2563EB", "#F59E0B", "#10B981", "#EC4899", "#06B6D4", "#8B5CF6"],
|
||||
fontFamily = "Inter, system-ui, sans-serif",
|
||||
textColor = "#1F2937",
|
||||
backgroundColor = "#FFFFFF",
|
||||
donut = false,
|
||||
centerLabel,
|
||||
centerValue,
|
||||
showLegend = true,
|
||||
animationStyle = "expand",
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps, durationInFrames } = useVideoConfig();
|
||||
|
||||
const total = data.reduce((sum, d) => sum + d.value, 0) || 1;
|
||||
|
||||
// Layout
|
||||
const cx = showLegend ? 760 : 960;
|
||||
const cy = title ? 540 : 500;
|
||||
const outerRadius = 300;
|
||||
const innerRadius = donut ? outerRadius * 0.55 : 0;
|
||||
|
||||
// Build slice angles
|
||||
const slices: {
|
||||
datum: PieDatum;
|
||||
color: string;
|
||||
startAngle: number;
|
||||
endAngle: number;
|
||||
percentage: number;
|
||||
}[] = [];
|
||||
let cumAngle = -Math.PI / 2; // start from top
|
||||
data.forEach((datum, i) => {
|
||||
const angle = (datum.value / total) * 2 * Math.PI;
|
||||
slices.push({
|
||||
datum,
|
||||
color: datum.color || colors[i % colors.length],
|
||||
startAngle: cumAngle,
|
||||
endAngle: cumAngle + angle,
|
||||
percentage: (datum.value / total) * 100,
|
||||
});
|
||||
cumAngle += angle;
|
||||
});
|
||||
|
||||
// Animation progress
|
||||
const globalProgress = spring({
|
||||
frame: frame - 5,
|
||||
fps,
|
||||
config: { damping: 18, stiffness: 50 },
|
||||
});
|
||||
|
||||
// Fade out near end
|
||||
const fadeOut = interpolate(
|
||||
frame,
|
||||
[durationInFrames - 15, durationInFrames],
|
||||
[1, 0],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
backgroundColor,
|
||||
justifyContent: "flex-start",
|
||||
alignItems: "center",
|
||||
padding: 40,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 1920 1080"
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
>
|
||||
{/* Title */}
|
||||
{title && (
|
||||
<text
|
||||
x={960}
|
||||
y={80}
|
||||
textAnchor="middle"
|
||||
fill={textColor}
|
||||
fontFamily={fontFamily}
|
||||
fontWeight={700}
|
||||
fontSize={48}
|
||||
opacity={spring({ frame, fps, config: { damping: 20 } })}
|
||||
>
|
||||
{title}
|
||||
</text>
|
||||
)}
|
||||
|
||||
{/* Pie/donut slices */}
|
||||
<g opacity={fadeOut}>
|
||||
{slices.map((slice, i) => {
|
||||
let sliceProgress: number;
|
||||
let sliceOpacity: number;
|
||||
|
||||
if (animationStyle === "spin") {
|
||||
// All slices animate together by sweeping the full circle
|
||||
sliceProgress = globalProgress;
|
||||
sliceOpacity = interpolate(
|
||||
frame,
|
||||
[3, 10],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
} else if (animationStyle === "expand") {
|
||||
// Radial expand from center
|
||||
sliceProgress = 1; // full angles immediately
|
||||
sliceOpacity = globalProgress;
|
||||
} else {
|
||||
// sequential — each slice appears one after another
|
||||
const staggerDelay = i * 6;
|
||||
sliceProgress = spring({
|
||||
frame: frame - staggerDelay - 5,
|
||||
fps,
|
||||
config: { damping: 16, stiffness: 60 },
|
||||
});
|
||||
sliceOpacity = interpolate(
|
||||
frame,
|
||||
[staggerDelay + 3, staggerDelay + 8],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
);
|
||||
}
|
||||
|
||||
// For "spin", we progressively reveal slices by clipping the end angle
|
||||
const totalSweep = slice.endAngle - (-Math.PI / 2);
|
||||
const maxSweep = 2 * Math.PI;
|
||||
let effectiveStartAngle = slice.startAngle;
|
||||
let effectiveEndAngle = slice.endAngle;
|
||||
|
||||
if (animationStyle === "spin") {
|
||||
const currentMaxAngle = -Math.PI / 2 + maxSweep * sliceProgress;
|
||||
if (slice.startAngle >= currentMaxAngle) {
|
||||
// slice not visible yet
|
||||
return null;
|
||||
}
|
||||
effectiveEndAngle = Math.min(slice.endAngle, currentMaxAngle);
|
||||
}
|
||||
|
||||
if (animationStyle === "sequential") {
|
||||
const sliceAngleSpan = slice.endAngle - slice.startAngle;
|
||||
effectiveEndAngle =
|
||||
slice.startAngle + sliceAngleSpan * sliceProgress;
|
||||
}
|
||||
|
||||
// For "expand", scale the radius
|
||||
const currentOuterRadius =
|
||||
animationStyle === "expand"
|
||||
? outerRadius * globalProgress
|
||||
: outerRadius;
|
||||
const currentInnerRadius =
|
||||
animationStyle === "expand"
|
||||
? innerRadius * globalProgress
|
||||
: innerRadius;
|
||||
|
||||
const path = describeArc(
|
||||
cx,
|
||||
cy,
|
||||
currentOuterRadius,
|
||||
currentInnerRadius,
|
||||
effectiveStartAngle,
|
||||
effectiveEndAngle
|
||||
);
|
||||
|
||||
return (
|
||||
<path
|
||||
key={slice.datum.label}
|
||||
d={path}
|
||||
fill={slice.color}
|
||||
opacity={sliceOpacity}
|
||||
stroke={backgroundColor}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Donut center */}
|
||||
{donut && (centerLabel || centerValue) && (
|
||||
<g
|
||||
opacity={interpolate(
|
||||
globalProgress,
|
||||
[0.5, 1],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" }
|
||||
)}
|
||||
>
|
||||
{centerValue && (
|
||||
<text
|
||||
x={cx}
|
||||
y={centerLabel ? cy - 10 : cy + 10}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill={textColor}
|
||||
fontFamily={fontFamily}
|
||||
fontWeight={800}
|
||||
fontSize={56}
|
||||
>
|
||||
{centerValue}
|
||||
</text>
|
||||
)}
|
||||
{centerLabel && (
|
||||
<text
|
||||
x={cx}
|
||||
y={centerValue ? cy + 36 : cy + 10}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill={textColor}
|
||||
fontFamily={fontFamily}
|
||||
fontWeight={400}
|
||||
fontSize={24}
|
||||
opacity={0.7}
|
||||
>
|
||||
{centerLabel}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
|
||||
{/* Legend */}
|
||||
{showLegend && (
|
||||
<g opacity={fadeOut}>
|
||||
{slices.map((slice, i) => {
|
||||
const legendY = cy - (slices.length * 44) / 2 + i * 44;
|
||||
const legendX = showLegend ? 1200 : cx + outerRadius + 80;
|
||||
const legendOpacity = spring({
|
||||
frame: frame - 15 - i * 3,
|
||||
fps,
|
||||
config: { damping: 20 },
|
||||
});
|
||||
return (
|
||||
<g key={`legend-${i}`} opacity={legendOpacity}>
|
||||
<rect
|
||||
x={legendX}
|
||||
y={legendY - 8}
|
||||
width={20}
|
||||
height={20}
|
||||
rx={4}
|
||||
fill={slice.color}
|
||||
/>
|
||||
<text
|
||||
x={legendX + 32}
|
||||
y={legendY + 6}
|
||||
fill={textColor}
|
||||
fontFamily={fontFamily}
|
||||
fontSize={22}
|
||||
fontWeight={500}
|
||||
>
|
||||
{slice.datum.label}
|
||||
</text>
|
||||
<text
|
||||
x={legendX + 32}
|
||||
y={legendY + 6}
|
||||
fill={textColor}
|
||||
fontFamily={fontFamily}
|
||||
fontSize={22}
|
||||
fontWeight={400}
|
||||
opacity={0.6}
|
||||
textAnchor="end"
|
||||
dx={280}
|
||||
>
|
||||
{slice.percentage.toFixed(1)}%
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Build an SVG arc path for a pie/donut slice.
|
||||
*/
|
||||
function describeArc(
|
||||
cx: number,
|
||||
cy: number,
|
||||
outerR: number,
|
||||
innerR: number,
|
||||
startAngle: number,
|
||||
endAngle: number
|
||||
): string {
|
||||
const outerStart = polarToCartesian(cx, cy, outerR, startAngle);
|
||||
const outerEnd = polarToCartesian(cx, cy, outerR, endAngle);
|
||||
const largeArc = endAngle - startAngle > Math.PI ? 1 : 0;
|
||||
|
||||
if (innerR <= 0) {
|
||||
// Full pie slice
|
||||
return [
|
||||
`M ${cx} ${cy}`,
|
||||
`L ${outerStart.x} ${outerStart.y}`,
|
||||
`A ${outerR} ${outerR} 0 ${largeArc} 1 ${outerEnd.x} ${outerEnd.y}`,
|
||||
"Z",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
// Donut slice
|
||||
const innerStart = polarToCartesian(cx, cy, innerR, startAngle);
|
||||
const innerEnd = polarToCartesian(cx, cy, innerR, endAngle);
|
||||
|
||||
return [
|
||||
`M ${outerStart.x} ${outerStart.y}`,
|
||||
`A ${outerR} ${outerR} 0 ${largeArc} 1 ${outerEnd.x} ${outerEnd.y}`,
|
||||
`L ${innerEnd.x} ${innerEnd.y}`,
|
||||
`A ${innerR} ${innerR} 0 ${largeArc} 0 ${innerStart.x} ${innerStart.y}`,
|
||||
"Z",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
function polarToCartesian(
|
||||
cx: number,
|
||||
cy: number,
|
||||
r: number,
|
||||
angle: number
|
||||
): { x: number; y: number } {
|
||||
return {
|
||||
x: cx + r * Math.cos(angle),
|
||||
y: cy + r * Math.sin(angle),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { BarChart } from "./BarChart";
|
||||
export { LineChart } from "./LineChart";
|
||||
export { PieChart } from "./PieChart";
|
||||
export { KPIGrid } from "./KPIGrid";
|
||||
@@ -0,0 +1,6 @@
|
||||
export { TextCard } from "./TextCard";
|
||||
export { StatCard } from "./StatCard";
|
||||
export { ProgressBar } from "./ProgressBar";
|
||||
export { CalloutBox } from "./CalloutBox";
|
||||
export { ComparisonCard } from "./ComparisonCard";
|
||||
export { BarChart, LineChart, PieChart, KPIGrid } from "./charts";
|
||||
@@ -0,0 +1,4 @@
|
||||
import { registerRoot } from "remotion";
|
||||
import { Root } from "./Root";
|
||||
|
||||
registerRoot(Root);
|
||||
Reference in New Issue
Block a user