🐇

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

2023/11/15に公開

はじめに

現在、Software Designで連載中の「位置情報エンジニアリングのすすめ」を毎月楽しく拝読しております。第4回「Web地図を作ってみる(前編)」ではMapLibre GL JSを用いて地理情報を表示していました。記事にも記載されている通り、MapLibre GL JSはMapbox GL JSからフォークされた地図ライブラリなので、使い方は非常に似通っています。そこで、ここではMapbox GL JSを用いて、記事の中で紹介された国土数値情報のデータを表示するのを試したいと思います。

Mapbox GL JSとMapLibreの違い

MapLibre GL JSはMapbox GL JS v1.13.0からフォークして作られました。また、Mapbox GL JSはv1.13.xを最後にv1系の開発を終了し、v2へとバージョンアップしました。現在のMapbox GL JSの最新の安定バージョンはv2.15.0です。さらに、MapLibre GL JSMapbox GL JS v2ともにMapbox GL JS v1.xへの後方互換性があることからMapLibre GL JSとMapbox GL JS v2にも互換性があります。つまり、MapLibreを用いて開発した地図アプリケーションは、概ねMapbox GL JS v2でも動くことが期待できます。

Mapbox GL JS v2とMapLibre GL JSの違い

Mapbox GL JS v2では以下の新機能が追加されました。これらの機能はMapLibre GL JSには含まれません。

  • Globe View: ズームレベルが低いとき、Webメルカトルではなく地球を球体として表現します
  • Atmospheric Styling: 空や宇宙を表現します
  • 3D terrain: 地面の凹凸を3Dで表現します
  • Adaptive projections: 複数のプロジェクション(等積図法等)がサポートされます
  • FreeCamera API: より柔軟なカメラコントロールができます

また、Mapbox GL JSを使う場合のメリットは以下のとおりです.

Mapbox GL JSのライセンス

記事表1ではMapLibre GL JSについて「OSS対応をやめたMapbox GL JSのコードをフォークし」とあります。実際、Mapbox GL JS v1は3条項BSDライセンスですが、v2以降はTOSライセンスとなります。TOSとはTerms of serviceで、ソフトウェアライセンスではなく利用規約です。規約で改変等が禁止されているため、OSSライセンスではありません。

また、v1ではスタイル、ソースがMapboxのサーバにホストされている場合のみ課金対象でした。しかし、v2からは場所によらず課金対象となります。そのため、アクセストークンの取得・設定が必要になります。

これらの変更に関する発表に対する反響は大きく、結果としてBSDライセンスであるv1.13.0からMapLibreが誕生しました。発表およびそれに対する反響は以下をご参照ください。

https://github.com/mapbox/mapbox-gl-js/issues/10162

国土数値情報のデータの収集および加工

それでは記事にしたがってデータを作成します。

ダウンロード

福祉施設のデータは以下のリンクから「北海道」をダウンロードします。令和3年で良いと思います。
https://nlftp.mlit.go.jp/ksj/gml/datalist/KsjTmplt-P14-v2_1.html

学校のデータは以下のリンクから「全国」をダウンロードします。令和3年で良いと思います。
https://nlftp.mlit.go.jp/ksj/gml/datalist/KsjTmplt-P29-v2_0.html

ShapefileをGeoJSONに変換

ダウンロードしたzipを展開します。

福祉施設

展開したフォルダ(P14-21_01_GML)の中のSHPフォルダにP14-21_01.shpがあります。

% ogrinfo P14-21_01.shp
INFO: Open of `P14-21_01.shp'
      using driver `ESRI Shapefile' successful.
1: P14-21_01 (Point)

GeoJSONに変換します

 ogr2ogr -f geojson P14-21_01.geojson P14-21_01.shp -sql "SELECT P14_008 as name, P14_004 as address, P14_005 as school_type, P14_003 as gyosei_code FROM \"P14-21_01\" WHERE P14_005='05'"

P14_004といった属性情報は国土数値情報のサイトに記載があります。

学校

展開したフォルダ(P29-21_GML)にP29-21.shpがあります。

% ogrinfo P29-21.shp
INFO: Open of `P29-21.shp'
      using driver `ESRI Shapefile' successful.
1: P29-21 (Point)

GeoJSONに変換します

% ogr2ogr -f geojson P29-21.geojson P29-21.shp -sql 'SELECT P29_004 as name, P29_005 as address, P29_003 as school_type, P29_001 as gyosei_code FROM "P29-21" WHERE P29_003 IN (16011,16001,16002,16003,16004,16005)'

P29_004といった属性情報は国土数値情報のサイトに記載があります。

マージ

展開したフォルダと同じ深さのフォルダに移動し、以下のコマンドを実行します。これでmerge.geojsonに両方のデータが入った状態になります。

% ogr2ogr -f geojson -update -append merge.geojson P14-21_01_GML/SHP/P14-21_01.geojson -nln "merge"
% ogr2ogr -f geojson -update -append merge.geojson P29-21_GML/P29-21.geojson -nln "merge"

コード

以下が、記事のコードをMapbox GL JS対応にしたものです。

<head>
<meta charset="utf-8">
<title>サンプル</title>
<meta name="viewport" content="initial-scale=1,maximum-scale=1,user-scalable=no">
<link href="https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.css" rel="stylesheet">
<script src="https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.js"></script>
<style>
body { margin: 0; padding: 0; }
#map { position: absolute; top: 0; bottom: 0; width: 100%; }
</style>
</head>
<body>
<div id="map"></div>
<script>
    mapboxgl.accessToken = 'YOUR_PUBLIC_TOKEN';
    const map = new mapboxgl.Map({
        container: 'map',
        style: 'mapbox://styles/mapbox/light-v11',
        center: [139.7765214, 35.7123457],
        zoom: 15,
        pitch: 0,
    });

    map.on('load', () => {
        map.addSource('source_point', {
            type: 'geojson',
            data: './merge.geojson',
        });

        map.addLayer({
            id: 'point_sample',
            type: 'circle',
            source: 'source_point',
            layout: {},
            paint: {
              'circle-color': [
                'case',
                ['==', ['get', 'school_type'], '05'], 'olive',
                ['==', ['get', 'school_type'], '16001'], 'red',
                ['==', ['get', 'school_type'], '16002'], 'orange',
                ['==', ['get', 'school_type'], '16003'], 'purple',
                ['==', ['get', 'school_type'], '16004'], 'blue',
                ['==', ['get', 'school_type'], '16005'], 'salmon',
                ['==', ['get', 'school_type'], '16011'], 'cyan',
                '#F00000'
              ],
              'circle-radius': 10
            }
        });
    });

    map.on('click', 'point_sample', (e) => {
        const coordinates = e.features[0].geometry.coordinates.slice();
        const name = e.features[0].properties.name;
        const address = e.features[0].properties.address;

        new mapboxgl.Popup()
            .setLngLat(coordinates)
            .setHTML(name + ':' + address)
            .addTo(map);
    });
</script>

</body>
</html>

記事との違いは以下の2点です。

  • 名前空間の変更: maplibreglmapboxglに変更
  • パブリックトークンの指定: mapboxgl.accessToken = 'YOUR_PUBLIC_TOKEN';の追加

これら以外はそのままなので、高い互換性が保たれていることがわかります。

処理内容

それでは処理内容を見ていきましょう。

地図の作成

ここではMapboxが提供しているLight v11スタイルを使用しました。もちろん、style: 'https://gsi-cyberjapan.github.io/gsivectortile-mapbox-gl-js/pale.json'を指定しても動作します。

const map = new mapboxgl.Map({
    container: 'map',
    style: 'mapbox://styles/mapbox/light-v11',
    center: [139.7765214, 35.7123457],
    zoom: 15,
    pitch: 0,
});

loadイベント

ソースとレイヤーは、スタイルの読み込みが終わってから作成する必要があります(ソース・レイヤーはスタイルに管理される要素であるため)。そこで、map.on('load', () => {/*ここ*/});のように、スタイルのロード完了時に発火するloadイベントのコールバック関数の中でソースおよびレイヤーの作成を行います。

ソースの作成

Map#addSourceでソースを作成します。第1引数がソースID(自分で決めます)、第2引数がソースの中身になります。ここではGeoJSONなので、typegeojsondataがファイルパスとなります。

map.addSource('source_point', {
    type: 'geojson',
    data: './merge.geojson',
});

レイヤーの作成

Map#addLayerでレイヤーを作成します。第1引数にオブジェクトとしてレイヤーの情報を与えます。idはレイヤーID(自分で決めます)、typeはレイヤーのタイプ、sourceは使用するソースID(ここでは直前で作成したsource_point)です。layoutpaintはレイヤーに表示する方法や、色・形などを指定します。第2引数にレイヤーIDを指定すると、そのレイヤーの下にレイヤーを作成することができます。

map.addLayer({
    id: 'point_sample',
    type: 'circle',
    source: 'source_point',
    layout: {},
    paint: {
      /* 後述 */
    }
});

paintの中身を見ていきます。layoutpaintExpressionsという記法で記述します。circleレイヤーで設定できる項目はここに記載されています。

circle-colorは円の色を指定します。case['case', 条件1, 処理1, 条件2, 処理2, ... , 条件N, 処理N, フォールバック]の用に記述します。if文のようなもので、条件1に合致したら処理1が評価されます。どの条件にも合わない場合はフォールバックが評価されます。

1つ目の条件を見てみましょう。['==', ['get', 'school_type'], '05']とあります。まず、==は後ろの2個の引数が一致する際にtrueを返します。1つ目の引数は['get', 'school_type']ですが、getはソースから第1引数(ここではschool_type)で指定されたプロパティの値を取得します。全体としてはschool_typeの値を取得し、それが'05'と一致する場合はolive色として評価されます。

それ以降の条件も同じです。05は福祉施設のデータで「児童福祉施設等」、16001は学校のデータで「小学校」(以下同)です。

'circle-radius'は円の半径を指定します。

paint: {
  'circle-color': [
    'case',
    ['==', ['get', 'school_type'], '05'], 'olive',
    ['==', ['get', 'school_type'], '16001'], 'red',
    ['==', ['get', 'school_type'], '16002'], 'orange',
    ['==', ['get', 'school_type'], '16003'], 'purple',
    ['==', ['get', 'school_type'], '16004'], 'blue',
    ['==', ['get', 'school_type'], '16005'], 'salmon',
    ['==', ['get', 'school_type'], '16011'], 'cyan',
    '#F00000'
  ],
  'circle-radius': 10
}

これで学校の種別ごとに色分けする方法がわかりました。ただし、円の半径がズームレベルによらず一定であるため、ズームレベルを小さくすると円が大量に重なって見にくくなっています。連載第5回ではこれを見やすくするようです。

クリックイベントの処理

clickイベントもloadイベントと同じようにMap#onで記述します。違いとしては、第2引数にレイヤーIDを指定できる点です。レイヤーIDを指定すると、そのレイヤーのフィーチャーをクリックしたときにだけイベントが発火します。ここではpoint_sampleを指定しているので、学校を表現した円をクリックしたときにだけイベントが発火します。

コールバック関数の引数eにはフィーチャーの配列が入っています。そこから座標、名前、住所情報を取得し、Popupで表示します。

map.on('click', 'point_sample', (e) => {
    const coordinates = e.features[0].geometry.coordinates.slice();
    const name = e.features[0].properties.name;
    const address = e.features[0].properties.address;

    new mapboxgl.Popup()
        .setLngLat(coordinates)
        .setHTML(name + ':' + address)
        .addTo(map);
});

Popupの使い方は以下の記事もご参照ください。

結果

結果は以下のとおりです。

MapLibre GL JSの場合は以下のとおりです。

まとめ

MapLibre GL JS用に作成したアプリケーションが、ほとんど変更せずにMapbox GL JSで使えることがわかりました。

続き

https://zenn.dev/mapbox_japan/articles/4b6ed508f2e24f

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

Discussion