PostGISのST_AsMVTを使用してベクトルタイルサーバーを構築し、地図に表示させてみた

## はじめに
今回はPostGISのST_AsMVTを使って動的なベクトルタイルサーバーを構築する方法と、そのベクトルタイルをMapLibreで表示する方法をローカル環境で試してみました。すべての手順を記載してはいませんが、ポイントとなる箇所をまとめておきたいと思いました。

今回のベクトルタイルサーバーは、対象テーブルが増えても設定追加等のメンテナンスが不要になるように、対象テーブルをクエリパラメータで指定できるようにしてみました。

なお、ベクトルタイルの対象は行政界ポリゴンとし、TerraMap APIのデータベースと同じものを使用しております。TerraMap APIのデータベースから取得できる行政界ポリゴン、その提供する形式をGeoJSONからベクトルタイルへと変更した試みでもあります。

https://www.mapmarketing.co.jp/terramap-api?grid=qiita&utm_source=qiita.com&utm_medium=referral&utm_campaign=qiita_mvt_polygon_server_f62d4410e063ee98baa7_01

### ベクトルタイルとは
ベクトルデータをタイル化処理したもの。プロトコルバッファを準拠したバイナリデータでもあり、大量のベクトルデータを配信するのに適した形式。**MapboxVectorTile (MVT)** が現在のデファクトスタンダードだと言えそうです。

## データベースについて
データベースについては、PostgreSQL 12とそのGIS拡張であるPostGIS 3.0を用いました。今回の方法を取る必須条件としては、以下のようなものがあります。

- **ベクトルタイルに関する関数**が使用できるデータベース環境を用意する
- 町丁目ポリゴン等、**行政区画ごとのテーブル**を持たせる
- テーブルには、**Webメルカトル(EPSG:3857)のジオメトリ**を持たせる

### ST_AsMVT関数
タイルレイヤーに対応する行集合をMapboxVectorTile (MVT)で返す集約関数です。今回必要不可欠な関数の1つであり、PostGIS 2.4で新規作成された関数です。

使用するには、PostGISをソースからprotobuf-cに関連付けた上でコンパイルする必要があるらしく、注意が必要です。今回使用したPostGIS 3.0では別途の関連付けやコンパイルは不要でした。

:::note warn
PostGIS 3.0を使うことで、手早くST_AsMVT関数を利用することができましたが、もし最新バージョンでの用意（コンパイル等）が困難ならば、PostGISのDockerイメージの利用を検討すると良いかもしれません。
:::

### テーブル および ジオメトリ構成
例えば、ベクトルタイル生成に対して必要最低限の町丁目ポリゴンテーブルを考えた場合、以下のようになるかと思います。

```
Table: "chomoku_polygons"

    Column    |            Type             | Nullable 
--------------+-----------------------------+------------
 geocode      | character varying(20)       | not null 
 polygon      | geometry(MultiPolygon,3857) |          

Indexes:
    "chomoku_polygons_pkey" PRIMARY KEY, btree (geocode)
    "chomoku_polygons_polygon" gist (polygon)
```

ベクトルタイルはWeb地図で表示させる想定なので、**Webメルカトル(EPSG:3857)** のジオメトリを持たせています。
ジオコードは今回の例では使いませんが、主キーでありテーブルを結合する際には必須になるカラムです。

:::note info
ポリゴン情報を最初にPostgreSQLデータベースへ登録するには、**shp2pgsql**コマンドや**ogr2ogr**コマンド等を利用することになるかと思います。
:::

## サーバーサイド（Node.js）
### 事前準備
Node.jsをインストールし、npmを使用できるようにしておきます。
npmで、今回はExpress、node-postgres、node-pg-formatをインストールしました。

```shell:expressとpg関係のインストール
npm install express pg node-pg-format
```

### 処理内容
今回のサーバーサイドのコードは、以下のようになりました。
URLのパスパラメータからは z, x, y が取得され、バウンディングボックスが決定されます。対象とする行政界ポリゴンは切り替えることを想定し、テーブル名をクエリパラメータ name で受け付けるようにしています。そしてPostgreSQLデータベースに接続し、バウンディングボックス内のベクトルタイルを生成し返すようにしています。

```javascript:server.js
const express = require("express");
const { Pool } = require("pg");
const { format } = require("node-pg-format");

const app = express();
const port = 3000;

// PostgreSQLデータベース接続設定
const pool = new Pool({
  user: "DBユーザー名",
  host: "DBホスト",
  database: "DB名",
  password: "DBパスワード",
  port: 5432,
});

app.get("/tiles/:z/:x/:y", async (req, res) => {
  const { z, x, y } = req.params;
  // クエリパラメータからテーブル名を取得する
  const { name } = req.query;

  // z, x, y, nameについては適宜バリデーションを入れて下さい

  // パスからバウンディングボックスが決定される
  const bbox = `ST_TileEnvelope($1, $2, $3)`;

  // バウンディングボックス内のベクトルタイル生成クエリ
  const query = format(
    `SELECT ST_AsMVT(q, 'mvt_polygons')
    FROM (
      SELECT ST_AsMVTGeom(polygon, ${bbox})
      FROM %I
      WHERE polygon && ${bbox}
    ) q;`,
    name
  );

  // 今回はローカル環境での試しなので、すべてのオリジンからのアクセスを許可する設定
  res.header("access-control-allow-origin", "*");

  pool.query(query, [z, x, y], (error, result) => {
    if (error) {
      res.status(400).send({ error: "Invalid Parameters" });
      return;
    }
    const tile = result.rows[0].st_asmvt;
    res.header("Content-Type", "application/vnd.mapbox-vector-tile");
    res.send(tile);
  });
});

app.listen(port, () => {
  console.log(`Server is running at http://localhost:${port}`);
});
```
### サーバー起動
```shell:サーバーの起動コマンド
node server.js
```
## フロントエンド（MapLibre）
今回のフロントエンドのコードは、以下のようになりました。

MapLibreを用いて背景地図を表示させ、ベクトルタイルをオーバーレイさせています。背景地図にはOpenStreetMapのラスター地図タイルを指定しています。ベクトルタイルに関しては、サーバーサイドで決まっている仕様に合わせ、町丁目ポリゴンのテーブル名を指定させて表示しています。
```html:index.html
<!DOCTYPE html>
<html>
  <head>
    <title>町丁目ポリゴンをベクトルタイルで表示する</title>
    <script src="https://unpkg.com/maplibre-gl@3.0.0/dist/maplibre-gl.js"></script>
    <link
      href="https://unpkg.com/maplibre-gl@3.0.0/dist/maplibre-gl.css"
      rel="stylesheet"
    />
  </head>
  <body>
    <div id="map" style="height: 90vh"></div>
    <script>
      // MapLibre地図インスタンスの初期化
      const map = new maplibregl.Map({
        container: "map",
        center: [139.66204291, 35.67260863], // 初期中心位置
        zoom: 11,
        style: {
          version: 8,
          sources: {
            // 背景地図ソースの定義（OSMを利用）
            osm: {
              type: "raster", // ラスタータイル
              tiles: ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"],
              tileSize: 256, // タイルの解像度
              maxzoom: 18,
              attribution:
                '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
            },
          },
          layers: [
            // 背景地図レイヤーの定義
            {
              id: "osm-layer",
              source: "osm",
              type: "raster",
            },
          ],
        },
      });

      // MapLibre地図ロード時の処理
      map.on("load", function () {
        // ベクトルタイルをソースに追加
        map.addSource("mvt_data", {
          type: "vector",
          // URLはserver.jsの仕様に合わせ、町丁目をテーブル名で指定
          tiles: [
            "http://localhost:3000/tiles/{z}/{x}/{y}?name=chomoku_polygons",
          ],
        });
        // ポリゴンの塗りつぶしレイヤー追加
        map.addLayer({
          id: "mvt_data",
          type: "fill",
          source: "mvt_data",
          "source-layer": "mvt_polygons", // server.jsで決めている名前を指定
          layout: {},
          paint: {
            "fill-color": "#2d2d70",
            "fill-opacity": 0.3,
          },
        });
        // ポリゴンのラインレイヤー追加
        map.addLayer({
          id: "mvt_data_line",
          type: "line",
          source: "mvt_data",
          "source-layer": "mvt_polygons", // server.jsで決めている名前を指定
          layout: {},
          paint: {
            "line-color": "#2d2d70",
            "line-width": 0.7,
          },
        });
      });
    </script>
  </body>
</html>
```

## 動作確認
全体が上手く行くと、以下のようなポリゴン表示ができるはずです。ローカル環境でのことですが、広範囲の新規ポリゴンが、GeoJSON形式と比べると速く描画されていることが分かりました。

![chomoku_polygon_mvt.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2893989/63a4ebda-e3af-f321-3d5c-0c926a1ca231.gif)

## おわりに
以前から見聞きはしていましたが、PostgreSQL/PostGISからのベクトルタイル生成は面白い技術だと改めて思いました。ポリゴンをいろいろなデータと紐づければ、「動的」の度合いも高まり、可能性も広がるような気がしました。

実際のサーバーにするに当たっては、この記事に無い課題も出てくるかと思います。SQLインジェクション対策を十分にするには、バリデーションの強化が必要かもしれません。

少しでも参考にしていただければ幸いだと思っています。最後まで読んでいただきまして、ありがとうございました。

## 参考情報
[PostGIS 3.0.0 マニュアル](https://postgis.net/docs/manual-3.0/postgis-ja.html)
[node-pg-format - npm](https://www.npmjs.com/package/node-pg-format)
[現場のプロがわかりやすく教える 位置情報エンジニア養成講座](https://www.shuwasystem.co.jp/book/9784798068923.html)