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

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

## 方向性
- L.Iconを引数とするL.CircleMarkerの拡張
- L.CircleMarkerにCanvasでアイコン画像を重ねる。（[Leaflet.BoatMarker](https://github.com/thomasbrueggemann/leaflet.boatmarker)とやってることは同じ）
- L.Iconのオプションにangleを持たせて、回転させる。
- L.IconのpopupAnchorやoffsetに対応する。
- 同一アイコン出ない場合のもっさり問題は、アイコンは同一のままで、fillTextで文字列を追加。

## ソースコード
```javascript: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);  
  }
}

```
## 使用例
```javascript
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2823234/c2055c3d-2cf0-1ca6-2c48-2aa632abfc24.png)
10万件の方向付マーカーの場合（初回表示に17秒くらいかかる。ズームレベル変更は15秒くらい）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2823234/e3e57479-df8c-702f-64b9-1aa8e497609a.png)
※　今回は角度をforループ内で順次割り当てています。

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

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