Remotion LabRemotion Lab
D3 資料視覺化系列D3.js × Remotion 系列(一):Bar Chart Race 排行榜競賽動畫
d3data-visualizationchartsadvanced

D3.js × Remotion 系列(一):Bar Chart Race 排行榜競賽動畫

用 D3 的 scaleLinear、scaleOrdinal、format 算 layout,用 Remotion 的 interpolate 把每一幀的數值與排名平滑播放出來——彭博風格的橫向長條競賽圖。

成品預覽

15 秒的示範影片裡,8 個國家從 1990 年一路競賽到 2025 年。每一幀你都會看到:右上角的「年份大數字」從 1990.0 平滑跑到 2025.0、長條的長度連續變化、彩色漸層的橫條按照當下排名上下移動位置——當中國的長條一格一格往上爬、超越日本、再超越德國的時候,那個視覺張力就是 bar chart race 之所以好看的原因。底部還有一條從左跑到右的進度條告訴你影片走到哪裡,下方一行小字標註資料來源與年份範圍。

整支影片沒有用任何 SVG、沒有任何 D3 transition,全部都是純 React <div> 加 inline style,由 D3 負責「算」、Remotion 負責「畫」。

為什麼要把 D3 放進 Remotion?

D3 是世界上最強的「把資料變成像素」的函式庫,這點沒什麼好爭的。從 New York Times 的互動圖表到 Observable 上幾萬個 notebook,背後都是 D3。

但如果你直接拿一份 D3 範例丟到 Remotion 裡,會發現一個尷尬的事實:D3 內建的動畫系統完全不適用

  • D3 的 transition() 是綁在 DOM 與 SVG element 上跑的,而 Remotion 雖然有 DOM,但它是 frame by frame 截圖的環境
  • D3 的時間軸來源是 requestAnimationFrame——也就是 wall clock,跟 Remotion 的 useCurrentFrame() 完全不同步
  • D3 的 selection API 會 mutate DOM,而 Remotion 偏好的是 React 的 declarative 模型

幸好,D3 不只是動畫——D3 的真正核心其實是一堆純函式:scale、layout、generator、format、sort 等等。這些東西都是 pure:給定相同輸入永遠回傳相同輸出,沒有副作用,不碰 DOM。

於是策略變得非常清楚:

只用 D3 的純數學部分計算每一幀的 layout,把動畫推進的責任交給 Remotion 的 useCurrentFrame()

這樣換來的好處:

  • Pixel-perfect render:同一個 frame 永遠產生同一張畫面,可以並行渲染
  • SSR-friendly:純函式不需要 DOM,跑在 Node.js worker 裡也沒問題
  • 沒有 DOM mutation:所有東西都走 React 的 declarative 流程,hot reload、time travel、跨幀偵錯全部行得通
  • D3 的所有 idiom 你都還能用:scale、format、sort、interpolate⋯⋯只是不再用 .transition()

這篇會用到

  • d3 v7(一份套件就涵蓋所有子模組)
  • d3.scaleLinear — 把數值映射到像素寬度
  • d3.scaleOrdinal + d3.schemeTableau10 — 把國家代碼映射到顏色
  • d3.format(',.0f') — 把數字加上千位逗號
  • d3.sort + d3.descending — 每一幀重新排序
  • Remotion 的 useCurrentFrameinterpolateSequenceAudio
  • React 的 useMemo 確保 scale 不會每幀重建

不需要 React-D3 hybrid 套件、不需要 react-faux-dom、不需要 ref。我們用的 render layer 就是普通 <div> 加 CSS,D3 只負責算

前置知識

建議先看過 T03:片頭動畫,對 useCurrentFrame()interpolate() 有基本理解。本篇的所有平滑動畫都建立在這兩個 API 上。

Step 1:安裝 D3

npm install d3 @types/d3

D3 v7 的整包大概 270KB(minified),但因為它的子模組支援 tree-shaking,這篇我們只用到 scale、format、sort 幾個,實際打包進去的可能不到 30KB。如果你的 bundle 大小很敏感,也可以只裝 d3-scaled3-formatd3-array 這幾個子套件——不過那樣 import 就要寫成 import {scaleLinear} from 'd3-scale',比較囉嗦。為了範例好讀,我們就直接用 import * as d3 from 'd3'

Step 2:準備 keyframe 資料集

Bar chart race 的核心資料是「N 個對象 × M 個時間點」的矩陣。我們用 8 個國家從 1990 到 2025 每 5 年一筆 GDP 數值(虛構,只為了示範):

const KEYFRAME_YEARS = [1990, 1995, 2000, 2005, 2010, 2015, 2020, 2025] as const;
 
type Country = {
  code: string; // 2-letter
  flag: string; // emoji
  name: string;
  values: number[]; // length = KEYFRAME_YEARS.length
};
 
const COUNTRIES: Country[] = [
  {
    code: 'US',
    flag: '🇺🇸',
    name: 'United States',
    values: [5963, 7640, 10250, 13036, 14992, 18238, 21060, 27360],
  },
  {
    code: 'CN',
    flag: '🇨🇳',
    name: 'China',
    values: [360, 734, 1211, 2286, 6087, 11226, 14688, 19370],
  },
  {
    code: 'JP',
    flag: '🇯🇵',
    name: 'Japan',
    values: [3133, 5449, 4888, 4831, 5759, 4444, 5040, 4210],
  },
  {
    code: 'DE',
    flag: '🇩🇪',
    name: 'Germany',
    values: [1764, 2591, 1942, 2843, 3399, 3357, 3889, 4456],
  },
  {
    code: 'IN',
    flag: '🇮🇳',
    name: 'India',
    values: [320, 366, 468, 820, 1675, 2103, 2667, 3737],
  },
  {
    code: 'GB',
    flag: '🇬🇧',
    name: 'United Kingdom',
    values: [1093, 1235, 1665, 2538, 2491, 2928, 2764, 3340],
  },
  {
    code: 'FR',
    flag: '🇫🇷',
    name: 'France',
    values: [1275, 1607, 1366, 2196, 2647, 2439, 2640, 3050],
  },
  {
    code: 'BR',
    flag: '🇧🇷',
    name: 'Brazil',
    values: [462, 786, 655, 892, 2209, 1802, 1476, 2173],
  },
];

這個資料結構有兩個值得注意的設計決定:

1. Keyframe 是稀疏的(每 5 年一筆)。 影片有 15 秒、30fps、共 450 幀,但我們只給 D3 看 8 個關鍵年份的數值。剩下的 442 幀全部由 Remotion 在這些 keyframe 之間做插值算出來——這正是 interpolate() 的拿手好戲。如果你硬要每幀都餵一份真實資料,你的 dataset 會變成 8 × 450 = 3600 筆,又大又難維護。

2. 用「程式碼」當主鍵而不是 index。 code: 'US' 是穩定的識別字,後面我們重新排序之後還是能用它去查 rank、查顏色。如果用 index,sort 一次之後 index 就亂了。

Step 3:D3 scales(比例尺)

D3 的核心 idiom 是 scale:給它一段「資料的範圍」(domain),給它一段「畫面的範圍」(range),它回傳一個函式,把任何 domain 裡的值映射成 range 裡的值。

我們需要兩個 scale:

import * as d3 from 'd3';
import {useMemo} from 'react';
 
// ...component 內部
 
// 顏色 scale:國家代碼 → 顏色
const colorScale = useMemo(
  () =>
    d3
      .scaleOrdinal<string>()
      .domain(COUNTRIES.map((c) => c.code))
      .range(d3.schemeTableau10 as readonly string[]),
  [],
);
 
// 寬度 scale:數值 → 像素寬度(依當下最大值動態調整)
const maxValue = ranked[0]?.value ?? 1;
const widthScale = useMemo(
  () =>
    d3
      .scaleLinear()
      .domain([0, maxValue])
      .range([0, CHART_WIDTH - 480]),
  [maxValue],
);

兩個 scale 的差異很重要:

  • colorScale 是 ordinal:domain 是離散的字串集合(國家代碼),range 也是離散的顏色陣列(d3.schemeTableau10 是 D3 內建的 10 色 categorical palette)。每個 code 永遠對應同一個顏色。所以它可以 useMemo([]) 一次建好不變。
  • widthScale 是 linear:domain 是連續數值 [0, maxValue],range 是連續像素 [0, 寬度]。重點:domain 的上限會隨著年份變化——1990 年最高的美國是 5963、2025 年是 27360,差了快 5 倍。如果不動態調整,1990 年的長條只會佔螢幕的 1/5。所以 widthScale 的 dependency 是 [maxValue],每幀重建。

這就是 scale 的精髓:它是「資料空間到螢幕空間」的橋樑,而且是純函式——widthScale(5963) 永遠回傳同一個像素值,沒有 DOM 副作用、沒有時間依賴。我們把這種純粹性丟進 Remotion 的 deterministic render,自然完美契合。

Step 4:把 frame 換算成「年份時間」

Remotion 給我們的是 frame number(0 到 449),但我們的資料軸是年份(1990 到 2025)。中間需要一個換算層:

const frameToYearTime = (frame: number): number => {
  const INTRO = 30;  // 開頭 1 秒留白
  const OUTRO = 30;  // 結尾 1 秒留白
  const totalRace =
    TUTORIAL_D3_BAR_CHART_RACE_DEMO_DURATION_FRAMES - INTRO - OUTRO;
  const racePos = Math.max(0, Math.min(totalRace, frame - INTRO));
  const t = racePos / totalRace; // 0..1
  const first = KEYFRAME_YEARS[0];
  const last = KEYFRAME_YEARS[KEYFRAME_YEARS.length - 1];
  return first + t * (last - first);
};

幾個關鍵點:

  • 回傳的是浮點數年份,例如 1995.72018.3,不是整數。這個小數點是 bar race 平滑感的來源——每一幀年份都往前走一點點(450 幀走完 35 年,每幀大約 +0.083 年),對應的數值也跟著走一點點。
  • 保留 intro / outro:開頭 1 秒和結尾 1 秒不參與 race,用來讓 title 進場、最終結果停留一拍。Math.max / Math.min 把 racePos clamp 在合理範圍。

接著我們要把這個浮點年份轉成「該年份的數值」。因為 keyframe 是每 5 年一筆,1995.7 介於 1995 和 2000 之間,要做插值:

const valueAtYear = (country: Country, year: number): number => {
  if (year <= KEYFRAME_YEARS[0]) return country.values[0];
  if (year >= KEYFRAME_YEARS[KEYFRAME_YEARS.length - 1]) {
    return country.values[country.values.length - 1];
  }
  for (let i = 0; i < KEYFRAME_YEARS.length - 1; i++) {
    const a = KEYFRAME_YEARS[i];
    const b = KEYFRAME_YEARS[i + 1];
    if (year >= a && year <= b) {
      const t = (year - a) / (b - a);
      // Smoothstep 讓 keyframe 之間的轉換更柔和
      const eased = t * t * (3 - 2 * t);
      return country.values[i] * (1 - eased) + country.values[i + 1] * eased;
    }
  }
  return country.values[0];
};

那個 t * t * (3 - 2 * t) 是經典的 smoothstep 函式——一條 S 型曲線,讓每個 keyframe 經過時的進場/出場速度比較柔,不會像 linear 一樣在 keyframe 點上有「折角」感。如果你不在意這個細節,純線性 t 也完全能跑。

寫到這裡你可能會問:「為什麼不直接用 Remotion 的 interpolate()?」答案是:可以,但 interpolate() 接受的是「frame → 一個輸出值」,這裡我們要算的是 8 個國家、每個都要插值。寫成函式比較好複用。內部其實做的事情跟 interpolate() 一模一樣。

Step 5:每幀重新排序

這是 bar chart race 之所以叫「race」的核心:每一幀都要重新排序,讓排名變化反映在垂直位置上。

const ranked = useMemo(() => {
  const arr = COUNTRIES.map((c) => ({
    country: c,
    value: valueAtYear(c, yearTime),
  }));
  // d3.sort 回傳新陣列(不會 mutate 原本)
  return d3.sort(arr, (a, b) => d3.descending(a.value, b.value));
}, [yearTime]);
 
// 建立 code → rank 的查詢表
const rankByCode = useMemo(() => {
  const m = new Map<string, number>();
  ranked.forEach((r, i) => m.set(r.country.code, i));
  return m;
}, [ranked]);

兩個 D3 小工具:

  • d3.sort vs JS 內建的 Array.prototype.sort:前者不會 mutate 原陣列,後者會。在 React 裡你絕對不想 mutate props 或 state,所以 d3.sort 是更安全的選擇。
  • d3.descending 是內建的比較器,相當於 (a, b) => b - a,但語意更清楚。

接著就是魔法時刻:每一個 bar 的垂直位置 (top) 直接由 rankByCode.get(code) 算出來。當某個國家的數值在某一幀超越另一個國家時,它的 rank 從 5 變成 4,下一幀的 top 就會少一個 ROW_HEIGHT。這就是長條互相超越的視覺效果

但如果只是 rank 突然從 5 跳到 4,畫面會「跳一格」。我們希望它平滑滑過去——這就是為什麼即使 rank 是離散的,bar 的渲染也要用「rank × ROW_HEIGHT」這種連續值,並且讓 frame 之間靠 React 重新 render 自動產生新位置。實際上因為相鄰兩幀的 yearTime 變化很小,相鄰兩幀如果 rank 沒變、bar 就不動;rank 變了的瞬間,下一幀直接跳到新位置——但因為 frame 間隔只有 1/30 秒,你看到的就是平滑的「位置切換」。

如果你想讓 rank 切換有真正的緩動(譬如 0.3 秒滑過去),可以額外維護一個「target rank」與「current rank」用 interpolate() 平滑。我們的範例為了簡單就直接跳,視覺上已經很流暢。

Step 6:畫橫條

每一個 bar 由三個部分組成(從左到右):

  1. 代碼徽章(96px 寬):國旗 emoji + 兩字母國家代碼,灰白漸層底
  2. 長條本身:彩色漸層,寬度由 widthScale(value) 決定
  3. 數值 + 國家名:放在長條外面(右側),永遠跟在 bar 末端

第三點是非常重要的設計決定。很多 bar chart race 把 label 放在 bar 內部(左對齊),這在 bar 很長的時候沒問題,但 bar 短的時候 label 會被裁切或溢出。把 label 放在 bar 外面則永遠看得到,不管 bar 多短。

{ranked.map(({ country, value }) => {
  const rank = rankByCode.get(country.code) ?? 0;
  const y = CHART_TOP + rank * ROW_HEIGHT + (ROW_HEIGHT - BAR_HEIGHT) / 2;
  const w = Math.max(2, widthScale(value));
  const color = colorScale(country.code);
 
  return (
    <div
      key={country.code}
      style={{
        position: 'absolute',
        left: CHART_LEFT,
        top: y,
        width: CHART_WIDTH,
        height: BAR_HEIGHT,
        display: 'flex',
        alignItems: 'center',
      }}
    >
      {/* 代碼徽章 */}
      <div style={{ width: 96, /* ... */ }}>
        <span>{country.flag}</span>
        {country.code}
      </div>
 
      {/* Bar + label container */}
      <div style={{ position: 'relative', height: BAR_HEIGHT, flex: 1 }}>
        {/* 長條本身 */}
        <div
          style={{
            position: 'absolute',
            left: 0,
            top: 0,
            height: BAR_HEIGHT,
            width: w,
            borderRadius: BAR_HEIGHT / 2,
            background: `linear-gradient(90deg, ${color} 0%, ${color}cc 70%, ${color}88 100%)`,
            boxShadow: `0 8px 28px ${color}55, inset 0 0 0 1px ${color}aa`,
          }}
        />
        {/* 數值 + 國家名(永遠在 bar 外面) */}
        <div
          style={{
            position: 'absolute',
            left: w + 16,
            top: 0,
            height: BAR_HEIGHT,
            display: 'flex',
            alignItems: 'center',
            gap: 14,
          }}
        >
          <span style={{ fontSize: 36, fontWeight: 800 }}>
            ${formatValue(value)}B
          </span>
          <span style={{ fontSize: 28, color: '#cbd5e1' }}>
            {country.name}
          </span>
        </div>
      </div>
    </div>
  );
})}

幾個小細節:

  • Math.max(2, widthScale(value)):強制 bar 至少有 2px 寬,避免某個國家數值極小的時候完全看不見
  • linear-gradient(90deg, ...):彩色漸層讓 bar 有立體感。${color}cc${color}88 是同一個顏色加上 alpha(CSS hex 8 位數寫法)
  • box-shadow 用同色光暈:每個 bar 自帶一圈柔光,讓視覺更彭博風
  • labelleft: w + 16:label 緊貼 bar 末端往右 16px。bar 變寬,label 跟著往右走;bar 變窄,label 跟著往左走

注意整段沒有任何 transition CSS——我們不能用 CSS transition,因為 Remotion 是 frame by frame 截圖,CSS transition 依賴瀏覽器 wall clock,跑不起來。所有「平滑感」都來自於 frame 之間的 React re-render + 我們在 Step 4 算出來的連續年份。

Step 7:年份大數字 + 進度條

右上角的大年份用一行 JSX 就好:

<div
  style={{
    position: 'absolute',
    top: 70,
    right: CHART_RIGHT,
    fontSize: 168,
    fontWeight: 900,
    fontVariantNumeric: 'tabular-nums',
    textAlign: 'right',
  }}
>
  {yearTime.toFixed(1)}
</div>

fontVariantNumeric: 'tabular-nums' 是一個常被忽略的 CSS 屬性——它讓所有數字字元都用同樣的字寬(monospace digits),這樣 1995.7 變成 1995.8 的時候,整串數字不會抖動。對於每幀都在變的數字 UI 來說,這個屬性是必須的。

底部進度條更簡單——frame / totalFrames 算出 0 到 1 的值,當作 div 的 width 百分比:

const progress = frame / (TUTORIAL_D3_BAR_CHART_RACE_DEMO_DURATION_FRAMES - 1);
 
<div style={{
  width: `${progress * 100}%`,
  height: '100%',
  background: 'linear-gradient(90deg, #38bdf8, #818cf8, #f472b6, #fb923c)',
}} />

這條 progress bar 既是裝飾,也是 debug 工具——如果某天你的 race 看起來不對勁,看 progress bar 跟年份大數字有沒有對齊就能立刻判斷是 frame 計算錯還是資料插值錯。

Step 8:音效

15 秒的影片用 5 軌音效層疊:

<Sequence from={0} durationInFrames={30}>
  <Audio src={staticFile('audio/t15/01-title-swipe.mp3')} volume={0.6} />
</Sequence>
<Sequence from={30} durationInFrames={420}>
  <Audio src={staticFile('audio/t14/bgm-relax-beat.mp3')} volume={0.25} />
</Sequence>
<Sequence from={60} durationInFrames={30}>
  <Audio src={staticFile('audio/t15/03-ka-ching.mp3')} volume={0.4} />
</Sequence>
{tickStarts.map((start) => (
  <Sequence key={`tick-${start}`} from={start} durationInFrames={20}>
    <Audio src={staticFile('audio/t14/03-tick.mp3')} volume={0.25} />
  </Sequence>
))}
<Sequence from={420} durationInFrames={30}>
  <Audio src={staticFile('audio/t15/07-outro-bell.mp3')} volume={0.5} />
</Sequence>

設計邏輯:

  • Title swipe(0-1s):開頭一聲「嗖」帶出標題
  • BGM bed(1-15s):低音量的輕鬆 beat 鋪整支影片,當作底層脊椎
  • Ka-ching(2-3s):race 正式開跑那一刻的「叮!」,提示觀眾「重點開始了」
  • Year ticks(4s, 6s, 8s, 10s, 12s, 14s):每兩秒一個 tick 聲,給人「秒針推進」的節奏感
  • Outro bell(14-15s):結尾的鈴聲收尾

關鍵原則:BGM 是脊椎,SFX 是節拍。BGM 音量壓低(0.25)讓它躲在背景,SFX 音量稍高(0.4-0.6)負責切換瞬間的注意力錨點。

可以擴充的方向

這個模式一旦理解,能擴展的地方非常多:

  • 讀真實 CSVd3.csvParse(csvString) 直接吃文字,回傳物件陣列。把 hardcode 的 COUNTRIES 換成讀檔即可
  • 換成其他資料 — 股票市值、人口、訂閱數、Spotify 月聽眾⋯⋯任何「時間序列 × 多個對象」的資料都能套用同一個模板
  • 加上資料來源 footer — 在底部加一行小字「Source: World Bank · Updated 2025-Q3」會讓影片更可信
  • 顏色隨數值大小變化 — 換成 d3.scaleSequential(d3.interpolateViridis),bar 的顏色就會跟著數值大小變化,而不是綁定國家
  • 串接 LLM 生成資料 — 用 GPT 生一份「2030 年熱門程式語言排行」的虛構資料,自動產出 daily 影片發到社群

小結

D3 提供算 layout 的腦,Remotion 提供精準播放的手——把這兩個分工弄清楚,你就能用最少的程式碼做出最豐富的資料動畫。整篇沒有任何 SVG、沒有任何 D3 transition,只有一堆純函式和 React 的 declarative render。

下一篇我們會用同一個原則做 Force-Directed Graph,看 D3 的物理模擬(d3.forceSimulation)怎麼跟 useMemo 結合,讓 nodes 在每一幀都站到正確的位置上——一樣是 D3 算、Remotion 播。