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 generatoruseMemo— sankey layout 算一次就好,整個 390 frames 都共用同一份結果interpolate/useCurrentFrame— Remotion 的時間軸基礎stroke-dasharrayreveal 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 暫存)在本篇會再用一次。
如果你還不熟 interpolate 跟 Sequence,建議先看 T3:YouTube 片頭動畫。
Step 1:安裝 d3-sankey
npm install d3-sankey @types/d3-sankey注意 D3 不是一個單一套件——它是 30+ 個子套件組成的家族(d3-array、d3-scale、d3-shape、d3-force、d3-geo、d3-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 寫法最直觀。group跟layer是我自己加的欄位,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 陣列裡的位置)。 - 每個 link:
source跟target從 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-dashoffset從totalLength慢慢降到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:
在 reveal 進度約 60% 的位置 highlight 達到最大,前後線性衰減,視覺上是一道光從畫筆掃過。配合 SVGconst highlight = Math.max( 0, 1 - Math.abs(frame - (start + GROUP_REVEAL_DURATION * 0.5)) / (GROUP_REVEAL_DURATION * 0.7), );<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]);兩件事同時發生:
- 整個 sankey 容器 scale 1 → 1.06 + opacity 1 → 0.15:圖被「推遠 + 淡出」,視覺上往背景退。
- 中央浮現大字
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/continueRender在useEffect裡 fetch。 - 多層 sankey(5+ columns):
d3-sankey完全支援——你只要把 link 的依賴關係給對,它會自動算出每個 node 在第幾欄。記得擴大extent的寬度。 - hover-style 主線高亮:在某段時間把「Google → Home → Signup」這條主線的 ribbon 變亮,其他 ribbon 變淡。可以用
linkRenders.filter()加上一個isHero旗標來控制 stroke opacity。 - 配色用 d3 內建 scale:
d3.scaleOrdinal(d3.schemeCategory10)一行把 group → color 對應做好,省得自己挑。或用d3.interpolateRainbow做漸層連續色。
系列總結
D3 × Remotion 系列到這篇就走完了,四篇剛好把資料視覺化的四大基本款都覆蓋一遍:
| 篇章 | 主題 | 核心工具 | 視覺敘事 |
|---|---|---|---|
| T21 | Bar Race | d3.scaleLinear + sort | 1D 排名變化 |
| T22 | Force Graph | d3-force simulation | 2D 網路結構 |
| T23 | World Map | d3-geo + projection | 地理空間 |
| T24 | Sankey | d3-sankey layout | 流量轉換 |
四篇背後的 pattern 完全一樣:
- D3 算 layout——
useMemo包起來,pure function,跟時間無關。 - Remotion 給時間——
useCurrentFrame+interpolate把 layout 結果轉成「frame N 的視覺狀態」。 - 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 的全貌。