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:
calesthio
2026-03-29 08:25:17 -07:00
commit a3e735cc7a
1147 changed files with 240221 additions and 0 deletions
@@ -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";