Remotion LabRemotion Lab
D3 資料視覺化系列D3.js × Remotion 系列(三):geoNaturalEarth1 世界地圖投影
d3data-visualizationgeoadvanced

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+ 種投影——geoMercatorgeoEqualEarthgeoNaturalEarth1geoOrthographicgeoAzimuthalEqualAreageoStereographicgeoConicConformal ⋯⋯ 全部寫好了,全部可組合。你只要一行:

const projection = d3.geoNaturalEarth1();
const path = d3.geoPath(projection);

之後拿任何 GeoJSON 物件丟給 path(),它就吐出 SVG path string。一行算式背後是幾百年的地理學累積。

這篇會用到

  • d3geoNaturalEarth1geoPathgeoGraticule10scaleSequentialscaleSqrtinterpolateRdYlBuextent
  • Remotion 的 useCurrentFrameinterpolate
  • 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 會自動幫你算出 scaletranslate,讓那個物件剛好填滿這個矩形。{ 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());

兩個關鍵點:

  1. { type: 'Sphere' } 是個特殊的 GeoJSON 物件,沒有 coordinates,代表「整顆球」。對 NaturalEarth1 來說,丟給 path() 後會吐出一個橢圓形的邊框 path——這就是你看到的世界地圖外框。

  2. d3.geoGraticule10() 是個 generator——它會生出一個 GeoJSON MultiLineString,內容是「每隔 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.xc.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 大致分兩類:

類別範例輸入適合
SequentialscaleSequential連續數值數值 → 漸層
OrdinalscaleOrdinal離散類別分類 → 顏色
  • 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-atlascountries-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 string
  • d3.geoGraticule10() 程序化生成經緯線格
  • projection([lon, lat]) 把經緯度變像素
  • d3.scaleSequential + d3.interpolateRdYlBu 數值映射顏色
  • d3.scaleSqrt 把數值映射半徑(面積感正確)
  • 用 projected x 同時驅動「位置」與「波浪 stagger 順序」
  • 自動從 colorScale 取樣建 SVG linearGradient legend
  • 為什麼這個 demo 不需要 topojson