Google Maps API → OpenLayers API (V3以降)

Google Maps APIOpenLayers API に書き換えるとこうなる、という話です。

以前、こんな投稿をしたのですが、OpenLayers のバージョンは V2 です。
そのときは既に V3 が出ていましたし、この記事を書いている時点では、OpenLayersV5 が出ようか、という状況です。
というわけで、そのときの記事を OpenLayers V3 以降に対応するコードでリライトしました。
# コードは、記事を書いている時点の最新版 V4.6.5 で確認しました

では、ここから本題です。


id:ykhpno1 さんの、この質問。

その前までの一連の質問で、Google Maps API を使ってある程度の形になっていたのだけれど、アクセス数の制限があったりするから OpenStreet Map に変更したいという お話。
Google Maps API のここは、OpenLayers API ではこう書く、みたいな感じページが意外と見つからなかったので、備忘を兼ねて。

長いよ

まずは、ソース

Google Map
<div id="map" style="width: 99%; height: 650px;"></div>
<script type="text/javascript">
(function() {

  var posts = [];
  posts.push({
    lat: (緯度:実数),
    lng: (経度:実数),
    name: (店の分類:文字列),
    link: (店のページの URL:文字列),
    title: (店の名前:文字列),
    cat_id: (カテゴリ:整数),
  });
  // データは、ここまで

  // ページの読み込みが終わったら、地図表示の処理を行う
  google.maps.event.addDomListener(window, 'load', function() {   // ■(B)
    // カテゴリーID とマーカー画像の対応

    var icon_map = {
      2: "http://maps.google.co.jp/mapfiles/ms/icons/red-dot.png",
      3: "http://maps.google.co.jp/mapfiles/ms/icons/blue-dot.png",
      4: "http://maps.google.co.jp/mapfiles/ms/icons/yellow-dot.png",
      5: "http://maps.google.co.jp/mapfiles/ms/icons/green-dot.png",
    };

    var mapdiv = document.getElementById('map');    // ■(C)
    var map = new google.maps.Map(mapdiv, {   // ■(A)
      mapTypeId: google.maps.MapTypeId.ROADMAP,
      scaleControl: true      // ■(E)
    });

    // マーカーが含まれる範囲の Bounds と、それに合わせたズーミング
    var bounds = new google.maps.LatLngBounds();
    for (var i = 0 ; i < posts.length ; ++i) {
      bounds.extend(new google.maps.LatLng(posts[i].lat, posts[i].lng));
    }
    map.fitBounds(bounds);    // ■(D)、■(F)-1

    // Zoom の最大値は 16 まで  // ■(G)
    var zoom_listener = google.maps.event.addListener(map, "idle", function() { 
      if (map.getZoom() > 16) {
        map.setZoom(16); 
      }
      google.maps.event.removeListener(zoom_listener); 
    });


    var marker = [];
    var infowindow = [];

    for (var i = 0 ; i < posts.length ; ++i) {
      var post = posts[i];
              
      // 緯度、経度を指定して Marker を作成
      var latlng = new google.maps.LatLng(post.lat, post.lng);
      var m = new google.maps.Marker({    // ■(H)
        icon: icon_map[ post.cat_id ],  // ■(I)
        position: latlng,
        map: map,
        title: post.title       // ■(J)
      });
      marker.push(m);

      var iw = new google.maps.InfoWindow({   // ■(K)
        content: '<div style="width : 100%;height : 36px;">' +
              post.name + ':' +
              '<a href="' + post.link + '">' + post.title +
              '</a></div>',
        size: new google.maps.Size(50, 30)
      });
      infowindow.push(iw);

      // ■(L)
      var handler = (function() {
        var m_ = m, iw_ = iw;
        return function() {
          if (iw_.getMap() == null){
            iw_.open(map, m_);
          } else {
            iw_.close();
          }
        };
      })();
      google.maps.event.addListener(m, 'click', handler);
    }

    var markerCluster = new MarkerClusterer( map, marker );     // ■(O)
  });

})();
</script>
OpenStreet Map (OpenLayers API V3 以降)
<div id="map"></div>
<script type="text/javascript">
(function() {
  var posts = [];
  posts.push({
    lat: (緯度:実数),
    lng: (経度:実数),
    name: (店の分類:文字列),
    link: (店のページの URL:文字列),
    title: (店の名前:文字列),
    cat_id: (カテゴリ:整数),
  });
  // データは、ここまで

  // ページの読み込みが終わったら、地図表示の処理を行う
  window.addEventListener("DOMContentLoaded", function () { // ■(B)

    // カテゴリーID とマーカー画像の対応
    const icon_map = {
      2: "http://dev.openlayers.org/img/marker.png",
      3: "http://dev.openlayers.org/img/marker-blue.png",
      4: "http://dev.openlayers.org/img/marker-gold.png",
      5: "http://dev.openlayers.org/img/marker-green.png",
    };

    const epsg4326 = ol.proj.get('EPSG:4326');
    const epsg3857 = ol.proj.get('EPSG:3857');

    // マーカー(ol.Feature)の生成
    const features = posts.map(post => {
      const lonlat = [post.lng, post.lat];
      return new ol.Feature({   // ■(H)-1
        geometry: new ol.geom.Point(ol.proj.transform(lonlat, epsg4326, epsg3857)),
        post: post,     // ■(I)-2
      });
    });

    // マーカーとツールチップを、店舗情報毎に設定するための Layer
    const marker_layer = new ol.layer.Vector();

    marker_layer.setSource(new ol.source.Vector({features: features}));  // ■(H)-2
    marker_layer.setStyle(function(feature, resolution) {
      const post = feature.get("post");
      return [new ol.style.Style({
        image: new ol.style.Icon({
          anchor: [0.5, 1],
          src: icon_map[post.cat_id],   // ■(I)-1
        }),
      })];
    });

    const view = new ol.View();

    const map = new ol.Map({    // ■(A)
      target: 'map',      // ■(C)
      view: view,
//    interactions: ol.interaction.defaults()
    });

    map.addLayer(new ol.layer.Tile({ source: new ol.source.OSM() }));   // ■(A)
    map.addLayer(marker_layer);                               // ■(H)-3

    map.addControl(new ol.control.ScaleLine());   // ■(E)

    // マーカーが含まれる範囲の Extent と、それに合わせたズーミング
    let ext = ol.extent.boundingExtent(posts.map(p => [p.lng, p.lat]));   // ■(F)-2
    ext = ol.proj.transformExtent(ext, epsg4326, epsg3857);
    view.fit(ext, map.getSize());   // ■(D)、(F)-1

    // ズームの最大値は 16 まで // ■(G)
    if (view.getZoom() > 16) {
      view.setZoom(16);
    }

    let popup_z_index = 9000;

    // 吹き出しの表示、非表示を切り替える
	// ■(L)
    function toggle_popup(feature, coordinate) {
      let popup = feature.get("popup");
      if (! popup) {
        const post = feature.get("post");
        const e = Object.assign(document.createElement("div"), {  // ■(K)-1
          className: "ol-popup",
          innerHTML: `
            <a href="#" class="ol-popup-closer"></a>
            <div class="ol-popup-content">
              ${post.name} : <a href="${post.link}">${post.title}</a>
            </div>
          `,
          ol_feature: feature,
        });
        // addOverlay() は、.ol-overlay-container の先頭に insert していくので、
        // 後で表示した方を上に出すように、z-index を調整する
        e.style.zIndex = ++popup_z_index;
        popup = new ol.Overlay({
          element: e,
          autoPan: true,
          autoPanAnimation: {
            duration: 250,
          },
        });
        feature.set("popup", popup);
        e.querySelector(".ol-popup-closer").addEventListener("click", ev => {
          toggle_popup(ev.target.parentNode.ol_feature);
          ev.target.blur();
          ev.preventDefault();
          ev.stopPropagation();
        });
        map.addOverlay(popup);
//        popup.setPosition(feature.getGeometry().getFirstCoordinate());
        popup.setPosition(coordinate);

      } else {
        map.removeOverlay(popup);
        feature.unset("popup");
      }
    }
    map.on("click", evt => {
      // クリックした位置にマーカーがあったら、吹き出しの表示、非表示を切り替える
      const features = map.getFeaturesAtPixel(evt.pixel);
      if (features) {
        toggle_popup(features[0], evt.coordinate);
      }
    });

    
    // マーカーにツールチップを表示するためのスタイル
    const tooltipStyle = function(feature, resolution) {
      const post = feature.get("post");
      return new ol.style.Style({
        image: new ol.style.Icon({
          anchor: [0.5, 1],
          src: icon_map[post.cat_id],   // ■(I)-1
        }),
        text: new ol.style.Text({ // ■(J)-1
          text: post.title,
          backgroundFill: new ol.style.Fill({
            color: "white",
          }),
          padding: [2,2,2,2],
          textAlign: "left",
          offsetX: 10,
          offsetY: -30,
        }),
      });
    };

    map.on('pointermove', function(evt) {
      if (evt.dragging) {
        return;
      }
      // マーカーのツールチップを消す
      marker_layer.getSource().getFeatures().forEach(f => {
        f.setStyle(null);
      });
      // マウスポインタがある位置のマーカーに、ツールチップを表示するスタイルを指定する
      const pixel = map.getEventPixel(evt.originalEvent);
      const feature = map.forEachFeatureAtPixel(pixel, function(feature) {  // ■(J)-2
        feature.setStyle(tooltipStyle);
        return feature;
      });    

      // マーカー上にマウスポインタがあれば、カーソルのスタイルを変更する
      const target = document.getElementById(map.getTarget());
      target.style.cursor = feature ? "pointer" : "";
    });

  });

})();
</script>
<style>
#map {
  /* (N) */
  width: 99%;
  height: 650px;
}

/* ■(K)-2 */
.ol-popup {
  position: absolute;
  background-color: white;
  -webkit-filter: drop-shadow(0 1px 4px rgba(0,0,0,0.2));
  filter: drop-shadow(0 1px 4px rgba(0,0,0,0.2));
  padding: 15px;
  border-radius: 10px;
  border: 1px solid #cccccc;
  bottom: 12px;
  left: -50px;
  min-width: 280px;
}
.ol-popup:after, .ol-popup:before {
  top: 100%;
  border: solid transparent;
  content: " ";
  height: 0;
  width: 0;
  position: absolute;
  pointer-events: none;
}
.ol-popup:after {
  border-top-color: white;
  border-width: 10px;
  left: 48px;
  margin-left: -10px;
}
.ol-popup:before {
  border-top-color: #cccccc;
  border-width: 11px;
  left: 48px;
  margin-left: -11px;
}
.ol-popup-closer {
  text-decoration: none;
  position: absolute;
  top: 2px;
  right: 8px;
}
.ol-popup-closer:after {
  content: "\2716";
}
</style>

Google Maps API vs. OpenLayers API (V3 以降)

(A) 地図の作成

G: google.maps.Map が地図を表す。
O: ol.Map は レイヤー のコンテナで、地図もひとつの レイヤー。地図の画像がない ol.Map も作れる。
レイヤーの派生関係

  • ol.layer.Base
    • ol.layer.Group
    • ol.layer.Layer
      • ol.layer.Image
      • ol.layer.Tile
      • ol.layer.Vector
        • ol.layer.Heatmap
        • ol.layer.VectorTile
(B) 地図の作成タイミング

G: 自分で EventListener の API を持っている。
O: DOM の addEventListener を使う。

(C) 地図を表示する要素との紐づけ

G: Map のコンストラクタには要素を指定する。
O: 要素の id か要素を指定する。

O(V2) の OpenLayers.Map では Element も指定できるけど、その場合は Map#render() を呼ばないと表示されなかったけど、V3 以降は問題ない気がする(試してない)。

(D) 中心の位置

どちらも、Map のコンストラクタか、Map#setCenter() で指定するのが基本。
ズーミングの調整をしている (F) ので、必要がなくなっているけれども(後述)。
OpenLayers API では、座標は、緯度・経度で指定した後に、transform() で座標変換する必要がある。
球面の座標を平面の座標に変換する、という意味で。

ちなみに、座標は、Google Maps APIOpenLayers で順序が違う。
G: 緯度、経度 : google.maps.LatLng
O: 経度、緯度 : ol.Coordinate - "An array of numbers representing an xy coordinate."
O(V2) では OpenLayers.LonLat というクラスはあったけど、V3 以降ではクラスの名前から緯度、経度の表記がなくなっている。
素性としては正しい気もするけれど、Reference で単位に言及していないのが良くないと思う。

(E) スケールバー

「100m がこのくらい」という あの線。
G: Map のコンストラクタで指定する
O: Map のコントロールという位置づけ(回答の時点では気が付いてなくて、回答には書いてない)。

O(V2) の OpenLayers.Control.ScaleLine は、デフォルトで表示される単位が二つで、ft なんかが表示されちゃったけど、O(V3以降) は気にする必要がない。
というか、逆に O(V2)のデフォルトのふたつの単位でスケールバーを表示するのはどうやるの、という感じ。
ふたつ並べるしかないかな。

(F) 良い感じにズーム

G: Map#fitBounds() を使う
O: ol.View#fit() を使う

(G) ズームの最大値を抑制

表示するマーカーがひとつだけだと、(F) でのズームが大きすぎてよく分からない地図になっちゃうので、ある値まででズームを止めたい。
G: Map のレンダリングが終わってから、ということらしい。ズームの値を調整した後は Listener を削除しておかないと、手動でのズーミングも制限されちゃう。
O: 非同期処理とか気にせずに、普通に呼び出せる

(H) マーカーの作成

G: Map に紐づける
O: いわゆる Marker に相当するクラスは無くて、ol.Feature(画像とかの情報を持たない) にスタイルを適用してマーカーのように見せている

(I) マーカー画像の指定

G: Marker のコンストラクタで指定する
O: ol.style.Style に ol.style.Icon で指定する

OpenLayers では、ol.Feature を抱えた ol.layer.Vector に一括でスタイルを指定する方法と、それぞれの ol.Feature に ol.Feature#setStyle で個々にスタイルを指定する方法がある。
スタイルは、ol.style.Style のインスタンスを指定する方法と、o.style.Style を返す function を指定する方法があって、後者だとダイナミックにスタイルを変更できる(マウスの位置とか時間とか)。

(J) マーカーのツールチップ

G: マーカーの画像と同様
O: マーカーの画像と同じように ol.style.Text で指定するのだけれど、自分でイベントを処理してマウスが乗ったときだけ、そのスタイルを適用する、みたいな処理を書く必要がある

(K) 吹き出しの作成

G: インスタンスを作っただけでは表示されず、InfoWindow#open() で表示。
O: ol.Overlay で作って、ol.Map#addOvaerlay() で紐づけた後に、ol.Overlay#setPosition() で位置を指定すると表示される

O(V3) では、ライブラリで吹き出しのスタイルを持っていないので、自分で用意する必要がある。
O(V2) では、OpenLayers.Popup.* でインスタンスを作成して、OpenLayers.Map#addPopup() で表示する。

(L) マーカーをクリックしたときに、吹き出しの表示をトグル

G: Marker の click イベントで処理する。InfoWindow#open() / close() で切り替える
O: ol.Map の click イベントで処理する。クリックされた座標から、その位置に該当する ol.Feature が手に入るので、それに紐づけて ol.Overlay#setPosition() で表示を切り替える

O(V3) で ol.Overlay にひもづけた要素は、親要素の先頭に挿入されていくので、複数表示したときには、後に表示したものが DOM の順序が先になるため、後から追加したものが下に潜り込んで表示される。
なので、z-index を増やして、後に表示した方が上になるようにする。
このマーカーや吹き出しのイベント処理は、OpenLayers の V2 と V3以降で随分と違う。

(M) 吹き出しがマーカーを覆い隠す

OpenLayers V2 で使っていた OpenLayers.Popup.FramedCloud は、吹き出しのひげを画像で実装してたので、この問題が出ていたけれど、V3 は吹き出しのスタイルを自分で作らなくちゃいけないこともあり、DOM の要素でひげの部分を作るようにしなければ、この問題は発生しない。

(N) 地図のスタイル

Google は、地図を表示する要素に以下のスタイルを埋め込む。

#map {
  position: relative;
  overflow: hidden;
}

なので、地図を抱えるエリアが地図よりも気持ち小さいとかの場合でも それっぽく表示されるけど、OpenLayers API の場合は使う側に任されている。

(O) マーカーのクラスタリング

G: MarkerClusterer というのを使う。
O: ol.source.Cluster を使う。ol.Feature を手に入れるための手間が一段増えるので、Google Maps API のように一行だけでは済まなくて、ol.style.Style とかのコードに影響が出る

マーカーとそのスタイルを作成する辺りのコードが、こんな感じ。

  overlay.setSource(
    new ol.source.Vector({features: features})
  );
  overlay.setStyle(function(feature, resolution) {
    const post = feature.get("post");
    return [new ol.style.Style({
      image: new ol.style.Icon({
        anchor: [0.5, 1],
        src: icon_map[post.cat_id],
      }),
    })];
  });

クラスター化すると、以下のような感じになる。

  overlay.setSource(
    // ol.source.Vector を ol.source.Cluster が wrap する
    new ol.source.Cluster({
      source: new ol.source.Vector({features: features}),
      distance: 50,
    }),
  );
  overlay.setStyle(function(feature, resolution) {
    // この feature はクラスターなので、クラスターからマーカー(ol.Feature)を手に入れる
    const features = feature.get("features");
    // 含まれるマーカーがひとつなら、マーカーのスタイルを返す
    if (features.length == 1) {
      const post = features[0].get("post");
      return [new ol.style.Style({
        image: new ol.style.Icon({
          anchor: [0.5, 1],
          src: icon_map[post.cat_id],
        }),
      })];
    // 含まれるマーカーが複数なら、クラスターとしてのスタイルを返す
    // 円の中に含まれているマーカーの数を表示する、とか
    } else {
      return new ol.style.Style({
        image: new ol.style.Circle({
          radius: 10,
          stroke: new ol.style.Stroke({
            color: '#fff'
          }),
          fill: new ol.style.Fill({
            color: '#3399CC'
          })
        }),
        text: new ol.style.Text({
          text: features.length.toString(),
          fill: new ol.style.Fill({
            color: '#fff'
          })
        })
      });
    }
  });

先のコードだと、マーカーのツールチップやクリックしたときの吹き出しの処理も、この調子で書いてあげないといけない(めんどう

よく見かけるサンプルとの違い

人力検索の質問に対する回答が元ネタなので、ネットでよく見るような、地図にマーカーと吹き出しをつけるサンプルとは、ちょっと機能が違います。
吹き出しが表示されている状態で、別のマーカーをクリックすると、もうひとつ吹き出しが表示されます。
マーカーをクリックするたびに吹き出しはどんどん表示されていき、消すためには吹き出し内の「×」をクリックするか、もう一回マーカーをクリックします。
あと、ズームも値で指定するのではなく、設定したマーカーがすべて表示されるようないい感じになるように API で処理しています。

以前の OpenLayers V2 との比較の記事のコードから変更したところ

話の主体が javascript なので、以前書いた記事のコードから以下の点を変更しました。

  • 元の質問にあった PHP のコードで店舗情報を展開するところを省略した
  • Google Maps API で、座標の範囲を自前で求めていたところを、OpenLayers のように、それ用の APIgoogle.maps.LatLngBounds#extend)を使うようにした
  • OpenLayers API で、マーカーを表示するレイヤーに使っていた overlay という変数名は ol.Overlay(吹き出しに使う)と紛らわしいので、marker_layer に変更した

OpenLayers 2 と OpenLayers 3 以降

あちこちに転がってるサンプルを見て分かると思いますが、V2 → V3 で、考え方は似ているものの互換性を捨てた大きな書き換えがありました。
以下、V2 のコードを V3以降(V4.6.5)で動くように書き換えたときに気が付いたことなど。

ol.Feature や ol.geom.* は、GeoJSON のオブジェクトモデルなんだ

ol.Feature は、マーカーとしては不自由だなあとは思ったんだけど、対応する実体がある。
座標を持っている地図情報は GeoJSON のインスタンスの写しで、「見た目」にあたる画像やツールチップは ol.style.Style に分離している。
「見た目」を分離したことで、GeoJSON 形式のデータを扱いやすくなった、ということかな(GeoJSON を返す Web API を地図に展開する、みたいな API があるはず)。
というわけで、見た目の制御や、クリックしたときのイベント処理も V2 と V3以降で全然違う、ということになってしまったみたいです。

地図は、基本的に canvas に描かれる

V3 は DOM でレンダリングするモードがあったけど、V4 で、ol.Map renderer: "dom" が廃止。
ol.interaction.defaults()では、"altShiftDragRotate" なんてのが ON になってて、canvas ならでは、な感じ。
他に WebGLレンダリングするモードもあるみたいだけど、ラベルが使えないっぽい

Marker (ol.Feature) も、canvas に描かれるので、イベント処理は ol.Map のイベントを処理する

「オブジェクト」という意味では気持ち悪い気もするけれど、V3 以降のマーカー(ol.Feature)は見た目を持たないので、これは仕方ない。
コードは増えるけど、データと見た目を分離しているという意味では、素性が正しいとも言える。

吹き出しは、自分で DOM を用意して、ol.Overlay と紐づける

HTML で静的に用意しても、自分が作成した位置からは移動されちゃう。
スタイルを自前で用意しなきゃならないのは面倒だけど、↓のサンプルのスタイルで十分使える。
http://openlayers.org/en/latest/examples/popup.html

他のサンプル(これとか、これとか)で、bootstrap + jquery で、$.fn.popover 使うのあるけど、他のライブラリに依存する必要性があまり感じられない。

オブジェクトの入れ子の階層が深いけど、javascript は型チェックがないので、間違えたクラスを渡したときのエラーが分かりにくい

「メソッドがない」とか「オブジェクトではない」的なエラーになるので、どこが間違っているか、とても分かりにくい。
例えば、ol.Map#addLayer で、複数の Layer が設定できるかな、って、Array で渡した場合。

  map.addLayer([
    new ol.layer.Tile({ source: new ol.source.OSM() }),
    new ol.layer.Vector({ source: ... }),
  ]);

こんなエラーが出る。

TypeError: a.addEventListener is not a function                        ol.js:47:314
ol.style.Style のインスタンスは、キャッシュした方が良さそう

範囲をドラッグで移動したり、ズームを変えるたびにスタイルの処理が呼び出される。
この記事では、コードの比較がしやすいという意味で、都度 ol.style.Style のインスタンスを生成するコードにしてるけど、スタイルのインスタンスはキャッシュしておいた方が良いよね、きっと。


# などなど。

その他

Fiddle

OpenLayers だけですけれど、実際に動くコードを jsFiddle に置きました。





(おしまい)