Remotion LabRemotion Lab
核心動畫圓餅圖:SVG arc + per-slice 動畫
data-vizpie-chartsvgarcintermediate

圓餅圖: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)的命令,給定半徑跟兩個端點,它會幫你畫出弧線。我們只需要:

  1. polarToCartesian:把角度轉成 x/y 座標
  2. buildArcPath:用 M + L + A + Z 組出一個扇形 path
  3. per-slice spring:每個扇形單獨動畫,依序展開

懂這三招,你不只可以做圓餅圖、還能做進度環、計時環、雷達圖、儀表盤。所有「跟角度有關」的視覺都是同一個基礎


前置知識


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) * 360

Step 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) = 1Math.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?因為我把 startend 在參數裡顛倒了(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"}],動畫的 currentEndinterpolate 從 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 行、效能差不多、視覺一樣。只有當你的甜甜圈洞需要透明(背景是漸層、或要疊在影片上)時才需要這麼做


本篇涵蓋的官方文件


下一步

T11:折線圖:stroke-dasharray 揭示 — 圓餅圖比的是「占比」、折線圖看的是「趨勢」。下一篇學一個經典的 SVG 動畫技巧——stroke-dasharray 讓線條從左邊「畫」到右邊,配上每個資料點的小圓點依序冒出來。