圓餅圖:SVG arc + per-slice 動畫
用 SVG 的 path arc 命令手刻一個會動的甜甜圈圖。學會 polarToCartesian 把角度換成座標、largeArc flag 處理大於 180 度的扇形、per-slice spring stagger 做出依序展開的效果。
成品預覽
6 秒、一個 4 分扇的甜甜圈圖:iOS 48%、Android 35%、Web 12%、Others 5%。每個扇形依序「轉出來」、右邊的圖例同時 fade in、中央的甜甜圈洞讓畫面看起來不像國小作業。整支動畫核心只有一個函式 buildArcPath + spring stagger。
這篇教什麼
圓餅圖是「比占比」的標準視覺。但很多人不知道怎麼用 SVG 手刻——大部分教學叫你裝 Recharts 或 D3。事實上 SVG 的 <path> 元素提供了一個叫 A(arc)的命令,給定半徑跟兩個端點,它會幫你畫出弧線。我們只需要:
polarToCartesian:把角度轉成 x/y 座標buildArcPath:用M+L+A+Z組出一個扇形 path- per-slice spring:每個扇形單獨動畫,依序展開
懂這三招,你不只可以做圓餅圖、還能做進度環、計時環、雷達圖、儀表盤。所有「跟角度有關」的視覺都是同一個基礎。
前置知識
- T3:YouTube 片頭動畫 — 熟悉 spring()
- T7:動態長條圖 — 上一篇講 stagger
- 國中數學的三角函數(sin/cos)會比較好懂,但不會也沒關係,本篇直接給公式
Step 1:資料結構
const PIE_DATA = [
{label: "iOS", value: 48, color: "#60a5fa"},
{label: "Android", value: 35, color: "#34d399"},
{label: "Web", value: 12, color: "#facc15"},
{label: "Others", value: 5, color: "#f87171"},
];value 是百分比(總和 = 100)。如果你的原始資料是絕對值(比如各國 GDP),先在元件裡 normalize 一下:
const total = PIE_DATA.reduce((s, d) => s + d.value, 0);
// 之後算角度時用 (d.value / total) * 360Step 2:polarToCartesian — 角度換座標
SVG 的座標系是「左上為原點,y 軸向下」,但人類想角度時是「12 點鐘方向為 0 度,順時針轉」。我們需要一個轉換函式:
const polarToCartesian = (
cx: number, // 圓心 x
cy: number, // 圓心 y
r: number, // 半徑
angleDeg: number, // 角度(0 = 12 點鐘方向)
) => {
const rad = ((angleDeg - 90) * Math.PI) / 180;
return {
x: cx + r * Math.cos(rad),
y: cy + r * Math.sin(rad),
};
};幾個關鍵點:
(angleDeg - 90):因為Math.cos(0) = 1、Math.sin(0) = 0,標準三角函數的 0 度是「3 點鐘方向」。減 90 度就是把它旋轉到「12 點鐘方向」* Math.PI / 180:JavaScript 三角函數吃 radian 不吃 degree,每次都要手動換算- 回傳
{x, y}:圓心 + 半徑 × 三角函數 = 圓周上的點
這個函式之後會被叫無數次。背起來。
Step 3:buildArcPath — 組扇形 path
SVG <path> 的 d 屬性語法看起來像鬼畫符,但其實只有幾個字母:
M x y— Move to(畫筆移動到,不畫線)L x y— Line to(從目前位置畫直線到)A rx ry x-axis-rotation large-arc-flag sweep-flag x y— Arc(畫一段弧線到)Z— Close path(畫直線回起點)
一個從圓心開始的扇形 path 是:「移到圓心 → 畫直線到弧線起點 → 沿弧線畫到弧線終點 → 畫直線回圓心」。
const buildArcPath = (
cx: number,
cy: number,
r: number,
startAngle: number,
endAngle: number,
) => {
const start = polarToCartesian(cx, cy, r, endAngle);
const end = polarToCartesian(cx, cy, r, startAngle);
const largeArc = endAngle - startAngle <= 180 ? 0 : 1;
return `M ${cx} ${cy} L ${start.x} ${start.y} A ${r} ${r} 0 ${largeArc} 0 ${end.x} ${end.y} Z`;
};兩個值得注意的點:
largeArc flag — SVG arc 有個惱人的特性:給定起點、終點、半徑,有兩條弧線都符合條件(一條短的、一條長的)。largeArc 告訴 SVG 走哪一條:0 = 短的(≤180°),1 = 長的(>180°)。如果你的扇形大於半圓但忘記設 largeArc=1,SVG 會畫出「另一邊」的小弧——畫面就會炸掉。
sweep-flag 永遠是 0 — 這個 flag 控制弧線的方向(順/逆時針)。為什麼這裡寫 0?因為我把 start 跟 end 在參數裡顛倒了(start = polarToCartesian(..., endAngle)),所以路徑是「從終點畫弧到起點」、配上 sweep=0(逆時針),最後得到的是順時針扇形。把這段背下來就好,不要試圖在腦袋裡推導 sweep-flag 的方向,會發瘋。
Step 4:Per-slice 動畫
import {AbsoluteFill, spring, useCurrentFrame, useVideoConfig} from 'remotion';
const PieChart: React.FC = () => {
const frame = useCurrentFrame();
const {fps} = useVideoConfig();
const cx = 540;
const cy = 540;
const r = 280;
// 預先算每個 slice 的起始角度
const slices = [];
let cumulative = 0;
for (const d of PIE_DATA) {
const angle = (d.value / 100) * 360;
slices.push({...d, startAngle: cumulative, fullEndAngle: cumulative + angle});
cumulative += angle;
}
return (
<AbsoluteFill style={{backgroundColor: "#0f172a"}}>
<svg width="1920" height="1080" viewBox="0 0 1920 1080">
<g transform="translate(420, 0)">
{slices.map((s, i) => {
const reveal = spring({
frame: frame - 20 - i * 12, // stagger
fps,
config: {damping: 18, stiffness: 80},
durationInFrames: 30,
});
// 每個 slice 從自己的 startAngle 「轉出來」到 fullEndAngle
const currentEnd = s.startAngle + (s.fullEndAngle - s.startAngle) * reveal;
const d = buildArcPath(cx, cy, r, s.startAngle, currentEnd);
return <path key={s.label} d={d} fill={s.color} />;
})}
{/* 中央的甜甜圈洞 */}
<circle cx={cx} cy={cy} r={140} fill="#0f172a" />
</g>
</svg>
</AbsoluteFill>
);
};關鍵邏輯就一行:
const currentEnd = s.startAngle + (s.fullEndAngle - s.startAngle) * reveal;reveal 從 0 跑到 1 的過程中,slice 的「結束角度」從 startAngle 慢慢延伸到 fullEndAngle——視覺上就是「扇形從中軸線轉出來」。每個 slice 的 frame - 20 - i * 12 就是 stagger:第 0 個從 frame 20 開始長、第 1 個從 frame 32 開始長…
中央的甜甜圈洞(donut hole) 用一個跟背景同色的 <circle> 直接蓋掉中心。比起算「環形扇形」的 path 簡單一百倍,效果完全一樣。設計圈管這招叫「用背景色當橡皮擦」——畫面工程的常用伎倆。
Step 5:圖例
右邊 fade in 一個圖例:
{slices.map((s, i) => {
const legendOpacity = spring({
frame: frame - 30 - i * 12,
fps,
config: {damping: 14, stiffness: 100},
durationInFrames: 20,
});
return (
<div key={s.label} style={{
display: "flex", alignItems: "center", gap: 16,
opacity: legendOpacity,
}}>
<div style={{width: 24, height: 24, backgroundColor: s.color, borderRadius: 4}} />
<span style={{fontSize: 36, color: "#fff", fontWeight: 600}}>
{s.label}
</span>
<span style={{
fontSize: 36, color: "#94a3b8", marginLeft: "auto",
fontVariantNumeric: "tabular-nums",
}}>
{s.value}%
</span>
</div>
);
})}圖例的 stagger 跟扇形對齊(同樣 i * 12 frames),所以每個扇形「轉出來」的同時,對應的圖例也淡入——畫面看起來像「資料正在被一筆一筆寫進儀表板」。fontVariantNumeric: "tabular-nums" 確保 5%/12%/35%/48% 的數字垂直對齊。
Step 6:渲染
npx remotion render AnimatedPieChart out/pie-chart.mp4 --codec=h264常見問題
Q:扇形邊緣有鋸齒?
SVG 預設會抗鋸齒,但有些瀏覽器/Remotion 渲染環境會關掉。在 <svg> 上加 shape-rendering="geometricPrecision" 通常能解決。或者把整個 SVG 裝在一個 <div> 裡,給那個 div 加 transform: translateZ(0) 強制 GPU 合成。
Q:扇形之間想要白色間隔(gap)?
把每個 slice 的 startAngle 加一點點(比如 +1 度)、fullEndAngle 減一點點:
const GAP = 1;
const d = buildArcPath(cx, cy, r, s.startAngle + GAP, currentEnd - GAP);每個扇形之間就會有 2 度的縫隙,露出底下的背景色。如果你想要白線而不是縫隙,加一個 stroke="#fff" strokeWidth="4" 就行。
Q:可以做進度環嗎(只有一個 slice 從 0 走到 100%)?
可以!把 PIE_DATA 簡化成 [{value: 100, color: "#60a5fa"}],動畫的 currentEnd 用 interpolate 從 0 走到 360。然後在中央放數字、外圈疊一個淺灰的「軌道」圓——這就是 Apple Watch Activity Ring 的做法。
Q:百分比加起來不是 100 怎麼辦?
Step 1 那段 total = reduce((s, d) => s + d.value, 0) 就是處理這個的。每個扇形的角度用 (d.value / total) * 360,總角度就一定是 360。
Q:想要用真正的環形(而不是圓 + 蓋洞)?
那需要兩條 arc + 兩條 line,path 會變成:「移到外圈起點 → 畫外弧 → 畫線到內圈 → 畫內弧(反向)→ 閉合」。對比 donut hole 法多寫 30 行、效能差不多、視覺一樣。只有當你的甜甜圈洞需要透明(背景是漸層、或要疊在影片上)時才需要這麼做。
本篇涵蓋的官方文件
- /docs/animation-math — interpolate / spring 與時間映射
- /docs/spring — spring 物理動畫
下一步
T11:折線圖:stroke-dasharray 揭示 — 圓餅圖比的是「占比」、折線圖看的是「趨勢」。下一篇學一個經典的 SVG 動畫技巧——stroke-dasharray 讓線條從左邊「畫」到右邊,配上每個資料點的小圓點依序冒出來。