D3.js × Remotion 系列(三):geoNaturalEarth1 世界地圖投影
用 D3 的 geoNaturalEarth1 投影把經緯度換算成 SVG 像素,搭配 geoGraticule10 畫經緯線、scaleSequential + interpolateRdYlBu 把數值映射成色階——40 個城市資料波浪式進場的世界地圖。
成品預覽
14 秒的世界地圖 demo:先用 geoNaturalEarth1 把整顆球面投影成一個橢圓形的平面世界圖,疊上 geoGraticule10 程序化生成的經緯線格,然後 40 個城市圓點按 x 座標由左至右波浪式淡入。Phase 3 一條 cyan 高亮帶從左掃到右,靠近的城市會被「點亮」放大。最後 Phase 4 整張地圖緩緩 zoom in(scale 1 → 1.08),底部色階 legend 浮現,收尾。
整支影片完全沒有外部 topojson 國界檔案——只有 sphere outline、graticule grid、與 40 個圓點。但視覺效果已經完整傳達「全球資料」的感覺。
為什麼地圖一定要用 D3?
把地球(一顆球面)攤平成 2D 影像是一個非平凡的數學問題。任何投影都會在某些地方失真——可能是面積、可能是角度、可能是距離。地理學家研究這件事研究了幾百年,發明了上百種投影法,每一種都在「保留什麼、犧牲什麼」之間做出不同取捨。
如果你要自己從零實作一個世界投影:
- 要查公式(例如 NaturalEarth1 是 Tom Patterson 在 2007 年用 polynomial 擬合的)
- 要處理球面三角學
- 要寫 clipping(哪些點在地球背面、不該被畫出來)
- 要算 graticule(經緯線格)
- 要支援 fitSize、translate、scale 等變換
- 要回傳能餵給 SVG 的 path string
預計花你一整週,而且還不一定對。
D3 內建 30+ 種投影——geoMercator、geoEqualEarth、geoNaturalEarth1、geoOrthographic、geoAzimuthalEqualArea、geoStereographic、geoConicConformal ⋯⋯ 全部寫好了,全部可組合。你只要一行:
const projection = d3.geoNaturalEarth1();
const path = d3.geoPath(projection);之後拿任何 GeoJSON 物件丟給 path(),它就吐出 SVG path string。一行算式背後是幾百年的地理學累積。
這篇會用到
d3—geoNaturalEarth1、geoPath、geoGraticule10、scaleSequential、scaleSqrt、interpolateRdYlBu、extent- Remotion 的
useCurrentFrame、interpolate - React 的
useMemo確保 projection 與 path 只算一次
特別強調:不依賴外部 topojson 檔。整段程式碼跑起來就是 self-contained,不用 fetch、不用 delayRender、不用設定 webpack loader。
前置知識
建議先看過 D3 scaleLinear 與 interpolate 關係 與 D3 scaleTime 時間軸與 axisBottom。本篇預設你已經知道 D3 的 scale 是「函式」、d3.extent() 怎麼用、以及 useMemo 在 Remotion 裡的角色。
Step 1:選一個投影
D3 的投影都是「函式工廠」——你呼叫 d3.geoNaturalEarth1() 拿到一個 projection 物件,然後鏈式設定它的參數:
const projection = d3
.geoNaturalEarth1()
.fitSize([MAP_WIDTH, MAP_HEIGHT], { type: 'Sphere' })
.translate([MAP_CENTER_X, MAP_CENTER_Y]);fitSize 是個超實用的方法——你給它一個 [width, height] 與一個 GeoJSON 物件,D3 會自動幫你算出 scale 與 translate,讓那個物件剛好填滿這個矩形。{ type: 'Sphere' } 是 GeoJSON 規範裡的特殊型別,代表「整顆地球」,所以這行的意思是「讓整個地球剛好塞進 MAP_WIDTH × MAP_HEIGHT」。
設好之後 projection 本身是個函式:餵它 [lon, lat] 經緯度(注意順序是 lon 在前),它回傳 [x, y] 像素座標。如果該點在地球背面(對某些投影而言)會回傳 null。
三個常用投影的差異
| 投影 | 特性 | 適合場景 |
|---|---|---|
geoMercator | 角度保真、極區嚴重變形 | Google Maps、需要正北朝上的導航 |
geoEqualEarth | 面積保真、2018 年才發表 | 統計地圖、需要正確比較國家面積 |
geoNaturalEarth1 | 美觀折中,National Geographic 風格 | Generic「全球資料」的視覺呈現 |
geoOrthographic | 球體側視圖,可旋轉 | 3D 地球感、單一地區聚焦 |
geoMercator 是大家最熟的(因為 Google Maps 用它),但它的問題是越靠近極區面積膨脹得越誇張——格陵蘭看起來跟非洲差不多大,實際只有非洲的 1/14。對「全球資料」的視覺呈現很誤導。
geoEqualEarth 是最近幾年才出的新投影(2018),面積保真,但長相相對沒那麼「眼熟」。
我們這個 demo 選 geoNaturalEarth1 的原因:
- 好看:橢圓邊緣、平滑曲線,視覺上就是「世界地圖該有的樣子」
- 不變形太誇張:極區雖然有點壓縮,但不會像 Mercator 那麼極端
- National Geographic 風格:大家潛意識會聯想到「正規地圖」
對 demo / 大多數資料視覺化來說,這是個安全的預設選擇。
Step 2:用 geoPath 畫 sphere 與 graticule
d3.geoPath(projection) 回傳一個函式——你餵它任何 GeoJSON object,它吐出 SVG <path d="..."> 的 d 字串:
const path = d3.geoPath(projection);
// 整顆地球的輪廓(橢圓邊緣)
const spherePath = path({ type: 'Sphere' });
// 經緯線格(每 10 度一條)
const graticulePath = path(d3.geoGraticule10());兩個關鍵點:
-
{ type: 'Sphere' }是個特殊的 GeoJSON 物件,沒有 coordinates,代表「整顆球」。對 NaturalEarth1 來說,丟給path()後會吐出一個橢圓形的邊框 path——這就是你看到的世界地圖外框。 -
d3.geoGraticule10()是個 generator——它會生出一個 GeoJSONMultiLineString,內容是「每隔 10 度經度、每隔 10 度緯度的線」。完全程序化,不需要任何資料檔。如果你想要更密的格線,可以用d3.geoGraticule().step([5, 5])自訂間隔。
把這兩個 path 字串塞進 SVG <path>:
<svg viewBox={`0 0 ${WIDTH} ${HEIGHT}`}>
<path d={spherePath} fill="#0b1220" stroke="#1e293b" strokeWidth={1.5} />
<path d={graticulePath} fill="none" stroke="#1e3a5f" strokeWidth={0.6} />
</svg>整個世界地圖的「底圖」就完成了。0 個外部檔案、0 個 fetch。
Step 3:為什麼這個 demo 不用 topojson?
很多 D3 地圖教學會教你載入 world-atlas 這類的 topojson 檔,裡面有完整的國界 polygon。為什麼這個 demo 不這麼做?
Trade-off:
| 項目 | 純 sphere + 圓點 | 加上 topojson 國界 |
|---|---|---|
| 檔案大小 | 0 | ~100KB(world-110m) |
| 載入流程 | 無 | fetch + topojson-client decode |
| delayRender | 不需要 | 必須 |
| 視覺資訊 | 地理感(球面)+ 資料點 | 完整國家輪廓 + 地理感 |
| 適合場景 | 城市資料、機場、地震、流量 | choropleth、按國家上色 |
對 demo / 大多數資料視覺化來說,「sphere + 點」就足夠了:
- sphere 提供「這是地球」的地理感——觀眾看到橢圓 + 經緯線就懂了
- 圓點 承載真正的資料訊息——位置代表地點、大小代表數值、顏色代表分類
只有當你需要 choropleth(按國家上色,例如「各國人口密度」)時,才必須要有國界 polygon——因為你要把顏色填進「國家形狀」裡。那種情況下 topojson 是必需品,沒有捷徑。
之後可能會出一篇進階版專門講 topojson + choropleth;本篇先把核心概念講清楚。
Step 4:把經緯度變成像素
有了 projection 之後,把資料變成畫面就是一個 forEach 的事:
type City = { name: string; lon: number; lat: number; value: number };
const CITIES: City[] = [
{ name: 'New York', lon: -74.006, lat: 40.7128, value: 8.2 },
{ name: 'London', lon: -0.1276, lat: 51.5074, value: 7.9 },
{ name: 'Tokyo', lon: 139.6917, lat: 35.6895, value: 6.9 },
// ... 共 40 筆
];
const projectedCities = useMemo(() => {
return CITIES.map((c) => {
const p = projection([c.lon, c.lat]);
return {
...c,
x: p ? p[0] : 0,
y: p ? p[1] : 0,
};
});
}, [projection]);幾個值得注意的點:
- 順序是
[lon, lat]——經度在前、緯度在後。這是 GeoJSON 規範(也是大多數 GIS 工具的慣例),跟你直覺寫地址時lat, lon的順序剛好相反。寫反了會看到資料點全部跑到奇怪的地方。 - 回傳值可能是
null——對某些投影(特別是geoOrthographic球體側視圖),地球背面的點會被 clip 掉,回傳 null。NaturalEarth1 不會 null,但養成p ? p[0] : 0的防禦習慣比較安全。 useMemo一次算完——projection 是 deterministic,所以投影結果可以快取。每幀都重算 40 個點不會慢到哪裡去,但既然能省就省。
projected 之後的 c.x、c.y 就是 SVG 像素座標,可以直接拿來畫 <circle cx={c.x} cy={c.y} />。
Step 5:scaleSequential 配色
每個城市除了位置之外還有個 value 數值(這個 demo 是虛構的「人均咖啡消耗量」)。要把這個數字映射成顏色,用 scaleSequential:
const valueExtent = d3.extent(CITIES, (c) => c.value) as [number, number];
const colorScale = d3
.scaleSequential(d3.interpolateRdYlBu)
.domain([valueExtent[1], valueExtent[0]]);注意 domain 故意倒過來寫(max → min)——這樣高 value 會落在 interpolator 的 0 端、低 value 落在 1 端。interpolateRdYlBu 是 ColorBrewer 的內建漸層,從紅 → 黃 → 藍。倒著用之後高值會是紅色、低值會是藍色,直覺上「紅 = 高、藍 = 低」。
Sequential vs Ordinal scale
D3 的 scale 大致分兩類:
| 類別 | 範例 | 輸入 | 適合 |
|---|---|---|---|
| Sequential | scaleSequential | 連續數值 | 數值 → 漸層 |
| Ordinal | scaleOrdinal | 離散類別 | 分類 → 顏色 |
- Sequential 適合「數值越大越熱」這種有順序的資料(人口、GDP、溫度)。輸入是連續的數字,輸出是連續的顏色——任何在 domain 範圍內的數字都對應到漸層上的某一點。
- Ordinal 適合「不同國家用不同顏色,沒有順序意義」這種分類資料。輸入是字串、輸出從一個有限的色盤裡取。
d3.interpolateRdYlBu 不是 scale,而是個 interpolator——一個函式,吃 t ∈ [0, 1] 回傳對應的顏色字串。scaleSequential(interpolator) 把它包裝成「domain 範圍 → 0~1 → interpolator → 顏色」的完整 pipeline。
D3 還有一票其他內建 interpolator:interpolateViridis(綠藍黃,常用於科學論文)、interpolateInferno(黑紅黃)、interpolatePlasma(紫紅黃)、interpolateSpectral(彩虹)⋯⋯ 全部開箱即用。
順便用 scaleSqrt 把 value 映射成圓點半徑——平方根而不是線性,因為「圓的面積與半徑平方成正比」,用 sqrt 之後肉眼比較好對應到數值大小:
const radiusScale = d3.scaleSqrt().domain(valueExtent).range([8, 30]);Step 6:波浪式淡入
40 個城市同時 fade in 太無聊。我們希望它們從左到右波浪式進場——這就是 D3 + Remotion 一起發力的時刻:D3 算位置(也就是排序的依據),Remotion 算時間(每個城市的 delay)。
// 按 projected x 座標由左到右排序
const sortedByX = useMemo(
() => [...projectedCities].sort((a, b) => a.x - b.x),
[projectedCities],
);
const cityIndex = useMemo(() => {
const idx = new Map<string, number>();
sortedByX.forEach((c, i) => idx.set(c.name, i));
return idx;
}, [sortedByX]);
// 把所有 40 個城市散佈在 fillStart → fillStart + fillSpan 之間
const fillStart = 90;
const fillSpan = 140;
const perCityWindow = 30;cityIndex 是個 Map,把城市名稱對應到「它在 x 排序裡的第幾個」。然後每個城市的 appearance 起點:
const order = cityIndex.get(c.name) ?? 0;
const cityStart = fillStart + (order / (sortedByX.length - 1)) * fillSpan;
const localT = clamp01((frame - cityStart) / perCityWindow);
const appear = easeOutCubic(localT);幾個要點:
order / (length - 1)把 index 正規化到 0~1,乘上fillSpan之後就是「該城市在波浪上的相對位置」perCityWindow = 30每個城市有 30 frame(1 秒)淡入完成easeOutCubic給它一點 ease 曲線,比 linear 好看
關鍵洞察:projection(coord)[0] 拿到的 x 既決定空間位置、也決定時間順序。同一個值同時驅動兩種維度——這就是「資料驅動」的精髓。如果你改用 geoOrthographic(球體側視圖),同樣這段程式碼會自動讓波浪繞著球面走,因為 projected x 的順序變了。
Step 7:sweep highlight
Phase 3 那條從左掃到右的 cyan 高亮帶,用同樣的座標系統算:
const phase3Progress = clamp01((frame - 240) / 120);
const sweepX = MAP_LEFT + easeInOutCubic(phase3Progress) * MAP_WIDTH;
const sweepActive = frame >= 240 && frame < 360;sweepX 是當前掃描線的 x 像素座標。畫法是一條 vertical line + 一個有 linearGradient 的 rect 當光暈:
<linearGradient
id="sweepGrad"
x1={sweepX - 120}
x2={sweepX + 120}
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" stopColor="#67e8f9" stopOpacity="0" />
<stop offset="50%" stopColor="#67e8f9" stopOpacity="0.55" />
<stop offset="100%" stopColor="#67e8f9" stopOpacity="0" />
</linearGradient>
<rect x={sweepX - 120} y={MAP_TOP} width={240} height={MAP_HEIGHT} fill="url(#sweepGrad)" />關鍵的 gradientUnits="userSpaceOnUse" 讓 gradient 的 x1/x2 用絕對像素而不是百分比,這樣才能讓 gradient 跟著 sweepX 移動。
接著對每個城市算它跟 sweep line 的距離,產生一個 sweepBoost:
let sweepBoost = 0;
if (sweepActive) {
const dist = Math.abs(c.x - sweepX);
if (dist < 90) {
sweepBoost = 1 - dist / 90;
}
}
const r = baseR + sweepBoost * 8; // 半徑變大
// + 套用 SVG glow filter when sweepBoost > 0.1效果:sweep line 經過時,附近的城市會「亮起來」並放大,像被掃描到一樣。這個小細節讓影片看起來有「在做事」的感覺,而不只是純展示資料。
Step 8:legend 與 outro
底部的色階 legend 是用 SVG <linearGradient> 從 colorScale 取樣建出來的:
const legendStops = useMemo(() => {
const n = 12;
return Array.from({ length: n }, (_, i) => {
const t = i / (n - 1);
const v = valueExtent[0] + t * (valueExtent[1] - valueExtent[0]);
return { offset: `${t * 100}%`, color: colorScale(v) };
});
}, [colorScale, valueExtent]);12 個取樣點對人眼來說已經接近平滑漸層。然後丟進 <defs>:
<linearGradient id="legendGradient" x1="0%" x2="100%">
{legendStops.map((s, i) => (
<stop key={i} offset={s.offset} stopColor={s.color} />
))}
</linearGradient>外面 <rect fill="url(#legendGradient)" /> 就拿到一條跟 colorScale 完全一致的色帶 legend。重點是這個 legend 是「自動」對應到 colorScale 的——你之後改了 domain、改了 interpolator,legend 會自動跟著變,不用手改。
最後 Phase 4 整張地圖緩緩 zoom in:
const phase4Progress = clamp01((frame - 360) / 60);
const zoomScale = 1 + easeOutCubic(phase4Progress) * 0.08;
<g transform={`translate(${cx}, ${cy}) scale(${zoomScale}) translate(${-cx}, ${-cy})`}>
{/* sphere, graticule, cities ... */}
</g>translate → scale → translate(負) 三步驟是 SVG 從中心點縮放的標準寫法。1 → 1.08 是個很細微的縮放,但加上同時淡入的 outro 標題字 + bell 音效,就能營造「故事結束」的感覺。
效能討論
D3 的 projection 計算非常 cheap:
- 40 個
projection([lon, lat])call:每幀算 40 次,總共 14 秒 × 30fps × 40 = 16,800 次。對 CPU 來說微不足道。 geoPath(sphere)/geoPath(graticule10()):每幀重算其實 OK,因為 sphere path 只有幾十個 line segment、graticule 也就是幾十條經緯線。但既然useMemo([projection])一次的成本是 0,當然就 memo 起來。- 40 個
<circle>SVG 元素:瀏覽器每幀重繪 40 個 circle 完全沒壓力,1080p 跑 60fps 都還很輕鬆。
整段 14 秒影片可以即時 preview、可以用 npx remotion render 全速跑。沒有任何 fetch、沒有任何 async、沒有任何 bottleneck。
如果之後你要畫 5,000 個點(例如每個機場一個 dot),那就要開始考慮:
- 把不會動的 sphere/graticule 拉成獨立
<svg>layer,避免每幀 React diff 那麼多元素 - 用
<canvas>+d3.geoPath(projection, context)改走 canvas 渲染,速度差幾十倍 - pre-project 所有點存成
[x, y]陣列,render 時不要再呼叫 projection
但對 demo 級別的 40 個點來說,這些都不需要。
可以擴充的方向
這篇只用了 D3 geo 的最基本款,可以延伸的方向非常多:
-
geoOrthographic做球體旋轉:把 projection 換成d3.geoOrthographic(),每幀.rotate([angle, 0])連續轉動,就是一顆會自轉的地球。Phase 3 的 sweep 可以改成「另一個半球的城市轉到正面時逐漸出現」。 -
加 topojson 做真實國界 choropleth:把每個國家按一個 metric 上色。需要 fetch
world-atlas的countries-110m.json、用topojson-client解碼,然後對每個 feature 套colorScale(country.value)算 fill。 -
d3.geoCentroid自動找國家中心放標籤:geoCentroid 接受任何 GeoJSON polygon 回傳它的「視覺中心」經緯度,然後projection(centroid)拿到像素座標。結合 topojson 國界,可以自動在每個國家中心放國名標籤。 -
串接 IATA airport CSV 做航線地圖:把點變成線——對每對城市用
d3.geoInterpolate([lon1, lat1], [lon2, lat2])取樣得到大圓弧,再path({ type: 'LineString', coordinates: arc })畫出來。看起來就是教科書經典的「全球航線圖」。 -
配合 T24 sankey 做「跨國資金流」混合圖:左邊是世界地圖,右邊是 sankey diagram,當 sankey 的某條 flow hover 時,地圖上對應的兩個國家點亮並用大圓弧連起來。
D3 的好處是這些東西全部都是同一套 API,學會了 projection + path + scale,剩下的就是組合題。
小結
D3 的地理投影是個「拿來就能用」的世界級寶藏——幾百年的地圖學濃縮成 30+ 個函式,每個都只要一行就能呼叫。geoNaturalEarth1 + geoPath + geoGraticule10 三件組合,不需要任何外部資料檔,就能畫出一張看起來很正規的世界地圖。
接下來把資料點、scaleSequential 配色、波浪式淡入接上,一個資料視覺化影片就完成了。整段程式碼裡 D3 負責「空間 → 像素」、Remotion 負責「時間 → 視覺狀態」,分工乾淨、彼此不踩線。
下一篇 T24 會看 D3 的最後一個招牌:sankey flow diagram。如果你做過任何「能源流向」、「使用者旅程」、「跨國貿易」的視覺化,sankey 是無可取代的工具,而 D3 同樣只需要幾行就能算完。
本篇涵蓋的觀念
d3.geoNaturalEarth1()與fitSize自動排版d3.geoPath(projection)把 GeoJSON 變 SVG path stringd3.geoGraticule10()程序化生成經緯線格projection([lon, lat])把經緯度變像素d3.scaleSequential+d3.interpolateRdYlBu數值映射顏色d3.scaleSqrt把數值映射半徑(面積感正確)- 用 projected x 同時驅動「位置」與「波浪 stagger 順序」
- 自動從 colorScale 取樣建 SVG linearGradient legend
- 為什麼這個 demo 不需要 topojson