Leafletでポップアップをドラッグしたい

# Leafletでポップアップ(L.Popup)をドラッグしたい
![comp.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2823234/b1d54aff-e429-4286-a8c2-1e2d9848a8fd.jpeg)
## 概要
- Leafletでポップアップをドラッグ可能にするためのプラグインを作成した軌跡です。
- LeafletプラグインはGithubで公開中。 (https://github.com/wrwrh/leaflet-popupmovable)
    - Google ChromeまたはChromium Edge推奨。
    - HTMLファイルおよびCSSファイルに以下の記述を加えてください。
```html:index.html
<meta name='viewport' content='width=device-width,initial-scale=1' />
```
```css:style.css
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
```

## まずはポップアップを移動可能にする
とりあえずググる「Leaflet L.popup draggable」とすぐに見つかる。
(https://stackoverflow.com/questions/58059686/draggable-leaflet-popup)
- ソース内のコメントは、筆者が追記
```javascript:
function makeDraggable(popup){
    //まずポップアップの位置を記録し、
    var pos = map.latLngToLayerPoint(popup.getLatLng());
    L.DomUtil.setPosition(popup._wrapper.parentNode, pos);
    //ポップアップのDOM要素をドラッグ可能にする。
    var draggable = new L.Draggable(popup._container, popup._wrapper);
    draggable.enable();
    //ドラッグが終了したら、
    draggable.on('dragend', function() {
        //ドラッグ後の位置の緯度経度をポップアップに紐づける。
        var pos = map.layerPointToLatLng(this._newPos);
        //これをしないとズームなどで位置がもどる。
        popup.setLatLng(pos);
    });
}
```
ただし、これだけだと引出線（ポップアップ下の三角形）はそのままの形
![draggable.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2823234/8b625e3a-f9f8-1019-de39-379934fba13b.jpeg)

## 移動後のポップアップとマーカーを結ぶ
基本的な考え方
- L.Draggableの'drag'イベントでドラッグ後の位置が取得できる。
- 元のマーカーの位置と移動後のポップアップの位置を頂点とした矩形をCSSとSVGで描画する。
- 引出線の太さを考慮した頂点同士を結んだ線を描画する。
- マーカーとポップアップが垂直、水平の場合、矩形の短辺が極端に短くなるため、ポップアップ側を底辺とした二等辺三角形を描画する。
- L.markerのpopupAnchorも考慮する。



```javascript
//マーカーに紐づいているポップアップを作成
const marker = L.marker(...);
const p = L.popup(...);
marker.bindPopup(p);

//あらかじめ、マーカーに紐づくポップアップの要素に緯度経度とpopupAnchorを仕込んでおく。
p._wrapper.parentNode.latlng = p.getLatLng();
try{
    p._wrapper.parentNode.popupAnchor = p._source.options.icon.options.popupAnchor;
}catch{
    p._wrapper.parentNode.popupAnchor = [0,0];
}

draggable.on('drag', e => {
    //ポップアップの要素と、ドラッグ後の位置を引出線の描画関数へ渡す。
    drawCss(e.target._element,e.target._newPos);
}
```

```javascript:
drawCss(el, newPosition){
        //あらかじめ仕込んでいたマーカーの位置を取得
        const originalPos = this._map.latLngToLayerPoint(el.latlng);
        originalPos.x += el.popupAnchor[0];
        originalPos.y +=  el.popupAnchor[1];
        //ポップアップの大きさ(垂直、水平判定に利用)と、矩形の大きさをSVG作成関数へ渡す
        const div = el.children[1],
            h = el.clientHeight,
            w = el.clientWidth,
            //チップ（ポップアップ下部の三角形）の高さ
            tip = 17,
            x = Math.round(originalPos.x - newPosition.x + tip),
            y = Math.round(originalPos.y - (newPosition.y - h/2 - tip)),
            css = createPopupCss(x,y,w,h);
        //引出線はCSSで返されるため、各要素をHTMLへ適用する
        for(const name in css){
            div.style[this._camelize(name)] = css[name];
        }
        //デフォルトのチップを非表示
        div.children[0].style.visibility = 'hidden';
    },
```

```javascript:
createPopupCss(x,y,w,h){
    //テンプレートリテラルを利用して指定した大きさの矩形及び引出線に相当するポリゴンを作成
    function svgicon = (points,width,height)=>{
        const xml = `<?xml version="1.0" encoding="utf-8"?>
            <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" 
            "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
            <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" 
            width="${width}" height="${height}" preserveAspectRatio="none" viewBox="0 0 100 100">
            <polygon points="${s}" stroke-width="0.2" stroke="gray" fill="white" />
            </svg>`;
        //SVG-XML未対応のプラグイン用にbase64化
        const encoded = btoa(xml);
        const uri = encodeURI(`data:image/svg+xml;charset=utf8;base64,${encoded}`);
        return `url(${uri})`;
        }
    const c = {
        //吹き出しの後ろに隠れる
        'z-index' : -1,
        'position': 'absolute',
        'filter': 'drop-shadow(0px 0px 2px gray)',
    },
    //マーカーと並行、垂直とみなす幅
    para = 18,
    //アンカーポイントの微調整用
    offset = 20,
    tweakH = 4,
    tweakW = 3
    
    //マーカーとポップアップの位置関係(8方向)によって場合分け
    if(Math.abs(y) + offset/2 <= h/2){
        //parallel
        c['height'] = para;
        c['top'] = h/2 - para/2 + y - tweakH;
        if(x >= 0){
            //left
            c['width'] = x - w/2 - offset + tweakW; 
            c['left'] = w + offset;
            c['background-image'] = svgicon("0,0 100,50 0,100",c['width'],para);
        }else{
            //right
        }
    }else if(Math.abs(x - offset) + offset <= w/2){
        //vertical
        if(y >= 0){
            //top
        }else{
            //bottom
        }
    }else if(x >= 0 && y >= 0){
        //left-upper
    }else if(x < 0 && y >= 0){
        //right-upper
    }else if(x < 0 && y < 0){
        //right-lower
    }else if(x >= 0 && y < 0){
        //left-lower
    }
    //一部の数値をpxへ変換
    Object.keys(c).forEach(function(key){
        const lst = ['width','left','height','top'];
        for(const i in lst){
            if(lst[i] === key) c[key] = String(c[key]) + 'px';
        }
    });
    return c;
},

```

## その他
Leafletプラグイン版は以下の機能を盛り込んであります。
- ズーム時の対応
- [Leaflet.SmoothWheelZoom](https://github.com/mutsuyuki/Leaflet.SmoothWheelZoom)への対応
- ポップアップ自動分散機能の追加

## 更新
いまさらながらLeaflet 1.9.4における動作確認