5
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Leafletで大量のマーカーを表示させる(ついでに個別の回転機能も)

Last updated at Posted at 2022-09-06

Leafletで1万件越えのマーカーを同時表示(HTML5のCanvasを利用)

課題

  • LeafletのL.MarkerはDOMで描画するため遅い。
  • L.Mapのオプション「preferCanvas: true」、かつL.CircleMarkerを使えばCanvasで描画されるため軽いけど、デザインが限られる。
  • L.MarkerをCanvasで描画してくれるプラグインとして、Leaflet.Canvas-Markersがあるけど、
    • 長らくメンテナンスされていない。(バグ修正のPRもマージされていない)
    • 同一画像のアイコンでは早いけど、連番等を付加した、ひとつづつ違うアイコンではもっさり。
      • 2000個くらいから初回読取が遅くなった。
    • (Canvas触ったことなかったのでお勉強もかねて新規作成)

方向性

  • L.Iconを引数とするL.CircleMarkerの拡張
  • L.CircleMarkerにCanvasでアイコン画像を重ねる。(Leaflet.BoatMarkerとやってることは同じ)
  • L.Iconのオプションにangleを持たせて、回転させる。
  • L.IconのpopupAnchorやoffsetに対応する。
  • 同一アイコン出ない場合のもっさり問題は、アイコンは同一のままで、fillTextで文字列を追加。

ソースコード

Icon2CircleMarker.js
'use strict';
/*
L.Iconを渡すとアイコン付きのL.CircleMarkerが返ってくる。
*/
class Icon2CircleMarker{
  constructor(){
    //既存クラスのメソッド上書き
    this.icon2Canvas = L.CircleMarker.extend({
      _containsPoint: function(makerPoint) {
        //L.IconのoffsetをCircleMarkerへ適用
        const offset = this.options.icon.options.pointOffset;
        if(offset){
          return L.CircleMarker.prototype._containsPoint.call(this, makerPoint.subtract([offset.x, offset.y]));
        }
      },
      
      _updatePath() {
        const op = this.options.icon.options;
        if(op.element){
          const ctx = this._renderer._ctx,
            p = this._point.round();
          p.x += op.offset[0];
          p.y += op.offset[1];
          //Canvasへ描画開始
          ctx.save();
          ctx.translate(p.x, p.y);
          //アイコンの回転を適用
          if(op.angle){
            ctx.rotate(op.angle * 0.0174);//Math.PI / 180;  
          }
          const x = op.iconSize[0],
            y = op.iconSize[1];
          //drawImageでは描画位置は整数にすること (https://developer.mozilla.org/ja/docs/Web/API/Canvas_API/Tutorial/Optimizing_canvas)
          ctx.drawImage(op.element, -Math.floor(x/2), -Math.floor(y/2), Math.floor(x), Math.floor(y));
          //文字は前面に出すため、drawImageの後に追加
          if(op.string){
            const count = op.string.length;
            //文字を中央にするため苦戦(アイコン、フォントによって変わるため適宜変更のこと)
            const fontsize = (x/count < x/2)
              ? (x/count) +4
              : x/2;
            ctx.font = fontsize + 'px sans serif';
            const offset = Math.floor(count * fontsize*1.3 /4);
            ctx.fillStyle = op.fontcolor;
            ctx.fillText(op.string,-offset,0);
          }
          ctx.restore();
        }else{
          const element = document.createElement('img');
          //element.onload = () => this.redraw();//redrawしないと画像が切れることがあるが、パフォーマンス優先
          element.src = op.iconUrl;//キャッシュが効かないと重いため、なるべく同一画像を
          op.element = element;
        }
      },
    });
  }

  getMarker(latlng, options){
    const op = options.icon.options;
    op.offset = [op.iconSize[0] / 2 - op.iconAnchor[0], op.iconSize[1] / 2 - op.iconAnchor[1]];
    op.popupAnchor = [0, 0];
    return new this.icon2Canvas(latlng, options);  
  }
}

使用例

const icm = new Icon2CircleMarker();
const max = 100000;
for(let i = 0; i < max; i++){
    const icon = L.icon({
        iconUrl: 'my-icon.png',
        iconSize: [38, 95],
        iconAnchor: [22, 94],
        popupAnchor: [-3, -76],
        /*シャドウには未対応
        shadowUrl: 'my-icon-shadow.png',
        shadowSize: [68, 95],
        shadowAnchor: [22, 94]*/
    });
    //L.iconへの追加オプション(プラグイン化の際にいい感じにする予定)
    icon.options['angle'] = 0; //アイコンの回転角。北を0として右回りで360°
    icon.options['string'] = hoge; //アイコンにかぶせる文字列
    icon.options['fontcolor'] = 'white'; //文字列の色
    //日本付近にマーカーをばらまく
    const marker = icm.getMarker([35 + Math.random()*10, 135 + Math.random()*10], {      
        popupAnchor: icon.options.popupAnchor,//L.IconのpopupAnchor引き継ぎ、offsetは自動処理
        radius: 10,//あくまでL.CircleMarkerなので不可視のサークルがある。(クリックの当たり判定でもある)
        icon: icon
    });
    layer.addLayer(marker);
}
layer.addTo(map);//layerとmapはそれぞれ適当なL.featureGroupとL.mapを指定してください。

使用例

  • 使用環境
    • CPU AMD Ryzen 5 3600 3.6Ghz
    • GPU Nvidia Geforce GTX 1050
    • Memory DDR4 16GB
    • Google Chrome 105.0.5195.102

10万件の連番付マーカーの場合(初回表示に15秒くらいかかる。ズームレベル変更は7秒くらい)
image.png
10万件の方向付マーカーの場合(初回表示に17秒くらいかかる。ズームレベル変更は15秒くらい)
image.png
※ 今回は角度をforループ内で順次割り当てています。

ToDo

  • プラグイン化
  • 一度のズームレベルチェンジで複数回_updatePath()が走っているため原因究明、改善

追記

今更ながら、Leaflet 1.9.4で動作確認
(1.9.0ではマーカーが表示されないバグがあったが修正済み)

5
5
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?