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秒くらい)
10万件の方向付マーカーの場合(初回表示に17秒くらいかかる。ズームレベル変更は15秒くらい)
※ 今回は角度をforループ内で順次割り当てています。
ToDo
- プラグイン化
- 一度のズームレベルチェンジで複数回_updatePath()が走っているため原因究明、改善
追記
今更ながら、Leaflet 1.9.4で動作確認
(1.9.0ではマーカーが表示されないバグがあったが修正済み)