KPI 達成率子彈圖
Stephen Few 風格子彈圖,5 個 KPI 指標各自展示最大範圍、滿意範圍、實際達成值與目標線,彩色實績條以彈簧動畫依序填入,一眼掌握業績達標狀況。
圖表商務簡約
提示詞(可直接修改內容)
import {
AbsoluteFill,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
} from "remotion";
import React from "react";
const METRICS = [
{ label: "營收", unit: "萬元", max: 1000, satisfactory: 700, target: 850, actual: 920, color: "#3b82f6" },
{ label: "用戶成長", unit: "%", max: 50, satisfactory: 30, target: 38, actual: 34, color: "#10b981" },
{ label: "客戶滿意", unit: "分", max: 100, satisfactory: 75, target: 85, actual: 88, color: "#8b5cf6" },
{ label: "轉換率", unit: "%", max: 15, satisfactory: 8, target: 11, actual: 9.5, color: "#f59e0b" },
{ label: "留存率", unit: "%", max: 100, satisfactory: 70, target: 80, actual: 76, color: "#ec4899" },
];
const LABEL-W = 200;
const VALUE-W = 180;
const BAR-W = 1300;
const ROW-H = 120;
const BAR-TRACK-H = 36;
const CHART-LEFT = 210;
const CHART-TOP = 200;
export const BulletChart: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const titleProgress = spring({
frame,
fps,
config: { damping: 30, stiffness: 70 },
});
const titleOpacity = interpolate(titleProgress, [0, 1], [0, 1]);
const titleY = interpolate(titleProgress, [0, 1], [-24, 0]);
const subtitleProgress = spring({
frame: Math.max(0, frame - 5),
fps,
config: { damping: 30, stiffness: 60 },
});
const subtitleOpacity = interpolate(subtitleProgress, [0, 1], [0, 1]);
return (
<AbsoluteFill
style={{
background: "#0f0f0f",
fontFamily: "sans-serif",
overflow: "hidden",
}}
>
{/* Title */}
<div
style={{
position: "absolute",
top: 60,
left: 0,
right: 0,
textAlign: "center",
opacity: titleOpacity,
transform: `translateY(${titleY}px)`,
}}
>
<div
style={{
fontSize: 52,
fontWeight: 700,
color: "#ffffff",
letterSpacing: "0.04em",
}}
>
KPI 達成率
</div>
</div>
{/* Subtitle / legend */}
<div
style={{
position: "absolute",
top: 132,
left: 0,
right: 0,
display: "flex",
justifyContent: "center",
gap: 40,
opacity: subtitleOpacity,
}}
>
{[
{ color: "#374151", label: "最大範圍" },
{ color: "#6b7280", label: "滿意範圍" },
{ color: "#ffffff", label: "目標線" },
].map((item) => (
<div
key={item.label}
style={{ display: "flex", alignItems: "center", gap: 10 }}
>
<div
style={{
width: item.label === "目標線" ? 3 : 20,
height: item.label === "目標線" ? 20 : 12,
background: item.color,
borderRadius: item.label === "目標線" ? 2 : 3,
}}
/>
<span style={{ fontSize: 18, color: "#9ca3af" }}>{item.label}</span>
</div>
))}
</div>
{/* Rows */}
{METRICS.map((metric, index) => {
const startFrame = index * 20 + 10;
const actualProgress = spring({
frame: Math.max(0, frame - startFrame),
fps,
config: { damping: 22, stiffness: 90 },
});
const rowProgress = spring({
frame: Math.max(0, frame - startFrame + 5),
fps,
config: { damping: 35, stiffness: 60 },
});
const rowOpacity = interpolate(rowProgress, [0, 1], [0, 1]);
const rowX = interpolate(rowProgress, [0, 1], [-40, 0]);
const actualBarW = interpolate(
actualProgress,
[0, 1],
[0, (metric.actual / metric.max) * BAR-W]
);
const satisfactoryW = (metric.satisfactory / metric.max) * BAR-W;
const targetX = (metric.target / metric.max) * BAR-W;
const displayActual = interpolate(
actualProgress,
[0, 1],
[0, metric.actual],
{ extrapolateRight: "clamp" }
);
const meetsTarget = metric.actual >= metric.target;
const rowTop = CHART-TOP + index * ROW-H;
const isEven = index % 2 === 0;
return (
<div
key={metric.label}
style={{
position: "absolute",
top: rowTop,
left: CHART-LEFT,
width: LABEL-W + BAR-W + VALUE-W + 40,
height: ROW-H,
opacity: rowOpacity,
transform: `translateX(${rowX}px)`,
}}
>
{/* Row background */}
<div
style={{
position: "absolute",
inset: 0,
background: isEven
? "rgba(255,255,255,0.02)"
: "transparent",
borderRadius: 4,
}}
/>
{/* Label */}
<div
style={{
position: "absolute",
left: 0,
top: 0,
width: LABEL-W,
height: ROW-H,
display: "flex",
alignItems: "center",
}}
>
<span
style={{
fontSize: 26,
fontWeight: 600,
color: "#e5e7eb",
letterSpacing: "0.02em",
}}
>
{metric.label}
</span>
</div>
{/* Bar track area */}
<div
style={{
position: "absolute",
left: LABEL-W,
top: (ROW-H - BAR-TRACK-H) / 2,
width: BAR-W,
height: BAR-TRACK-H,
}}
>
{/* Max range (background) */}
<div
style={{
position: "absolute",
inset: 0,
borderRadius: 4,
background: "#1f2937",
}}
/>
{/* Satisfactory range */}
<div
style={{
position: "absolute",
left: 0,
top: 0,
width: satisfactoryW,
height: "100%",
borderRadius: "4px 0 0 4px",
background: "#374151",
}}
/>
{/* Actual bar */}
<div
style={{
position: "absolute",
left: 0,
top: "50%",
transform: "translateY(-50%)",
width: actualBarW,
height: BAR-TRACK-H * 0.55,
borderRadius: 3,
background: `linear-gradient(90deg, ${metric.color}dd 0%, ${metric.color} 100%)`,
boxShadow: `0 0 16px ${metric.color}55`,
}}
/>
{/* Target line */}
<div
style={{
position: "absolute",
left: targetX - 2,
top: -4,
width: 3,
height: BAR-TRACK-H + 8,
background: "#ffffff",
borderRadius: 2,
boxShadow: "0 0 8px rgba(255,255,255,0.6)",
}}
/>
</div>
{/* Value label */}
<div
style={{
position: "absolute",
left: LABEL-W + BAR-W + 20,
top: 0,
width: VALUE-W,
height: ROW-H,
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "flex-start",
}}
>
<span
style={{
fontSize: 28,
fontWeight: 700,
color: meetsTarget ? metric.color : "#f87171",
letterSpacing: "0.02em",
}}
>
{metric.unit === "萬元"
? Math.round(displayActual)
: displayActual < 10
? displayActual.toFixed(1)
: Math.round(displayActual)}
</span>
<span
style={{
fontSize: 16,
color: "#6b7280",
marginTop: 2,
}}
>
{metric.unit}
{" "}
{meetsTarget ? "✓ 達標" : "✗ 未達標"}
</span>
</div>
</div>
);
})}
{/* Bottom axis labels */}
<div
style={{
position: "absolute",
top: CHART-TOP + METRICS.length * ROW-H + 8,
left: CHART-LEFT + LABEL-W,
width: BAR-W,
display: "flex",
justifyContent: "space-between",
opacity: interpolate(
spring({ frame: Math.max(0, frame - 15), fps, config: { damping: 35, stiffness: 60 } }),
[0, 1],
[0, 1]
),
}}
>
{[0, 25, 50, 75, 100].map((pct) => (
<div
key={pct}
style={{ fontSize: 16, color: "#4b5563", textAlign: "center" }}
>
{pct}%
</div>
))}
</div>
</AbsoluteFill>
);
};登入後查看完整程式碼