Remotion LabRemotion Lab
D3 資料視覺化系列D3.js × Remotion 系列(四):Sankey 流量圖(用戶轉換漏斗)
d3data-visualizationsankeyadvanced

D3.js × Remotion 系列(四):Sankey 流量圖(用戶轉換漏斗)

用 d3-sankey 算節點佈局與流量寬度,用 stroke-dasharray reveal 把每條 ribbon 從 source 畫到 target——12 個節點 18 條 flow 的用戶轉換漏斗動畫。

成品預覽

13 秒、12 個節點、18 條 flow 的用戶轉換漏斗。畫面分成左中右三層:左邊是 5 個流量來源 Google / Twitter / YouTube / Reddit / Direct,中間是 3 個 landing pages Home / Pricing / Blog,右邊是 4 個 outcomes Signup / Trial / Bounce / Subscribe

進場後,flows 會「一組一組」被 reveal——先 Google 的三條,再 Twitter 的兩條,依序往下走,每組進來的時候 ribbon 會有一道短暫的 glow 閃光。中段 landing → outcome 的 ribbon 在最後一波收尾後才被畫出來,最後 30 frames 整個 sankey 微縮 + 變淡,中央浮現 23,450 visits → 1,847 subscribers 的大字結尾。整支影片把「資料的故事」濃縮成一幅圖,再把這幅圖拆成有節奏的時間序列。


為什麼 Sankey 這麼難手刻?

Sankey 看起來只是一些彎彎的彩色 ribbon,但它背後的 layout 演算法是個經典難題:

  • 欄位(column)佈局:每個 node 屬於哪一欄?要能從 link 的依賴關係推出來——source 在前、target 在後。如果有 3 層那比較簡單,但 5 層、7 層的時候就要拓樸排序(topological sort)。
  • 欄內 y 排序:同一欄裡誰在上、誰在下?目標是讓 ribbon 之間「交叉次數最少」。這是個組合最佳化問題,最佳解是 NP-hard,實務上用啟發式(heuristic)多次迭代收斂到近似解。
  • ribbon 寬度:每條 ribbon 的粗細要對應 flow value,而且兩端要分別塞進 source / target node 的「出口」與「入口」位置裡——還不能彼此重疊。
  • 曲線控制點:ribbon 是 cubic bezier,控制點要算得「兩端水平、中間 S-bend」。
  • node 高度自動分配:每個 node 的高度 = 進入它的總流量 = 從它流出的總流量(守恆律)。

這些東西全部寫一遍,大概要 600 行起跳。d3-sankey 套件已經把上面所有事情實作好了——你只要餵 nodes 跟 links,它一行回給你完整的座標。手刻派可以省下來去做更有價值的事情。


這篇會用到

  • d3-sankey — 官方 D3 子套件,提供 sankey() layout generator 與 sankeyLinkHorizontal() path generator
  • useMemo — sankey layout 算一次就好,整個 390 frames 都共用同一份結果
  • interpolate / useCurrentFrame — Remotion 的時間軸基礎
  • stroke-dasharray reveal trick — 跟 SVG 手寫簽名 同一招,這次用在 sankey ribbon 上

核心心法一句話:D3 算 layout(pure function,跟時間無關)+ Remotion 給時間(useCurrentFrame)+ 用 useMemo 暫存重計算——這就是 D3 × Remotion 整套系列共用的 pattern。


前置知識

  • SVG 手寫簽名:本篇的 ribbon reveal 完全沿用 stroke-dasharray 那一招,建議先看過原理解析那一節。
  • T32:D3 Bar Race:D3 × Remotion 的整合 pattern(buildLayout 用 useMemo 暫存)在本篇會再用一次。

如果你還不熟 interpolateSequence,建議先看 T3:YouTube 片頭動畫


Step 1:安裝 d3-sankey

npm install d3-sankey @types/d3-sankey

注意 D3 不是一個單一套件——它是 30+ 個子套件組成的家族(d3-arrayd3-scaled3-shaped3-forced3-geod3-sankey⋯⋯),你可以用 import * as d3 from "d3" 把全部一次拉進來,也可以「按需安裝」只裝用到的子套件。Sankey 比較特別,它沒有被包進 d3 主套件,必須單獨安裝 d3-sankey

@types/d3-sankey 是社群維護的 TypeScript 型別宣告,這個套件本身沒有附 .d.ts


Step 2:定義 nodes 與 links

Sankey 的資料結構非常單純,就兩個陣列:

type NodeDatum = {
  name: string;
  group: number;       // 之後拿來決定顏色
  layer: 0 | 1 | 2;    // 0=source, 1=landing, 2=outcome(自定義輔助欄位)
};
 
type LinkDatum = {
  source: number;      // node 在 NODE_DATA 裡的 index
  target: number;      // 同上
  value: number;       // 流量大小
  group: number;
};
 
const NODE_DATA: NodeDatum[] = [
  // sources (idx 0..4)
  { name: "Google",   group: 0, layer: 0 },
  { name: "Twitter",  group: 1, layer: 0 },
  { name: "YouTube",  group: 2, layer: 0 },
  { name: "Direct",   group: 3, layer: 0 },
  { name: "Reddit",   group: 4, layer: 0 },
  // landing pages (idx 5..7)
  { name: "Home",     group: 5, layer: 1 },
  { name: "Pricing",  group: 5, layer: 1 },
  { name: "Blog",     group: 5, layer: 1 },
  // outcomes (idx 8..11)
  { name: "Signup",    group: 6, layer: 2 },
  { name: "Trial",     group: 6, layer: 2 },
  { name: "Bounce",    group: 6, layer: 2 },
  { name: "Subscribe", group: 6, layer: 2 },
];
 
const LINK_DATA: LinkDatum[] = [
  // Google -> landing
  { source: 0, target: 5, value: 4200, group: 0 },
  { source: 0, target: 6, value: 2800, group: 0 },
  { source: 0, target: 7, value: 1500, group: 0 },
  // Twitter -> landing
  { source: 1, target: 5, value: 1800, group: 1 },
  { source: 1, target: 7, value: 2100, group: 1 },
  // ... (省略)
  // landing -> outcome
  { source: 5, target: 8,  value: 4200, group: 0 }, // Home -> Signup
  { source: 5, target: 10, value: 4500, group: 2 }, // Home -> Bounce
  // ...
];

幾個重點:

  • source / target 是 index,不是字串名稱。d3-sankey 也支援字串 id(傳 .nodeId(d => d.name) 給 sankey generator),但 index 寫法最直觀。
  • grouplayer 是我自己加的欄位d3-sankey 不關心,純粹給後面 reveal schedule 跟著色用。
  • d3-sankey 自己會推算 layer:你只要把 link 的依賴關係給對,它會用拓樸排序算出每個 node 應該屬於第幾欄。我這裡標 layer 是為了排程方便,跟它算出來的 depth 不一定一一對應。

Step 3:設定 sankey generator

import { sankey, sankeyLinkHorizontal, type SankeyGraph } from "d3-sankey";
 
const sankeyGen = sankey<NodeDatum, LinkDatum>()
  .nodeWidth(26)
  .nodePadding(20)
  .extent([
    [200, 180],
    [1720, 880],
  ]);

三個關鍵 setting:

  • nodeWidth(26):每個 node 矩形的寬度(px)。Sankey 的 node 通常畫成細長的直條,寬度只是視覺,跟流量大小無關。
  • nodePadding(20):同一欄裡相鄰 node 之間的垂直間距。間距越大,留給 ribbon 走的空間就越擠,你需要在「node 視覺清楚」跟「ribbon 不擠」之間取平衡。
  • extent([[x0, y0], [x1, y1]]):sankey 要塞進的矩形範圍。d3-sankey 算出來的所有座標都會落在這個 box 裡。範例的 [[200, 180], [1720, 880]] 留了上方 180px 給標題、左右各 200px 給 node label。

<NodeDatum, LinkDatum> 是 TypeScript 的 generic 參數,告訴 d3-sankey 你的 node / link 物件長什麼樣,它幫你保留你的自訂欄位。


Step 4:呼叫 sankeyGen()

const graph: SankeyGraph<NodeDatum, LinkDatum> = {
  nodes: NODE_DATA.map((d) => ({ ...d })), // ⚠️ 必須 deep copy
  links: LINK_DATA.map((d) => ({ ...d })),
};
 
const result = sankeyGen(graph);
// result.nodes: 每個 node 多了 x0/x1/y0/y1/value/depth/index
// result.links: 每個 link 多了 width / y0 / y1(兩端 y 座標)/ source 跟 target 被替換成 node 物件

⚠️ 為什麼一定要 deep copy?

sankeyGen()mutate input——它直接在你傳進去的 node / link 物件上加屬性、改 source/target 引用。如果你直接傳 NODE_DATA 進去,你的常數會被永久污染,下次再算(例如熱重載觸發 re-render)就會出亂子。

正確做法是 .map(d => ({ ...d })) 做一層 shallow copy——對於這種 flat object 已經夠用,因為 d3-sankey 只會在 top level 加屬性。

呼叫完之後,你會拿到:

  • 每個 node:被加上 x0 / x1 / y0 / y1(矩形四個邊的座標)、value(總流量,等於進來的流量總和或出去的總和)、depth(在第幾欄)、index(在 nodes 陣列裡的位置)。
  • 每個 linksourcetarget 從 index 被換成 node 物件參考(這點很重要,下面會用到);多了 width(ribbon 粗細,px)、y0 / y1(兩端的 y 中心點座標)。

Step 5:用 sankeyLinkHorizontal 拿 SVG path

要把 link 畫成 ribbon,你不需要自己寫 cubic bezier。d3-sankey 提供一個現成的 path generator:

const linkGen = sankeyLinkHorizontal<NodeDatum, LinkDatum>();
 
result.links.forEach((link) => {
  const d: string = linkGen(link) ?? "";
  // d 是一條完整的 SVG path string,例如:
  //   "M226,300C863,300,1063,540,1700,540"
  // 直接餵給 <path d={d} /> 就好
});

這個 d 字串是一條 cubic bezier curve,從 source node 的右邊出發,畫一個 S-bend 到 target node 的左邊。它已經把 ribbon 的中心線算好了——你只要再用 strokeWidth={link.width} 把它畫粗,就成了一條有粗細的 ribbon。

注意 sankey 的 ribbon 不是 fill 出來的,是 stroke 出來的:你畫一條中心線(path),再用 stroke-width 把它撐開到對應的流量寬度。這個設計剛好讓我們可以用 stroke-dasharray 做 reveal——下一步就是這個。


Step 6:stroke-dasharray reveal

如果你看過 SVG 手寫簽名,這一招就完全一樣。再講一次原理:

  • SVG 的 stroke-dasharray 定義「實線段-空白段」的循環。把它設成 [totalLength, totalLength]——「實線等於整條 path」、「空白等於整條 path」。
  • stroke-dashoffset 控制 dash 從哪裡開始。設成 totalLength,整條實線就被「推出去」path 範圍外,畫面什麼都看不到。
  • stroke-dashoffsettotalLength 慢慢降到 0,實線段會「滑進來」,從 source 端一吋一吋顯現到 target 端。
  • 視覺效果:ribbon 從左邊的 node「流」到右邊的 node。

但這裡有個小麻煩:要知道 totalLength,你需要呼叫 SVG <path>getTotalLength()——這個方法在 SSR / Lambda render 環境下不一定有。範例的做法是用兩端點的距離 + 一個 fudge factor 估算:

const sx = link.source.x1; // source node 的右邊
const tx = link.target.x0; // target node 的左邊
const sy = link.y0;
const ty = link.y1;
const straight = Math.hypot(tx - sx, ty - sy);
const pathLen = straight * 1.25 + 60; // 1.25x 補上 S-bend 的長度

這個估算對 stroke-dasharray reveal 來說已經夠用——只要 dasharray 跟 dashoffset 用「同一個」估算值,視覺上就會從 0% 跑到 100%,觀眾根本看不出來這不是真正的 path 長度。

為什麼 sankey 要這樣做? 因為 static 直接把整張 sankey 顯示出來沒戲感——12 個節點 18 條 ribbon 一次塞給觀眾,眼睛根本看不過來。逐條 reveal 才有「流量被引導出來」的敘事節奏:觀眾的眼睛會跟著畫筆從 source 走到 target,自然而然就讀懂了「這條流量是從哪流到哪」。資料視覺化的動畫不只是好看,是幫觀眾建立閱讀順序


Step 7:分組 reveal 排程

一條一條 reveal 太慢,全部一起 reveal 又混亂。範例的折衷做法是「按 source group」分組——同一個 source 出去的所有 ribbon 一起進場,每組之間錯開 30 frames:

// 5 個 source group + 1 個 landing → outcome 收尾 wave
const GROUP_REVEAL_FRAMES = [60, 90, 120, 150, 180, 220];
const GROUP_REVEAL_DURATION = 45; // 每組 45 frames 完成
 
const groupStartFrame = (group: number, isLandingWave: boolean) => {
  if (isLandingWave) return GROUP_REVEAL_FRAMES[5];
  return GROUP_REVEAL_FRAMES[group] ?? GROUP_REVEAL_FRAMES[0];
};
 
// 在 render 階段:
const linkRenders = layout.links.map((link) => {
  const isLandingWave = link.source.layer === 1;
  const start = groupStartFrame(link.group, isLandingWave);
  const tRaw = (frame - start) / GROUP_REVEAL_DURATION;
  const t = Math.max(0, Math.min(1, tRaw));
  const revealT = easeOutCubic(t); // 0..1
  const dashOffset = link.pathLen * (1 - revealT);
  // ...
});

幾個細節:

  • easeOutCubic 比純線性看起來自然——前面快、後面慢,就像水流到 target 之後「停穩」。
  • landing → outcome 的 ribbon 留到最後一波(frame 220)才全部一起進場,當作收尾的高潮。
  • 每組 reveal 中段加一個短暫的 glow highlight pulse
    const highlight = Math.max(
      0,
      1 - Math.abs(frame - (start + GROUP_REVEAL_DURATION * 0.5)) /
          (GROUP_REVEAL_DURATION * 0.7),
    );
    在 reveal 進度約 60% 的位置 highlight 達到最大,前後線性衰減,視覺上是一道光從畫筆掃過。配合 SVG <filter id="link-glow">feGaussianBlur 做出模糊光暈。

Step 8:節點 fade in 與 value 跳數

Node 不能比它的 link 早出現——不然觀眾會看到一個空節點呆在那。範例的做法是讓每個 node 跟著「第一條 touching 它的 link」一起進場

const nodeOpacities = layout.nodes.map((n) => {
  // 找出所有碰到這個 node 的 link 中,最早的 reveal start frame
  let earliest = Infinity;
  for (const lr of linkRenders) {
    if (lr.link.source.index === n.index || lr.link.target.index === n.index) {
      const isLandingWave = lr.link.source.layer === 1;
      const s = groupStartFrame(lr.link.group, isLandingWave);
      if (s < earliest) earliest = s;
    }
  }
  if (!Number.isFinite(earliest)) return 0;
  return interpolate(frame, [earliest - 5, earliest + 15], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });
});

earliest - 5 讓 node 比 link 早 5 frames 出現一點點,視覺上是「容器先準備好,水才流進來」。

Value label 跳數 的部分用 interpolate clamp 從 0 跳到實際值就好:

<text>{formatInt((n.value ?? 0) * nodeOpacity)}</text>

直接乘 opacity 就是個「免費的 count-up」效果——opacity 從 0 到 1 時,value 也從 0 跳到 100%。Format 用 d3.format(",.0f") 加千分位逗號。


Step 9:收尾 reveal

最後 30 frames(frame 360 → 390)做整組收尾:

const FINAL_START = 360;
const finalProgress = interpolate(frame, [FINAL_START, 390], [0, 1], {
  extrapolateLeft: "clamp",
  extrapolateRight: "clamp",
});
const sankeyScale = interpolate(finalProgress, [0, 1], [1, 1.06]);
const sankeyFade  = interpolate(finalProgress, [0, 1], [1, 0.5]);

兩件事同時發生:

  1. 整個 sankey 容器 scale 1 → 1.06 + opacity 1 → 0.15:圖被「推遠 + 淡出」,視覺上往背景退。
  2. 中央浮現大字 23,450 visits → 1,847 subscribers:用一個獨立的 <div> 在 frame 365 開始 fade in。
<div style={{ opacity: sankeyFade, transform: `scale(${sankeyScale})` }}>
  <svg>{/* sankey */}</svg>
</div>
 
<div style={{ opacity: finalLabelOpacity, fontSize: 56 }}>
  {formatInt(visitsShown)} visits → {formatInt(subsShown)} subscribers
</div>

為什麼這樣收尾? 因為一支 13 秒的影片,觀眾看完之後記得的東西其實就一個——「這張圖在講什麼」。如果你在最後 1 秒給他一行濃縮成「23,450 → 1,847」的數字,他就會記住「轉換率約 8%」。把整支影片的「故事」濃縮成一行,是資料視覺化動畫的終極目標。

中央那行字也順便做 count-up:tallyT * 23450,跟前面 node value 一樣的招。


效能討論

整個 sankey 在 390 frames 裡只算一次 layout——useMemo(buildLayout, [])d3-sankey 的 layout 函式雖然不便宜(要做拓樸排序、迭代收斂 y 順序),但只跑一次完全可以接受:

const layout = useMemo(buildLayout, []);

每幀只更新:

  • 18 條 link 的 strokeDashoffset(一個數字)
  • 12 個 node 的 opacity(一個數字)
  • 兩個 label 的數字
  • 整體容器的 scale / opacity

這些全都是純 React 屬性更新,沒有任何 re-layout、沒有 DOM 測量、沒有非同步資源——所以渲染極輕。整支 13 秒的影片在普通筆電上跟一支「沒有 sankey 的」影片渲染速度幾乎一樣。

buildLayout 是個 pure function:給定相同的 NODE_DATA / LINK_DATA,永遠回傳一樣的結果。這個性質讓它在 SSR / Lambda 環境下完全 deterministic,是 D3 × Remotion 整合的關鍵。


可以擴充的方向

  • 換資料主題:銀行轉帳流向、能源來源 → 用途、選舉票流、預算分配。Sankey 對「A 流到 B」這類語意特別合適。
  • 讀真實 CSV:用 d3.csvParse(text) 把 CSV 解析成 Array<Record<string, string>>,再 group by source/target 算 value。配合 delayRender / continueRenderuseEffect 裡 fetch。
  • 多層 sankey(5+ columns)d3-sankey 完全支援——你只要把 link 的依賴關係給對,它會自動算出每個 node 在第幾欄。記得擴大 extent 的寬度。
  • hover-style 主線高亮:在某段時間把「Google → Home → Signup」這條主線的 ribbon 變亮,其他 ribbon 變淡。可以用 linkRenders.filter() 加上一個 isHero 旗標來控制 stroke opacity。
  • 配色用 d3 內建 scaled3.scaleOrdinal(d3.schemeCategory10) 一行把 group → color 對應做好,省得自己挑。或用 d3.interpolateRainbow 做漸層連續色。

系列總結

D3 × Remotion 系列到這篇就走完了,四篇剛好把資料視覺化的四大基本款都覆蓋一遍:

篇章主題核心工具視覺敘事
T21Bar Raced3.scaleLinear + sort1D 排名變化
T22Force Graphd3-force simulation2D 網路結構
T23World Mapd3-geo + projection地理空間
T24Sankeyd3-sankey layout流量轉換

四篇背後的 pattern 完全一樣:

  1. D3 算 layout——useMemo 包起來,pure function,跟時間無關。
  2. Remotion 給時間——useCurrentFrame + interpolate 把 layout 結果轉成「frame N 的視覺狀態」。
  3. stroke-dasharray / opacity / scale 做 reveal——這幾招幾乎就能做出 80% 的資料動畫。

這個 pattern 是 SSR-friendly 的:D3 layout 在 Node 環境跑得跟瀏覽器一樣,Remotion render 的時候完全 deterministic,每個 worker 算出來的結果都一樣。比起「在瀏覽器裡跑 d3 transition、手動截圖」那種 hack 寫法,這套寫法乾淨太多了。

如果你之前覺得「資料視覺化動畫是設計師的事」——這四篇之後,你應該會發現:有了 D3 + Remotion,你只要餵資料、調節奏,就能把任何 dashboard 變成有故事的影片。這就是這個整合最迷人的地方。


本篇涵蓋的官方文件


下一步

D3 × Remotion 系列完結。下一篇開始進入新的主題——把這套「資料 + 時間 + 動畫」的能力,搬到更貼近實務的 dashboard 影片自動化情境裡。記得回頭把 T21 ~ T24 整個重看一次,會更清楚這套 pattern 的全貌。