💘

GeoArrow のデータを Deck.gl レイヤーとして MapLibre GL JS に乗せてみる

に公開

少し前に、GeoArrow のデータを deck.gl 上に描画するための NPM パッケージがあることを知りました。

https://github.com/geoarrow/deck.gl-layers

軽く調べると、

  • GeoArrow のデータを deck.gl 上に描く方法(↑のパッケージ関連)
  • deck.gl を MapLibre GL JS に重ねる方法

はいくつか見つかりますが、その両方をつなげたのは見つからなかったので、試してみた時のメモです。

GeoArrow とは

GeoArrow というのは、GIS データを Apache Arrow 形式で扱う際のフォーマットです。Apache Arrow は、効率的なデータ交換のために設計されたデータフォーマットです。データを保存する際の効率性ではなく、サービス間やプロセス間でのデータ交換に主眼を置いている、というのがポイントです。つまり、GeoArrow は、効率的に GIS データをやりとりするためのフォーマットです

と言ってもピンとこないかもしれませんが、例えば、GeoJSON はデータ交換という観点ではあまり効率がよくありません。PostGIS などの DB に入っているデータを動的にウェブブラウザ上に表示するケースを考えてみましょう。GeoJSON を使う場合、DB に入っている時点ではバイナリデータだったものを一度文字列に変換して、その文字列をまたブラウザ上でパースする、という変換が発生しています。大きなデータではこうした変換がボトルネックになり得ます。そんなときは、GeoArrow を使うとパフォーマンスが改善する可能性があります。

Apache Arrow については、以下のブログ記事がコンパクトにまとまっているので、気になる方は読んでみてください。

https://www.clear-code.com/services/apache-arrow.html

GeoArrow のデータを MapLibre GL JS で描画するには

さて、そんなわけで GeoArrow を使いたいのですが、現状、MapLibre GL JS ではこれを描画する方法がなさそうです。データソースとして使えるタイプの一覧はこちらですが、使えそうなものがありません。addProtocol() でむりやりなんとかできるのかもですが、基本的にはタイル用のものだと思うので違いそうです。

https://maplibre.org/maplibre-style-spec/sources/

@geoarrow/deck.gl-layers とは

そんな感じで、GeoArrow のデータを GPU のバッファまで無変換で届ける方法は自分で発明するしかないのか...?、と悩んでいたところ、冒頭に書いた NPM パッケージ @geoarrow/deck.gl-layers を知りました。これは、その名の通り GeoArrow のデータを deck.gl のレイヤーとして描画するためのパッケージで、GeoArrow 公式が開発しています。

仕組みとしては、deck.gl にはバイナリデータを直接渡せる API があるので、それを使っています。ドキュメントを読んだ感じ、愚直な実装でよければ自分で実装するのもそれほど難しくなさそうです。

https://deck.gl/docs/developer-guide/performance#supply-attributes-directly

@geoarrow/deck.gl-layers を使う利点は、

  • ポリゴンのテッセレーション[1]をメインスレッドではなくワーカーを使って実行してくれるので、メインの処理がブロックされない。
  • データが全部揃うのを待たずに、Apache Arrow の batch record 単位で非同期に処理を進めてくれる。

というあたりです。まあ、恩恵を実感するほど使い込んでいないのでどこまで効果があるかはわからないのですが、説明やコードを読む感じ、わりとありがたそうな雰囲気はあります。

deck.gl を MapLibre GL JS に重ねる

ここまでで、GeoArrow を deck.gl に描く方法は分かりましたが、その deck.gl を MapLibre に載せるにはどうすればいいのでしょうか。deck.gl は、MapLibre GL JS や Mapbox GL JS と組み合わせて使う方法を提供しています。やり方は 3 つありますが、試した感じ、MapLibre のレイヤーのひとつとして扱う(interleaved)方法がいちばん軽そうでした。

https://deck.gl/docs/developer-guide/base-maps/using-with-maplibre

MapLibre 側のドキュメントはこちらです(これは overlay 方式みたいです)。

https://maplibre.org/maplibre-gl-js/docs/examples/toggle-deckgl-layer/

@geoarrow/deck.gl-layers を MapLibre GL JS に重ねる

@geoarrow/deck.gl-layers は、deck.gl が提供しているレイヤーに GeoArrow が付いたバージョンを提供しています。例えば、↑のドキュメントはこういう例になっていましたが(ScatterplotLayer は点を描画するものです)、

import {ScatterplotLayer} from '@deck.gl/layers';

...

const deckOverlay = new MapboxOverlay({
  interleaved: true,
  layers: [
    new ScatterplotLayer({
      id: 'deckgl-circle',
      data: [
        {position: [0.45, 51.47]}
      ],
      getPosition: d => d.position,
      getFillColor: [255, 0, 0, 100],
      getRadius: 1000,
      beforeId: 'watername_ocean' // In interleaved mode render the layer under map labels
    })
  ]
});

map.addControl(deckOverlay);

この layers に入れるレイヤーを GeoArrowScatterplotLayer に変えます。

- import {ScatterplotLayer} from '@deck.gl/layers';
+ import {GeoArrowScatterplotLayer} from '@geoarrow/deck.gl-layers';
  
  ...

  const deckOverlay = new MapboxOverlay({
    interleaved: true,
    layers: [
-     new ScatterplotLayer({
+     new GeoArrowScatterplotLayer({
        id: 'deckgl-circle',
...

ここで、ちょっと躓いたのはオプションの指定の仕方です。data にデータを指定するのはまあわかるんですが、get* に指定するのは (d) => d.position のようなアクセサの関数ではなく、data_points.getChild('geom') のような、実際の Arrow のデータです。じゃあ data いらなくない...?という気もするのですが、どういうルールになっているのかまだよく理解できていません。

const resp = await fetch('./points.arrow');
data_points = await arrow.tableFromIPC(resp);

...
    new GeoArrowScatterplotLayer({
      id: 'deckgl-circle',
      data: data_points,
      getPosition: data_points.getChild('geom')!,

デモ

以下が、@geoarrow/deck.gl-layers を使って、無駄に10万個の点、1万個の線、1万個の三角形を表示したデモです[2]

https://yutannihilation.github.io/maplibre-geoarrow-deckgl-layers/

コードはこんな感じです(svelte-maplibre-gl を使っています):

https://github.com/yutannihilation/maplibre-geoarrow-deckgl-layers/blob/main/src/routes/%2Bpage.svelte

余談:DuckDB でのデータ生成

ちなみに、最終的には DuckDB WASM をブラウザで動かして、そこから GeoArrow 形式のデータを動的に生成して MapLibre GL JS上に可視化...、みたいなことをやりたいのですが、まだ DuckDB WASM の使い方がよくわからないので今回はあらかじめ生成したデータを使っていました。

若干面倒だったのは、データの型が GEOMETRY のままだと @geoarrow/deck.gl-layers が受け付けてくれないので、型を合わせる必要がある、という点です。どういうことかというと、例えばDuckDB の spatial extension には点を作る関数が複数ありますが(reference)、シグネチャの違いに注目してみてください。

POINT_2D ST_Point2D (x DOUBLE, y DOUBLE)

POINT_3D ST_Point3D (x DOUBLE, y DOUBLE, z DOUBLE)

GEOMETRY ST_Point (x DOUBLE, y DOUBLE)

次元の違いによって点には POINT_2DPOINT_3DPOINT_4D といった型があり、ST_Point2D()ST_Point3D() は戻り値がそれぞれに対応する型になっていますが、ST_Point()GEOMETRY になっています。GEOMETRYPOINT_2DPOINT_3D も含む上位の型で、次元が分からない場合や点・線・ポリゴンのどれになるかわからない場合はこの型が使われるようです。

DuckDB 上でやっている分にはこの違いを意識する必要はないのですが、nanoarrow extension で Apache Arrow に変換する際はこの型の違いが影響してしまいます。型が POINT_2D 型だと GeoArrow の定義と一致する List<Struct<x: double, y: double>> で書き出されるのですが、GEOMETRY 型だと BinaryArray になってしまいます(WKB になってる?)。

線やポリゴンの場合は、次元別の関数はないので ST_MakeLine()ST_MakePolygon() の結果を LINESTRING_2DPOLYGON_2D にキャストしてやる必要があります。

SELECT
    ...
    ST_MakeLine(p1, p2)::LINESTRING_2D as geom
FROM
    ...

ちなみに、型定義には LINESTRING_3DPOLYGON_3D といった型はないので、3D のデータを扱いたい場合はほんとに回避方法がなさそうです。なんとかしたい...

追記:LINESTRING_3DPOLYGON_3D を追加しました。

https://github.com/duckdb/duckdb-spatial/pull/595

感想

DuckDB のデータ生成がぜんぜんわからなくて時間がかかったんですが、本題の部分は思ったより簡単でした。また実際に必要になったときに思い出して使ってみたいと思います。

脚注
  1. GPU は三角形しか描画できません。なので、任意のポリゴンを描画するためには、あらかじめそのポリゴンを三角形に分割しておく必要があります。この処理を tessellation とか triangulation とか呼びます。 ↩︎

  2. 三角形なのでテッセレーションのありがたみはないです。データを作るのがめんどくさくて... ↩︎

MIERUNEのZennブログ

Discussion