🐰

「Software Design 2023年12月号 位置情報エンジニアリングのすすめ 第5回」をMapbox GL JSで実装してみる

2023/12/14に公開

はじめに

前回に引き続き、Software Designで連載中の「位置情報エンジニアリングのすすめ」をMapbox GL JSで実装してみます。

地図の表示

環境構築

紙面の通り、Viteをインストールします。

% npm create vite@latest webmap
Need to install the following packages:
  create-vite@5.0.0
Ok to proceed? (y) y
✔ Select a framework: › Vanilla
✔ Select a variant: › TypeScript

Scaffolding project in /Users/yochi/Downloads/20231130/webmap...

Done. Now run:

  cd webmap
  npm install
  npm run dev

上記で指示された通りコマンドを打っていきます。

% cd webmap
% npm install
% npm run dev

Mapbox GL JSのインストール

下記コマンドでMapbox GL JSをインストールします。

% npm install mapbox-gl

MapbLibre GL JSはTypeScriptで記述されているそうですが、残念ながらMapbox GL JSはJavaScriptです。そこで、以下のように型定義ファイルもインストールします。

% npm install @types/mapbox-gl

地図の表示

HTMLは紙面の通り記述します。

<!doctype html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Mapbox GL JSでWeb地図</title>
  </head>
  <body style="margin: 0;">
    <div id="map" style="height: 100vh;"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

JavaScriptは以下のとおりです。違いはライブラリの名称と、Mapオブジェクトのインスタンス化の際にオプションとしてaccessTokenを設定することです。

import { Map } from 'mapbox-gl';
import 'mapbox-gl/dist/mapbox-gl.css'

const map = new Map({
    accessToken:'YOUR_PUBLIC_TOKEN',
    container: 'map',
    style: 'mapbox://styles/mapbox/streets-v12',
    center: [143.95, 43.65],
    zoom: 6,
});

ポイントを表示

データの取得

以下からHokkaidoの.shp.zipをダウンロードします。

https://download.geofabrik.de/asia/japan.html

紙面の通りGeoJSONに変換します。

% ogr2ogr -f GeoJSON poi.json gis_osm_pois_free_1.shp

データの表示

変換したGeoJSONを表示します。紙面ではMapオブジェクトのインスタンス化の際に直接Styleとして記述していました。今回、streets-v12を使っている都合上、Map#addSourceMap#addLayerで追加します。コードは以下のようになります。

import { Map } from 'mapbox-gl';
import 'mapbox-gl/dist/mapbox-gl.css'
import poiGeojson from './poi.json';

const map = new Map({
    accessToken:'YOUR_PUBLIC_TOKEN',
    container: 'map',
    style: 'mapbox://styles/mapbox/streets-v12',
    center: [143.95, 43.65],
    zoom: 6,
});

map.on('style.load', () => {
    map.addSource('poi', {
        type: 'geojson',
        data: poiGeojson,
    });

    map.addLayer({
        id: 'poi',
        type: 'circle',
        source: 'poi',
        paint: {
            'circle-radius': 6,
            'circle-color': '#ff0000',
        },
    });
});

クラスタ化

クラスタの使い方

クラスタは複数のPointデータを集約し、一つのPointデータとしてソースに追加されます。そのため、まずaddSourceでクラスターであることを宣言します。

map.addSource('poi', {
    type: 'geojson',
    data: poiGeojson as any,
    cluster: true, //ここ
    clusterMaxZoom: 15,
});

あとはデータとして存在していることになるのでレイヤーにソースを使用することで表示できます。

以下のサンプルコードが参考になります。
https://docs.mapbox.com/mapbox-gl-js/example/cluster/

コード

クラスタ化も基本的にMapLibre GL JSと同じコードで動きます。

import { Map } from 'mapbox-gl';
import 'mapbox-gl/dist/mapbox-gl.css'
import poiGeojson from './poi.json';

const map = new Map({
    accessToken:'YOUR_PUBLIC_TOKEN',
    container: 'map',
    style: 'mapbox://styles/mapbox/streets-v12',
    center: [143.95, 43.65],
    zoom: 6,
});

map.on('style.load', () => {
    map.addSource('poi', {
        type: 'geojson',
        data: poiGeojson as any,
        cluster: true,
        clusterMaxZoom: 15,
    });

    map.addLayer({
        id: 'poi',
        type: 'circle',
        source: 'poi',
        paint: {
            'circle-radius': [
                'interpolate',
                ['linear'],
                ['get', 'point_count'],
                1, 10,
                500, 50,
                2000, 70,
            ],
            'circle-color': '#aaaaff',
            'circle-stroke-color': '#000000',
            'circle-stroke-width': 1,
            'circle-opacity': 0.8,
        },
    });
});

ここでは以下のcircle-radiusのExpressionsを見ていきます。

'circle-radius': [
    'interpolate',
    ['linear'],
    ['get', 'point_count'],
    1, 10,
    500, 50,
    2000, 70,
],

interpolateは補間です。第1引数は補間の方法です。ここではlinearが指定されているので線形補間です。第2引数が入力値です。ここではgetを使用してpoint_countプロパティの値を取得し入力値として使用します。それ以降の引数は入力値,出力する値のペアとなります。例えば1,10point_count1の時、円の半径が10となります。また、線形補間となるので、point_countの値が1500の時、半径は1050の間で線形補間されます。

結果は以下のようになります。

https://sd202312.netlify.app/cluster

建物データ、道路ラインをベクタータイルに変換

紙面では建物ポリゴンを表示してから道路ラインも表示するというステップを踏んでいますが、ここではまとめてやってしまいます。GeoJSONSeqと指定すると出力形式がndjsonになります。ndjsonでは1行1フィーチャーになります。

% ogr2ogr -f GeoJSONSeq building.jsonl gis_osm_buildings_a_free_1.shp
% ogr2ogr -f GeoJSONSeq road.jsonl gis_osm_roads_free_1.shp

次にtippecanoeでタイルセットに変換します。並列処理を行う-Pオプションがline-delimited JSON (ndjson)のときのみ使用可能です。そのため、先ほどGeoJSONSeqを指定しました。また、-Mオプションは一つのタイルサイズの上限値です。デフォルトでは500KBなので、5MBは相当大きい値です。

% tippecanoe -e tiles -pC -pf -M 5000000 -P -L road:road.jsonl -L building:building.jsonl

建物データ、道路ラインの表示

Mapbox GL JSに関する部分およびstyle.loadに関する部分以外は紙面の通りコードを書きます。fill-extrusionは高さ情報を用いて建物等を3D表示できるレイヤーです。

import { Map } from 'mapbox-gl';
import 'mapbox-gl/dist/mapbox-gl.css'
import poiGeojson from './poi.json';

const map = new Map({
    accessToken:'YOUR_PUBLIC_TOKEN',
    container: 'map',
    style: 'mapbox://styles/mapbox/streets-v12',
    center: [143.95, 43.65],
    zoom: 6,
});

map.on('style.load', () => {
    map.addSource('vectortile', {
        type: 'vector',
        tiles: [
            `${window.location.origin}/tiles/{z}/{x}/{y}.pbf`,
        ],
        maxzoom: 14,
    });

    map.addLayer({
        id: 'building2',
        type: 'fill-extrusion',
        source: 'vectortile',
        'source-layer': 'building',
        paint: {
            'fill-extrusion-color': '#a66',
            'fill-extrusion-height': 10,
            'fill-extrusion-opacity': 0.6,
        },
   });

    map.addLayer({
        id: 'road2',
        type: 'line',
        source: 'vectortile',
        'source-layer': 'road',
        paint: {
            'line-color': [
                'case',
                ['==', ['get', 'fclass'], 'primary'], '#f00',
                ['==', ['get', 'fclass'], 'secondary'], '#ff0',
                ['==', ['get', 'fclass'], 'teriary'], '#0a0',
                ['==', ['get', 'fclass'], 'residential'], '#00f',
                '#000',
            ],
            'line-width': [
                'case',
                ['==', ['get', 'fclass'], 'primary'], 10,
                ['==', ['get', 'fclass'], 'secondary'], 8,
                ['==', ['get', 'fclass'], 'teriary'], 6,
                ['==', ['get', 'fclass'], 'residential'], 4,
                2,
            ],
        },
   });
});

結果は以下のようになります。

https://sd202312.netlify.app/building

まとめ

今回も、MapLibre GL JSとほとんど同じコードで動くことがわかりました。

Appendix A. 第4回をクラスタ化

せっかくなので第4回で作成したものをクラスタ化しました。

複数種類のデータ(school_type)が一つのGeoJSONに含まれているため、fetchでGeoJSONをダウンロードしてからaddSourceの際にデータをフィルタリングしています。

    const response = await fetch("https://gist.githubusercontent.com/OttyLab/f4526ddf444b8f4add296ad337bcc601/raw/58732f3b6b3479d2f50b015fc1167dcfaeb238fe/merge.geojson");
    const data = await response.json();

    schools.forEach(s => {
      map.addSource(`source_point_${s.type}`, {
        type: "geojson",
        data,
        filter: ["==", ["get", "school_type"], s.type],
        cluster: true,
        clusterMaxZoom: 15
      });

Appendix B. tippecanoe

tippecanoeはもともとMapboxで開発されていました。しかし、メインで開発を行っていたエンジニアが転職したため、現在はFeltで開発が継続されています。brewでインストールした場合、feltの方(つまり最新)が使用されます。

Appendix C. consoleに表示されるエラー

Vite v5 + Mapbox GL JSではconsoleに以下のエラーが表示されます。

Uncaught Error: Unimplemented type: 4
    at lm.skip (3519c730-a3f6-4996-9e1f-531464dd0686:9404:21)
    at lm.readFields (3519c730-a3f6-4996-9e1f-531464dd0686:9266:75)
    at new Gf.VectorTile (3519c730-a3f6-4996-9e1f-531464dd0686:9131:28)
    at 3519c730-a3f6-4996-9e1f-531464dd0686:27067:145
    at Object.fn (3519c730-a3f6-4996-9e1f-531464dd0686:17358:17)
    at fw.process (3519c730-a3f6-4996-9e1f-531464dd0686:16857:18)
    at MessageChannel._channel.port2.onmessage (3519c730-a3f6-4996-9e1f-531464dd0686:16812:45)

これは存在しないタイルを取得しようとした際に、Viteが404エラーではなくindex.htmlを返してくることに起因しています。Mapbox GL JSはダウンロードしたデータをベクタータイルとしてパースしようとしてエラーが発生しています。ちなみに、Vite v4では404エラーとなります。MapLibre GL JSではエラーは表示されないので、例外処理を行っていると予想されます。

GitHubで編集を提案
マップボックス・ジャパン合同会社

Discussion