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,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";
|
||||
Reference in New Issue
Block a user