Leaflet.jsのマウスホイールZoomを、Googlemap的な無段階ズームにする

### モチベーション
Leaflet.jsは使い勝手がシンプルで、D3やPixiJSなどと組み合わせてオリジナルのレイヤーを拡張することが割と自由にできて気に入っています。

ただ、１点、マウスホイールでのズームが段階ズームであることが気に入らなかったので、無段階化するプラグインもどきを作ってみました。
（leaflet.jsはデフォルトでズームのステップを細かくすることはできるのですが、ホイールを回した量に応じてズーム量を変えることができない）

[デモ](https://mutsuyuki.github.io/Leaflet.SmoothWheelZoom/)はこちら

イメージ
![demo_short.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/139559/83345bc1-e0e2-73ea-291f-dead9c7d2ac9.gif)

できたものは[GitHub](https://github.com/mutsuyuki/Leaflet.SmoothWheelZoom)に上げております。

### 方法
実は作ったのはずいぶん前なので細かい部分までは覚えていないのですが、leafletはピンチイン/アウトでのズームにも対応しており、こちらは無段階ズームです。
なので、マウスホイールイベントをトリガーとして、ピンチイン/アウトしたときの処理内容を移植したプラグインを作ればよいのではないかと思いました。



#### githubのleaflertを見て、プラグインの作り方を学ぶ

leafletは本体自体もプラグインを組み合わせて機能を増やしていく構造になっていました。
マウスホイールズーム、ピンチイン/アウトを実現しているプラグインは以下の通りです。

##### ピンチイン/アウトのプラグイン（構造のみ抜粋）
```Map.TouchZoom.js
export var TouchZoom = Handler.extend({
	addHooks: function () {
		DomUtil.addClass(this._map._container, 'leaflet-touch-zoom');
		DomEvent.on(this._map._container, 'touchstart', this._onTouchStart, this);
	},

	removeHooks: function () {
		DomUtil.removeClass(this._map._container, 'leaflet-touch-zoom');
		DomEvent.off(this._map._container, 'touchstart', this._onTouchStart, this);
	},

	_onTouchStart: function (e) {
        ... 中略
	},

	_onTouchMove: function (e) {
        ... 中略
	},

	_onTouchEnd: function () {
        ... 中略
	}
});
```

##### マウスホイールズームのプラグイン（構造のみ抜粋）
```Map.ScrollWheelZoom.js
L.Map.SmoothWheelZoom = L.Handler.extend({

    addHooks: function () {
        L.DomEvent.on(this._map._container, 'mousewheel', this._onWheelScroll, this);
    },

    removeHooks: function () {
        L.DomEvent.off(this._map._container, 'mousewheel', this._onWheelScroll, this);
    },

    _onWheelScroll: function (e) {
        _onWheelScroll: function (e) {
        if (!this._isWheeling) {
            this._onWheelStart(e);
        }
        this._onWheeling(e);
    },

    _onWheelStart: function (e) {
        var map = this._map;
        this._isWheeling = true;
        ...中略
        this._zoomAnimationId = requestAnimationFrame(this._updateWheelZoom.bind(this));
    },

    _onWheeling: function (e) {
        ...中略
        clearTimeout(this._timeoutId);
        this._timeoutId = setTimeout(this._onWheelEnd.bind(this), 200);
    },

    _onWheelEnd: function (e) {
        this._isWheeling = false;
        cancelAnimationFrame(this._zoomAnimationId);
    },

    _updateWheelZoom: function () {
        ...中略
    }

});
```

構造をみてみると、マウスホイールは開始、終了イベントがないため、
１、フラグを使って開始イベントを疑似的発生させて目的のズーム率を設定
２、requestAnimationFrameで目的のズーム率に向かってアニメーション
３、一定時間触っていないことをsetTimeoutで判定して疑似的に終了イベントを発生させてズーム終了
みたいになっています。
多少違いはあるものの、開始、ズーム中、終了に分かれてるので、単純に移植できそうです。


#### 完成版
```SmoothWheelZoom.js
L.Map.SmoothWheelZoom = L.Handler.extend({

    addHooks: function () {
        L.DomEvent.on(this._map._container, 'mousewheel', this._onWheelScroll, this);
    },

    removeHooks: function () {
        L.DomEvent.off(this._map._container, 'mousewheel', this._onWheelScroll, this);
    },

    _onWheelScroll: function (e) {
        if (!this._isWheeling) {
            this._onWheelStart(e);
        }
        this._onWheeling(e);
    },

    _onWheelStart: function (e) {
        var map = this._map;
        this._isWheeling = true;
        this._wheelMousePosition = map.mouseEventToContainerPoint(e);
        this._centerPoint = map.getSize()._divideBy(2);
        this._startLatLng = map.containerPointToLatLng(this._centerPoint);
        this._wheelStartLatLng = map.containerPointToLatLng(this._wheelMousePosition);
        this._startZoom = map.getZoom();
        this._moved = false;
        this._zooming = true;

        map._stop();
        if (map._panAnim) map._panAnim.stop();

        this._goalZoom = map.getZoom();
        this._prevCenter = map.getCenter();
        this._prevZoom = map.getZoom();

        this._zoomAnimationId = requestAnimationFrame(this._updateWheelZoom.bind(this));
    },

    _onWheeling: function (e) {
        var map = this._map;

        this._goalZoom = this._goalZoom - e.deltaY * 0.003 * map.options.smoothSensitivity;
        if (this._goalZoom < map.getMinZoom() || this._goalZoom > map.getMaxZoom()) {
            this._goalZoom = map._limitZoom(this._goalZoom);
        }
        this._wheelMousePosition = this._map.mouseEventToContainerPoint(e);

        clearTimeout(this._timeoutId);
        this._timeoutId = setTimeout(this._onWheelEnd.bind(this), 200);
    },

    _onWheelEnd: function (e) {
        this._isWheeling = false;
        cancelAnimationFrame(this._zoomAnimationId);
    },

    _updateWheelZoom: function () {
        var map = this._map;

        if ((!map.getCenter().equals(this._prevCenter)) || map.getZoom() != this._prevZoom)
            return;

        this._zoom = map.getZoom() + (this._goalZoom - map.getZoom()) * 0.3;
        this._zoom = Math.floor(this._zoom * 100) / 100;

        var delta = this._wheelMousePosition.subtract(this._centerPoint);
        if (delta.x === 0 && delta.y === 0)
            return;

        if (map.options.smoothWheelZoom === 'center') {
            this._center = this._startLatLng;
        } else {
            this._center = map.unproject(map.project(this._wheelStartLatLng, this._zoom).subtract(delta), this._zoom);
        }

        if (!this._moved) {
            map._moveStart(true, false);
            this._moved = true;
        }

        map._move(this._center, this._zoom);
        this._prevCenter = map.getCenter();
        this._prevZoom = map.getZoom();

        this._zoomAnimationId = requestAnimationFrame(this._updateWheelZoom.bind(this));
    }

});
```



詳しい解説はしませんが（もう忘れてる）、`Map.ScrollWheelZoom.js`の構造に`Map.TouchZoom.js`の処理を入れこむような形です。




#### 使い方
デフォルトのScrollWheelZoomをオフにして、作ったプラグインをオンにします。

```js
var map = L.map('map', {
  scrollWheelZoom: false, // disable original zoom function
  smoothWheelZoom: true,  // enable smooth zoom 
  smoothSensitivity: 1,   // zoom speed. default is 1
});
```


使って頂けるのもうれしいですし、自分でleafletをカスタマイズする際の参考になれば幸いです。

[デモ](https://mutsuyuki.github.io/Leaflet.SmoothWheelZoom/)

本当はyarnとかでインストールできるようにとかしてみたいんですが、なかなか手をだせてません。