MapboxとGoogle Mapsを比べながらMapboxの本当の素晴らしさを発掘する

こんにちは、@nazomikanです。
この投稿はLifull Advent Calenderの1日目の投稿になります。

本記事はざっくり言うとmapboxすごいよって話です。
※言うまでもなく素晴らしいgoogle maps apiと比較することでmapboxを深掘りするのが目的で、決してgoogle mapsを貶めるつもりの記事ではないことをご理解ください

Mapboxとは

地図サービスです。 GoogleMapsと近い感じでJSやnative appのsdkが公開されてていい感じに開発できるやつです。
データソースはopen streatmapになります。

Google Mapsとの違い

タイルのレンダリングについて

google mapsは地図をサーバサイドレンダリングして256x256の地図タイル画像として配信してタイル上に配置していきます。

スクリーンショット 2017-11-29 1.13.38.png

一方でmapboxは地理情報のvectorタイルをpbf形式にして配信し、フロントエンドでデコードして1枚のcanvasにwebglでレンダリングしていきます。

スクリーンショット 2017-11-29 1.22.35.png

体感だとさすがにgoogle mapsの方が早く感じるけど、mapboxもさほど遅いといった印象はないです。
結構ぬるぬる動くのでこのアーキテクチャを選択してこのスピードでレンダリングするmapboxマジ半端ないってのが正直な感想です。

地理データの高品質でハイスピードなバイナリエンコーダとか自前実装しててこの辺がmapboxの速度を支える技術なのかなーって感じします

地図スタイルのカスタマイズ性

Google Mapsでは地図のスタイルを変更する際、こんな感じのスタイル情報をMapコンストラクタに渡してあげればデザインの設定が行えます

下記は公園と高速道路の色指定を行なっています。

let style = [
  {featureType: 'poi.park', elementType: 'geometry.fill', stylers: [{color: '#ed6103'}]},
  {featureType: 'road.highway', elementType: 'geometry', stylers: [{color: '#006699'}]},
];

let map = new google.maps.Map(document.getElementById('map'), {
  center: {lat: 35.685175, lng: 139.7528},
  zoom: 14,
  styles: style
});
スクリーンショット 2017-11-29 1.57.37.png

https://jsfiddle.net/nazomikan/xv1cw0yn/

地物の種類ははじめから用意されていて(poi.parkやroad.highwayなど)、それのラベルやジオメトリに対してスタイルを指定していく感じのインタフェースになっています。

一方、mapbox側では地物そのものの選択から行うことができます。
デフォルトで用意されているタイルセットは現時点で

  • satellite
  • streets v7
  • terrain v2
  • traffic v1

の4つになります (以下はmapboxの管理画面)

スクリーンショット 2017-11-29 2.19.47.png
  • satelliteは衛星写真データ(ラスタ型)
  • streetsはosmを基にした地理データ(ベクタ型) --- こいつをメインに使うケースが多い(道路情報や建物情報など)
  • terrainは山形データ(ベクタ型)
  • trafficは渋滞情報データ(ベクタ型)

基本的にこれらのタイルセットに含まれる地物データ(自分で作ることもできる)をもとにして、mapbox studioとよばれるwebエディタでそれを編集して(組み合わせて)スタイルを作成し、javascript api等からそれを呼び出す形となる(mapbox studioで作らなくてもcliツールやrest apiからでも編集は可能ではある)

たとえば以下はstreetsタイルセットに含まれる地物のroad(道路),building(建物),admin_contry(国境),wayer(水域)...と、trafficタイルセットに含まれる地物のtraffic(渋滞)をそれぞれレイヤ化し、重ね合わせたスタイルをmapbox studioで作ってるところです(交通情報の多さに応じて色分けもしている)

スクリーンショット 2017-11-29 2.39.11.png

このスタイルを公開して以下のように地図APIから呼び出すのが主流

http://jsfiddle.net/nazomikan/by2kr5z8/

mapboxgl.accessToken = 'xxxxxx'; // 公開用のaccess token
let map = new mapboxgl.Map({
  container: 'map',
  style: 'mapbox://styles/nazomikan/cjajwxkc801rm2rnthoypc6tx',
  center: [139.752993, 35.685192],
  zoom: 12
});

mapboxではstudioなどから地物一つ一つのプロパティ情報(geojsonのpropertyと同じと思ってください)をもとにスタイルを適用したりなんかもできます。
たとえばtrafficにはcongestion(渋滞の度合いを示すもの)というプロパティが情報があり、low, haveyなどの値をとるので、lowの時は青色、haveyの時は赤色などといった柔軟なスタイルの指定が可能になります。

デフォルトのgeometryに対してgoogle mapsではgeometry種別ごとのおおまかな指定になるのに対し、非常に柔軟な側面を持っていることがわかります。

こうしたmapbox studioで作られた地理データはタイルごとに分割されたpbfデータに変換され、javascript api(mapbox-gl.js)などで呼び出されます。

データのマッピングについて

地図の上にアジアの国境情報をマッピングしたいと思います。
データはmyjson.comにあげておきました
https://api.myjson.com/bins/epixv

データ形式はこんな感じ

{
  "type":"FeatureCollection",
  "features":[
    {
      "type":"Feature",
      "properties":{
        "name":"Azerbaijan",
        "pop_est":8238672
      },
      "geometry":[...]
    }, ...
  ]
}

(propertyのnameは国名、pop_estは人口が入ってます)
これをそのままgoogle mapsでマッピングするとこんな感じになります。

http://jsfiddle.net/nazomikan/ymvc9ry4/

let map = new google.maps.Map(document.getElementById('map'), {
  center: {
    lat: 35.685175,
    lng: 139.7528
  },
  gestureHandling: 'greedy',
  zoom: 2
});
map.data.loadGeoJson('https://api.myjson.com/bins/epixv')
スクリーンショット 2017-11-30 23.43.19.png

ネットワーク環境によってはこれでも十分に高速に表示されたかもしれませんが、コードを見てわかる通り、ブラウザが一旦myjson.comにデータを取りに行って、それをgoogle mapsに橋渡しするというフローになるため、google mapsがいかにそれを高速に処理したところでそれに至るまでの過程でデータサイズによっては非常にもたついてしまう結果となります。
(ほんとはもっとでかいデータでやりたかったけど僕のwifi死にそうなのでこの程度にしました...)

また、巨大なLineString型のデータをあげる時なども、hover/click判定のために一旦メモリ上に全coordinateを保持するため、路線図などを一気に展開すると非常に重くなったりします。

一方でmapboxはというと、こんな感じのコードになります。
http://jsfiddle.net/nazomikan/23w8ee8b/

mapboxgl.accessToken = 'xxxxxx'; // 公開用のaccess token

let map = new mapboxgl.Map({
  container: 'map',
  style: 'mapbox://styles/mapbox/streets-v9',
  center: [139.752993, 35.685192],
  zoom: 2,
});

map.on('load', function() {
  map.addLayer({
    "id": "asia",
    "type": "fill",
    "source": {
      type: 'geojson',
      data: 'https://api.myjson.com/bins/epixv'
    }
  });
});

見てわかる通り、これだと前述のGoogle Mapsと同じ問題を抱えています。
しかしmapboxには自分で作った地物データをタイル化してmapboxにアップロードしてstyleの一部として利用するという最もスペシャルな機能があります。

結構ぽちぽちやるので手順を1分ほどの動画にしてみました。

(スクリーンショット 2017-12-01 3.51.17.png

ここで作ったスタイルのurlをmapbox.gl.jsでロードするとこんな感じの実装にかわります。

http://jsfiddle.net/nazomikan/foj4c26t/

mapboxgl.accessToken = 'xxxxxx'; // 公開用のaccess token

let map = new mapboxgl.Map({
  container: 'map',
  style: 'mapbox://styles/nazomikan/cjammcpe8ds7g2rqtqx1odeg8',
  center: [139.752993, 35.685192],
  zoom: 2,
});

地物データをアップロードしてタイル化することで地図表示時にタイル単位でこの地物データを扱えるようになり、それがmapboxサーバから他の地物データと一緒にpbf形式でフロントに送られてwebglでレンダリングされるという流れになります。

こうすることでわざわざブラウザが一度geojsonを取得する必要がなくなるためgeojsonを読み込むリードタイムがなくなります。

また、タイル単位でデータが送られてくるため、一度に多くのデータを展開することなくレンダリングに到れます。

mapbox側にタイル単位のデータとして地物をアップロードできることは本当に素晴らしいソリューションで大規模で精巧なレイヤを考えれば考えるほど価値があるものなります。

さらにこれらの作業はrest apiを通して行うこともできるのでなんらかのバッチで地物データのgeojsonを作って、それをmapbox側にあげて、スタイルに適用までの流れを一連のプログラムで行うこともできます。

npm: mapbox

参考コード
let MapboxClient = require('mapbox')
  , client = new MapboxClient('YOUR_ACCESS_TOKEN')
  ;

client.createdataset({name: 'asia polygon', description: 'アジアの国境'})
  .then(dataset => client.insertFeature(geojson, dataset.id)
// ...

マッピングデータの色付け

さきほどのgeojsonには人口数が含まれていました。
人口数に基づいて国を色付けしていきたいと思います。

google mapsだとsetStyleメソッドにハンドラ定義すること一つ一つのfeature(今回だと国境)に対してスタイルが適応できます。
getPropertyでpropertyにアクセスし、人口数(pop_est)にアクセスして5000万人未満を青、それ以上を赤に色分けします。

http://jsfiddle.net/nazomikan/7mtxmh2c/

let map = new google.maps.Map(document.getElementById('map'), {
  center: {lat: 31.680081, lng: 104.902885},
  gestureHandling: 'greedy',
  zoom: 2
});

map.data.loadGeoJson('https://api.myjson.com/bins/epixv')

map.data.setStyle(function(feature) {
  let pop = feature.getProperty('pop_est')
    , color = +pop > 50000000 ? 'red' : 'blue';
    ;

  return {fillColor: color};
});
スクリーンショット 2017-12-01 1.32.40.png

mapboxも同様に行うことができるのですが、特性を生かしてまた、mapbox studioから色分け済みのスタイルを作って呼び出す形にしたいと思います。

mapboxのstyle layerには各地物データに含まれるpropertyを基に色やボーダーなどのスタイル情報を決定するProperty Functionという機能があります。

スクリーンショット 2017-12-01 1.42.05.png

これを利用することでmapbox側でも人口ごとの色分けが可能になります。
しかもあらかじめそれを行うことができるなんてとても素晴らしいことですね。
studioでstyleが作成できたらjs側はそのスタイルを呼ぶだけです。

http://jsfiddle.net/nazomikan/5zbhw5dx/

mapboxgl.accessToken = 'xxxxxx'; // 公開用のaccess token

let map = new mapboxgl.Map({
  container: 'map',
  style: 'mapbox://styles/nazomikan/cjampl8n7dv8n2rqt19v22b6h',
  center: [104.902885, 31.680081],
  zoom: 2,
});

データのラベル

今度は国境データに国名を表示させていきたいと思います。
この辺からgoogle maps apiでは厳しい領域となってきます。

google mapsでテキストを地図上にマッピングするにはInfoBoxを利用したり、canvasでテキスト画像をつくってそれをマーカー画像に設定したり、OverlayViewでDOMを生成して地図にマッピングするかくらいしか選択肢がなくなってきます。

ここでは大掛かりな実装が面倒なのでInfoBoxを使いました。

https://jsfiddle.net/nazomikan/asxvysye/

let map = new google.maps.Map(document.getElementById('map'), {
  center: {lat: 31.680081, lng: 104.902885},
  gestureHandling: 'greedy',
  zoom: 2
});

map.data.loadGeoJson('https://api.myjson.com/bins/epixv')

map.data.setStyle(function(feature) {
  let pop = feature.getProperty('pop_est')
    , color = +pop > 50000000 ? 'red' : 'blue';
    ;

  return {fillColor: color};
});
map.data.addListener('addfeature', function(evt) {
  let feature = evt.feature
    , geometry = feature.getGeometry()
    , type = geometry.getType()
    , bounds = new google.maps.LatLngBounds()
    , polygon
    , infobox
    , label
    ;
  polygon = (type === 'MultiPolygon') ? geometry.getAt(0) : geometry;
  polygon.forEachLatLng(latlng => bounds.extend(latlng));
  label = document.createElement('div')
  label.innerText = feature.getProperty('name');

  infobox = new InfoBox({
    content: label,
    boxStyle: {
      border: "none",
      textAlign: "center",
      fontSize: 20,
      width: "50px"
    },
    disableAutoPan: true,
    pixelOffset: new google.maps.Size(-25, 0),
    position: bounds.getCenter(),
    closeBoxURL: "",
    isHidden: false,
    pane: "mapPane",
    enableEventPropagation: true
  });

  infobox.open(map);
})
スクリーンショット 2017-12-01 2.33.08.png

ぼちぼちつらいですね

DOMを生成して地図にマッピングする場合は縮尺が変わった時などに描画位置の再計算(drawメソッドのコール)がマッピング物分実行されるためズームイン/アウト時に高負荷が発生します。
また、polygon内にプロットするならともかくLinStringなどの線分のもつカーブなどに添わせるようなテキストなどは独自実装を鬼のようにがんばらないと無理になります。

mapboxであればtilesetをstyleで呼び出す時に形式をsymbolとすればlabel的なデータも載せることができます。
そしてまた、スタイルができあがればjsではそれを呼ぶだけ...

http://jsfiddle.net/nazomikan/10b2sr5c/

mapboxgl.accessToken = 'xxxxxx'; // 公開用のaccess token

let map = new mapboxgl.Map({
  container: 'map',
  style: 'mapbox://styles/nazomikan/cjamro72qa6xa2socrp8snbrr',
  center: [104.902885, 31.680081],
  zoom: 2,
});
スクリーンショット 2017-12-01 2.43.11.png

両者ともにmultipolygonラベルがうざったいのが玉にキズですが、とにかくmapboxはコード量が全くあがらないのが素晴らしいです。

またLineStringに対するラベルに関しても曲線に添うような表現も可能です。

{
  "type": "Feature",
  "properties": {
    "name": "これはとってもすごいラベル"
  },
  "geometry": {
    "coordinates": [
      [ 139.726767, 35.657764 ],
      [ 139.694455, 35.708975 ],
      [ 139.771689, 35.719853 ],
      [ 139.789028, 35.697455 ],
      [ 139.870991, 35.704495 ],
      [ 139.897787, 35.751839 ],
      [ 139.830798, 35.779977 ]
    ],
    "type": "LineString"
  }
}

こんな線分データを地図に表示しname propertyをlabelにしてみましょう。
線分データ用のラベルを選択して、線に対するテキストの位置(線分の上、下、真上など)を指定したり、背面のぼかし距離・色を指定したり、設定できる項目はたくさんあります。

スクリーンショット 2017-12-01 3.15.05.png

https://jsfiddle.net/nazomikan/5v62wpvn/

mapboxgl.accessToken = 'pk.eyJ1IjoibmF6b21pa2FuIiwiYSI6ImNpem9hZHlpNjAydTAyd3J2eXp4b2R2MzUifQ.GRdOzl4ieyG4ph220RqjhQ';

var map = new mapboxgl.Map({
  container: 'map',
  style: 'mapbox://styles/nazomikan/cjams61dndy1w2sp67d1bapdy',
  center: [139.850076, 35.774705],
  zoom: 13,
});
スクリーンショット 2017-12-01 3.27.41.png

最後に

いかがだったでしょう。

Google Maps APIももちろん素晴らしいですが、Mapboxにも十分なアドバンテージがあると感じられたと思います。

表を軽くするために画像を配信する方法を選んだGoogleと柔軟性を極限まで高められる余地のあるMapboxの二つとも十分に理解しておいて損はないと思います。

また、技術的な差だけでなく、契約形態的にも、Google Mapsは非公開ページでは利用できないことが多いのですが、mapboxは有償プランであれば非公開ページでの利用もできるためそういった使い分けで利用する機会もあるかもしれません。

また地物データにおいても、dataset, tilesetなどは有償プランであればprivate化することもできるのでクローズドにしておきたいときにも有益です。

以上で終わりになります。

二日目の方がんばってください:)