🗺️

地理空間データの可視化は deck.gl がよいぞ

に公開

こんにちは、g0eです。最近業務で緯度経度等の地理空間データを可視化する仕組みを構築したのですが、deck.glがなかなかに便利だったので紹介したいと思います。

https://deck.gl

deck.gl とは?

deck.glは、地理空間データをビジュアライズするためのOSSで、JSON形式のデータをアイコンやポリゴン、テキスト等のレイヤーとして、地図上に重ね合わせて描画することができます。deck.gl単体だけでなく、Google MapsやMapLibre といった地図サービスと組み合わせて利用することもできます。描画は WebGPU/WebGL2 を使って行われ、大規模なデータを高速に描画することができることも強みの一つです。

Reactとの統合のしやすさも強みの1つのようですが、VanillaなJavascriptライブラリも提供されており、今回はVueと組み合わせて使用しましたが、特に問題なく使うことができました。技術検証としては、Google MapsとMapLibreの組み合わせを途中まで検証していたのですが、最終的には Google Mapsと組み合わせて使うことにしました。

前提

以降の記事では、特に断りのない場合、Javascript版(非React版)をGoogle Mapsと組み合わせてVue3上で使うことを前提にします。また、情報は2025年12月5日執筆時点のものであり、@deck.gl/xxxのバージョンは9.0系を、@googlemaps/js-api-loaderのバージョンは2.0系を前提としています。

deck.glの良い点

責務の分離による構造のわかりやすさ

deck.glを利用することで、データの可視化を行うレイヤー(以下、データレイヤーと呼ぶ)とベース地図のレイヤーを切り離して管理することができます。

例えば、Google Mapsだけを使って地図上にピンを表示する場合、地図を描画したうえで、Map Javascript APIを使ってマーカーを描画します。一方で、deck.glを使う場合、ベース地図のレイヤーとしてGoogle Mapsを使いつつ、ピンはdeck.glのIconLayerを使って実装することになります。

データレイヤーとベース地図が分かれていることのメリットは、それぞれを独立して差し替えることが出来るという点です。例えば、Google Mapsではなくて、オープンソースのMapLibreに差し替えたい、自前で用意した白地図に差し替えたい、といったことが比較的簡単にできます。(ベース地図との連携方法によっては多少の手間はかかりますが…)

同じデータレイヤーを、

  • Google Mapsと組み合わせた場合Google Mapsへのピン表示例
  • MapLibreと組み合わせた場合MapLibreへのピン表示例
  • 白地図(GeoJsonLayerとして実装)と組み合わせた場合白地図へのピン表示例
  • データレイヤーだけ描画した場合ピンだけの表示例

また、データレイヤー=deck.glのレイヤーは抽象化されたレイヤークラスを継承して生成されており、これらのレイヤーの差し替えや、複数レイヤーの重ね合わせも非常に簡単にできます。

可視化方法が豊富

deck.glではデータを可視化するための多種多様なレイヤーが用意されています。すべてのレイヤーを検証したわけではないのですが、一部のレイヤーについて可視化例をいくつか挙げたいと思います。

ScatterplotLayer

文字通り、散布図のように点のプロットにも使えますし、getRadiusで半径を変えることでバブルのプロットにも使えます。

バブルチャートの描画例

HeatmapLayer

ヒートマップは、点の密度が高い場所が自動的に濃くなるので、事前のデータ集計をしなくてもいい感じに描画してくれるので便利ですね。

元々はGoogle Mapsでヒートマップを描画しようと調査していたところ、Google Maps Javascript API ではヒートマップレイヤは非推奨となり、 deck.glの利用を案内していたのが、deck.glを知ったきっかけでした。

ヒートマップの描画例

ArcLayer

2点間の関係を可視化するのに使えます。似たレイヤーとしてLineLayerというのもあります。以下の例では、ArcLayerとLineLayerの違いがほとんど分からないのですが、ズームしたりドラッグ&ドロップで地図を動かすと、ArcLayerの方がより立体的に見えるようです。

2点間のパス分析例

GeoJsonLayer

別途、自前でGeoJSONを用意しておく必要がありますが、GeoJSONで表現された任意の図形を描画できます。ベース地図を使わずに、都道府県の白地図をGeoJsonLayerで描画するといった使い方も出来ます。

以下は、事前に用意した都道府県毎のGeoJSONを使って都道府県を塗り分けた時の描画例となります。

都道府県の塗り分け例

以下は、適当なGeoJSONの図形をLLMに生成させて描画した例です。GeoJSONで表現できる形式であれば、任意の図形を描画できるので、例えばデータベースに保存されている緯度経度情報を使って移動ルートを描画するなど、幅広い使い方ができると思います。

GeoJSONによる図形の描画例

実装パターン

deck.glとGoogle Mapsを組み合わせて利用する場合、状態管理や描画方法によっていくつかのパターンがあります。deck.glで状態管理を行う方がコードや構造がシンプルではあるのですが、Google Mapsが状態管理を行うパターンでもそこまで複雑にならず、Google Mapsの全機能がそのまま使えるので、Google Mapsで状態管理を行うようにしました。

Google Mapsが状態管理を行うパターン

公式ドキュメントの Interleaved と Overlaid がこのパターンに当てはまります。通常通り(deck.glと組み合わせない場合と同じように)Google Mapsを描画して、 @deck.gl/google-maps の提供する GoogleMapsOverlay を使って接続します。

コードのイメージとしては以下のような感じになります。

import { importLibrary } from '@googlemaps/js-api-loader';
import { GoogleMapsOverlay } from '@deck.gl/google-maps';

// 1. Google Maps を初期化
const { Map } = await importLibrary('maps');
const map = new Map(container, {
  center: { lng: 139.69, lat: 35.68 },
  zoom: 10,
  mapId: 'YOUR_MAP_ID',
});

// 2. deck.gl オーバーレイを作成して接続
const overlay = new GoogleMapsOverlay({
  layers: [/* deck.gl レイヤー */],
});
overlay.setMap(map);

状態管理(表示中の緯度経度やズームレベル等)はGoogle Mapsが主体となるので、アプリケーション側から状態を操作する場合、Google Mapsのmapインスタンスを直接操作する必要があります。Vueで管理する状態と地図をリアクティブに連動させたい場合、以下のような処理が必要になります。

  • mapインスタンスのイベントをトリガーに状態を更新
  • 状態が変化した時にmapインスタンスと差分があればmap側を更新

状態の型は独自定義しても良さそうですが、deck.glの MapViewState を使っておくと、ベース地図を変更した時の差分が少なく済むと思います。以下にコードのイメージを示します。(以下のイメージでは地図の傾き(pitch/tilt)は管理していないので、必要に応じて追加してください)

import { ref, watch } from 'vue';
import type { MapViewState } from '@deck.gl/core';

const viewState = ref<MapViewState>(/* ... */);

// Google Maps → viewState
map.addListener('zoom_changed', () => {
  const zoom = map.getZoom();
  if (zoom != null && zoom !== viewState.value.zoom) {
    viewState.value.zoom = zoom;
  }
});

map.addListener('center_changed', () => {
  const center = map.getCenter();
  if (center) {
    const lat = center.lat();
    const lng = center.lng();
    if (lng !== viewState.value.longitude || lat !== viewState.value.latitude) {
      viewState.value.longitude = lng;
      viewState.value.latitude = lat;
    }
  }
});

// viewState → Google Maps
watch(
  viewState,
  (newViewState) => {
    const mapZoom = map.getZoom();
    if (mapZoom != null && newViewState.zoom !== mapZoom) {
      map.setZoom(newViewState.zoom);
    }
    const center = map.getCenter();
    if (center) {
      const mapLatitude = center.lat();
      const mapLongitude = center.lng();
      if (newViewState.latitude !== mapLatitude || newViewState.longitude !== mapLongitude) {
        map.panTo({ lng: newViewState.longitude, lat: newViewState.latitude });
      }
    }
  },
  { deep: true }
);

deck.glが状態管理を行うパターン

公式ドキュメントの Reverse Controlled と、deck.gl の提供する TileLayer から Map Tiles API を呼び出して描画する方法が、このパターンに当てはまります。

Reverse Controlledは残念ながらReact版しか用意されていないので未検証です。

deck.glのTileLayerとして地図を読み込む場合は、deck.gl単独で利用する場合と同様に初期化処理をして、layersの中に生成したTileLayerを入れます。

deck.gl単独で動かす場合、@deck.gl/core の提供するDeckクラスから、deckインスタンスを生成します。Vue側の状態管理とリアクティブにするのであれば、以下のようなコードになるイメージです。

import { ref, watch } from 'vue';
import { Deck, FlyToInterpolator } from '@deck.gl/core';
import type { DeckProps, MapViewState } from '@deck.gl/core';

const viewState = ref<MapViewState>({
  longitude: 139.69,
  latitude: 35.68,
  zoom: 10,
});

let deck: Deck | null = null;
// ユーザーが操作中かどうかを判定するフラグ
let isInteracting = false;

const onViewStateChange: DeckProps['onViewStateChange'] = ({
  viewState: newViewState,
  interactionState,
}) => {
  // ドラッグなどの操作中はフラグを立てる
  if (interactionState) {
    isInteracting = interactionState.isDragging || interactionState.isZooming || false;
  }
  viewState.value = { ...viewState.value, ...newViewState };
};

deck = new Deck({
  canvas: container,
  width: '100%',
  height: '100%',
  viewState: viewState.value,
  controller: true,
  layers: [/* deck.gl レイヤー */],
  onViewStateChange,
});

// viewState の変更を deck に反映
watch(
  viewState,
  newViewState => {
    if (!deck) return;
    // ユーザー操作中の更新であれば、ガタツキ防止のためアニメーションしない
    const shouldAnimate = !isInteracting;
    deck.setProps({
      viewState: {
        ...newViewState,
        transitionDuration: shouldAnimate ? 'auto' : 0,
        transitionInterpolator: shouldAnimate ? new FlyToInterpolator({ speed: 2 }) : undefined,
      },
    });
  },
  { deep: true }
);

deck.glのハマりどころ

レイヤーは描画のたびに生成する

データの可視化を担うレイヤーですが、deck.gl においては Immutable(不変) なものとして扱います。一度描画に使われたレイヤーインスタンスを再利用したり、プロパティを直接書き換えて更新したりすることは推奨されていません。

例えば、凡例の操作によって表示データを切り替えるような場合、既存のレイヤーインスタンスを操作するのではなく、切り替えのたびに new XxxLayer(...) で生成し直して deck.gl に渡す のが正解らしいです。

パフォーマンスと仕組みについて

「毎回 new してパフォーマンスは大丈夫なのか?」という気がするのですが、deck.gl は React と同様の「リアクティブ」なアーキテクチャ で設計されており、この使い方が最も最適化されているらしいです。

FAQ「Should I really regenerate all layers every time something changes?」で解説されているように、deck.gl は内部でレイヤーの id をキーにして新旧のレイヤーをマッチングし、差分 を検出して実際に変わった部分だけを更新します。同じ id を持つレイヤーを生成し続ける限り、見た目上は毎回 new していても、実際の描画処理は最小限に抑えられるらしいです。

逆に、インスタンスを無理に使い回そうとすると、ライフサイクル(finalize によるリソース破棄など)の整合性が取れなくなり、予期せぬエラーの原因となるようです。(自分は最初に結構困りました)

レイヤーの数は増やしすぎない

前の項目と繋がるのですが、当初はレイヤーの生成を最小化した方がパフォーマンスが良いのではないかと思い、表示のオン・オフ切り替えをする凡例とレイヤーの数を同じにしていました。凡例の表示切り替えのたびにレイヤーを clone して、visible のフラグだけを反転する処理にしていました。

ただ、システムの仕様上、凡例の理論上限値が1000件のため、凡例を増やした時のパフォーマンスを確認したところ、200〜300レイヤーになると処理が重くなる(もっさりする)挙動に遭遇しました。

公式ドキュメントの「Number of Layers」によると、100レイヤー程度までは問題なく動作し、数百レイヤーまでは可能とされていますが、数千レイヤーでの使用は想定されていない と明記されています。また、pickable: true にできるレイヤーには 256件の上限 というのもあります。

visible プロパティによる制御は公式ドキュメントでも推奨されてはいますが、レイヤーの数が増えすぎることによるトレードオフの方が大きそうです。凡例ごとにレイヤーを分けるのではなく、少数のレイヤーに表示対象のデータだけを渡して再生成する方がパフォーマンスが良さそうです。

レイヤーの描画順序の制御

データレイヤーで描画したピンやバブルが、Google Maps側で描画している地名のラベルや、ランドマークのアイコンより下部に描画されて、表示が隠れてしまう問題が発生しました。 (データレイヤ同士の描画順序なら、レイヤー配列の定義順で制御できそう)

これはベース地図とdeck.glの描画をどのように組み合わせるかに起因する問題で、以下の2つの方式があります。

Overlaid

Overlaidの場合、ベース地図とdeck.glの描画は別のcanvasに対して行われ、それをマージして描画します。なので、deck.glで描画したデータレイヤはベース地図より上に描画され、要素が隠れることはなくなります。

※画像は公式ドキュメントから引用
Overlaid

Interleaved

Interleavedの場合、ベース地図とdeck.glの描画は同一のcanvasに対して行われ、この時に何を手前に描画するかが決まります。MapLibre では beforeId を指定することで、deck.glのレイヤーをベース地図のどの要素の前に描画するかを決めることが出来るようですが、Google Mapsに関しては自分の調べた限りそのようなオプションは無さそうでした。

※画像は公式ドキュメントから引用
Interleaved

Google Maps側のレンダリングタイプ

結局、次に説明するWebGLのコンテキスト上限の問題があり、なるべくWebGL描画を減らす方針にしたので、Google Maps側のレンダリングタイプとして、ベクターではなくラスターを選ぶことにしました。
deck.glの公式ドキュメントによると、ベクターマップが検知されない場合は、自動的に Overlaid にフォールバックされるということで、Google Maps側に renderingType: google.maps.RenderingType.RASTER の設定を入れることで、無事、データレイヤーがすべて手前に描画されるようになりました。(GoogleMapsOverlay を生成する際にも明示的に interleaved: false を渡すことでも Overlaid に切り替えることができるようです)

WebGLのコンテキスト上限

(これは deck.gl の問題というよりは、WebGLを使った描画を行う場合全般の課題のようですが、)ブラウザでWebGLを使って何かを描画する場合、同時に描画できる上限が決まっているようです(ChromeとSafariは16)。

その上限を超えてWebGLによる描画を行おうとすると、 webglcontextlost というイベントが発生して、最初に描画された要素が真っ白になります。一般的な利用方法でこの制限にひっかかるのは稀のような気もするのですが、開発中は動作確認のためかなりの数の地図を画面上に並べて動かしていたので、比較的高頻度で発生していました。

各種リソースの解放処理の改善など色々と試してみたのですが、結局、WebGLを使う限りは発生を完全に回避することは出来ないと判断して、エラーハンドリングと、再描画をユーザにトリガーしてもらえるUIを実装することにしました。

Google Mapsで検知してもらえるとありがたいのですが、その方法が見つけられなかったので、やや強引ですがcanvasを直接監視する形で実装しました。以下がコードのイメージで、emitsして親のコンポーネント側でエラー表示と再描画ボタンを描画する仕様にしています。

map.addListener('tilesloaded', () => {
  // ...

  canvas = container.querySelector('canvas');
  canvas.addEventListener('webglcontextlost', (ev: Event) => {
    console.warn('WebGL context lost detected', ev);
    emits('webglcontextlost');
  });
});

さいごに

地理空間データの可視化と聞くと、まっさきにGoogle Mapsとの戦いになるのかぁ…と思っちゃいましたが、deck.glという素晴らしいOSSに出会えて、なかなか楽しむことができました。
あまり触れていないですが、GeoJsonLayerで使う用のGeoJSONの加工でも色々と新しい学びがあり楽しかったので、別途記事にまとめてみたいと考えています。

それでは最後までお付き合いいただきありがとうございました。

Discussion