leaflet-butter-smooth-zoom 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,303 @@
1
+ L.Map.mergeOptions({
2
+ butterSmoothZoom: false,
3
+ butterSmoothScale: 0.0042,
4
+ butterSmoothEasing: 0.38,
5
+ butterSmoothEndDelay: 140,
6
+ butterSmoothHideVectors: false,
7
+ butterSmoothButtonZoomDelta: 1,
8
+ butterSmoothZoomAnimationDuration: 0.125,
9
+ });
10
+
11
+ const clampNumber = function (value, min, max) {
12
+ return Math.min(Math.max(value, min), max);
13
+ };
14
+
15
+ L.Map.ButterSmoothZoom = L.Handler.extend({
16
+ addHooks: function () {
17
+ const map = this._map;
18
+ L.DomEvent.on(map._container, 'wheel', this._onWheel, this);
19
+ this._prevZoomDelta = map.options.zoomDelta;
20
+ if (typeof map.options.butterSmoothButtonZoomDelta === 'number') {
21
+ map.options.zoomDelta = map.options.butterSmoothButtonZoomDelta;
22
+ }
23
+ this._ensureZoomAnimationStyle();
24
+ const container = map.getContainer();
25
+ this._addedZoomAnimClass = !container.classList.contains('butter-smooth-zoom-fast');
26
+ container.classList.add('butter-smooth-zoom-fast');
27
+ this._prevZoomAnimDuration = container.style.getPropertyValue(
28
+ '--butter-smooth-zoom-duration'
29
+ );
30
+ const duration = map.options.butterSmoothZoomAnimationDuration;
31
+ if (typeof duration === 'number') {
32
+ container.style.setProperty('--butter-smooth-zoom-duration', `${duration}s`);
33
+ }
34
+ },
35
+
36
+ removeHooks: function () {
37
+ const map = this._map;
38
+ L.DomEvent.off(map._container, 'wheel', this._onWheel, this);
39
+ if (typeof this._prevZoomDelta === 'number') {
40
+ map.options.zoomDelta = this._prevZoomDelta;
41
+ }
42
+ const container = map.getContainer();
43
+ if (this._addedZoomAnimClass) {
44
+ container.classList.remove('butter-smooth-zoom-fast');
45
+ }
46
+ if (typeof this._prevZoomAnimDuration === 'string') {
47
+ if (this._prevZoomAnimDuration.length > 0) {
48
+ container.style.setProperty('--butter-smooth-zoom-duration', this._prevZoomAnimDuration);
49
+ } else {
50
+ container.style.removeProperty('--butter-smooth-zoom-duration');
51
+ }
52
+ }
53
+ this._cleanup();
54
+ },
55
+
56
+ _ensureZoomAnimationStyle: function () {
57
+ if (typeof document === 'undefined') {
58
+ return;
59
+ }
60
+ if (document.getElementById('butter-smooth-zoom-style')) {
61
+ return;
62
+ }
63
+ const style = document.createElement('style');
64
+ style.id = 'butter-smooth-zoom-style';
65
+ style.textContent =
66
+ '.butter-smooth-zoom-fast .leaflet-zoom-anim .leaflet-zoom-animated{' +
67
+ '-webkit-transition-duration:var(--butter-smooth-zoom-duration)!important;' +
68
+ '-moz-transition-duration:var(--butter-smooth-zoom-duration)!important;' +
69
+ 'transition-duration:var(--butter-smooth-zoom-duration)!important;' +
70
+ '}';
71
+ document.head.appendChild(style);
72
+ },
73
+
74
+ _toggleHide: function (active) {
75
+ if (!this._map.options.butterSmoothHideVectors) {
76
+ return;
77
+ }
78
+ const container = this._map.getContainer();
79
+ if (!container) {
80
+ return;
81
+ }
82
+ container.classList.toggle('smooth-wheel-hide', active);
83
+ },
84
+
85
+ _getZoomAroundCenter: function (focus, zoom) {
86
+ const map = this._map;
87
+ const scale = map.getZoomScale(zoom);
88
+ const viewHalf = map.getSize().divideBy(2);
89
+ const containerPoint = map.latLngToContainerPoint(focus);
90
+ const centerOffset = containerPoint.subtract(viewHalf).multiplyBy(1 - 1 / scale);
91
+ return map.containerPointToLatLng(viewHalf.add(centerOffset));
92
+ },
93
+
94
+ _syncRenderers: function () {
95
+ if (!this._baseCenter || this._baseZoom === null || this._baseZoom === undefined) {
96
+ return;
97
+ }
98
+ this._map.eachLayer((layer) => {
99
+ if (layer instanceof L.Renderer) {
100
+ layer._center = this._baseCenter;
101
+ layer._zoom = this._baseZoom;
102
+ }
103
+ });
104
+ },
105
+
106
+ _cacheLineWeights: function () {
107
+ if (!this._lineBaseWeights) {
108
+ this._lineBaseWeights = new Map();
109
+ }
110
+ this._map.eachLayer((layer) => {
111
+ if (layer instanceof L.Path && !this._lineBaseWeights.has(layer)) {
112
+ const baseWeight =
113
+ layer.options && typeof layer.options.weight === 'number' ? layer.options.weight : 3;
114
+ this._lineBaseWeights.set(layer, baseWeight);
115
+ }
116
+ });
117
+ },
118
+
119
+ _cacheCircleRadii: function () {
120
+ if (!this._circleBaseRadii) {
121
+ this._circleBaseRadii = new Map();
122
+ }
123
+ this._map.eachLayer((layer) => {
124
+ if (
125
+ layer instanceof L.CircleMarker &&
126
+ !(layer instanceof L.Circle) &&
127
+ !this._circleBaseRadii.has(layer)
128
+ ) {
129
+ this._circleBaseRadii.set(layer, layer.getRadius());
130
+ }
131
+ });
132
+ },
133
+
134
+ _setCircleRadius: function (layer, radius) {
135
+ if (!layer) {
136
+ return;
137
+ }
138
+ layer.options.radius = radius;
139
+ layer._radius = radius;
140
+ if (layer._radiusY) {
141
+ layer._radiusY = radius;
142
+ }
143
+ if (layer._renderer && layer._renderer instanceof L.SVG && layer._renderer._updateCircle) {
144
+ layer._updateBounds();
145
+ layer._renderer._updateCircle(layer);
146
+ }
147
+ },
148
+
149
+ _projectCircleBase: function (layer) {
150
+ if (!layer || !this._baseCenter || this._baseZoom === null || this._baseZoom === undefined) {
151
+ return;
152
+ }
153
+ if (this._map._latLngToNewLayerPoint) {
154
+ const latlng = layer.getLatLng ? layer.getLatLng() : layer._latlng;
155
+ layer._point = this._map._latLngToNewLayerPoint(latlng, this._baseZoom, this._baseCenter);
156
+ }
157
+ },
158
+
159
+ _applyZoomFrame: function (zoom, focus) {
160
+ const map = this._map;
161
+ const center = this._getZoomAroundCenter(focus, zoom);
162
+ map.fire('zoomanim', { center, zoom, noUpdate: true });
163
+ map._move(center, zoom, undefined, true);
164
+ this._syncRenderers();
165
+
166
+ if (!map.options.butterSmoothHideVectors && this._baseZoom !== null && this._baseZoom !== undefined) {
167
+ const scale = map.getZoomScale(zoom, this._baseZoom);
168
+ if (this._lineBaseWeights) {
169
+ this._lineBaseWeights.forEach((baseWeight, layer) => {
170
+ if (layer && layer.setStyle) {
171
+ layer.setStyle({ weight: baseWeight / scale });
172
+ }
173
+ });
174
+ }
175
+ if (this._circleBaseRadii) {
176
+ this._circleBaseRadii.forEach((baseRadius, layer) => {
177
+ this._projectCircleBase(layer);
178
+ this._setCircleRadius(layer, baseRadius / scale);
179
+ });
180
+ }
181
+ }
182
+ },
183
+
184
+ _finalizeZoom: function () {
185
+ if (this._targetZoom === null || this._targetZoom === undefined) {
186
+ return;
187
+ }
188
+ const map = this._map;
189
+ const focus = this._lastFocus || map.getCenter();
190
+ const center = this._getZoomAroundCenter(focus, this._targetZoom);
191
+
192
+ map._animatingZoom = true;
193
+ map._animateToCenter = center;
194
+ map._animateToZoom = this._targetZoom;
195
+ map.fire('zoomanim', { center, zoom: this._targetZoom, noUpdate: false });
196
+ map._move(center, this._targetZoom, undefined, true);
197
+ if (map._onZoomTransitionEnd) {
198
+ map._onZoomTransitionEnd();
199
+ }
200
+
201
+ this._resetLineWeights();
202
+ this._resetCircleRadii();
203
+ this._toggleHide(false);
204
+ this._targetZoom = null;
205
+ this._lastFocus = null;
206
+ this._baseCenter = null;
207
+ this._baseZoom = null;
208
+ },
209
+
210
+ _animate: function () {
211
+ if (this._targetZoom === null || this._targetZoom === undefined) {
212
+ this._animationId = null;
213
+ return;
214
+ }
215
+
216
+ const map = this._map;
217
+ const current = map.getZoom();
218
+ const diff = this._targetZoom - current;
219
+
220
+ if (Math.abs(diff) < 0.0005) {
221
+ const focus = this._lastFocus || map.getCenter();
222
+ this._applyZoomFrame(this._targetZoom, focus);
223
+ this._animationId = null;
224
+ return;
225
+ }
226
+
227
+ const next = current + diff * map.options.butterSmoothEasing;
228
+ const focus = this._lastFocus || map.getCenter();
229
+ this._applyZoomFrame(next, focus);
230
+ this._animationId = requestAnimationFrame(this._animate.bind(this));
231
+ },
232
+
233
+ _onWheel: function (event) {
234
+ event.preventDefault();
235
+ const map = this._map;
236
+ const deltaModeFactor = event.deltaMode === 1 ? 20 : event.deltaMode === 2 ? 60 : 1;
237
+ const scaledDelta = -event.deltaY * deltaModeFactor;
238
+ const zoomChange = scaledDelta * map.options.butterSmoothScale;
239
+
240
+ if (this._baseCenter === null || this._baseCenter === undefined || this._baseZoom === null || this._baseZoom === undefined) {
241
+ this._baseCenter = map.getCenter();
242
+ this._baseZoom = map.getZoom();
243
+ this._syncRenderers();
244
+ this._cacheLineWeights();
245
+ this._cacheCircleRadii();
246
+ }
247
+
248
+ const current = this._targetZoom === null || this._targetZoom === undefined ? map.getZoom() : this._targetZoom;
249
+ this._targetZoom = clampNumber(current + zoomChange, map.getMinZoom(), map.getMaxZoom());
250
+ this._lastFocus = map.mouseEventToLatLng(event);
251
+
252
+ if (this._animationId === null || this._animationId === undefined) {
253
+ this._animationId = requestAnimationFrame(this._animate.bind(this));
254
+ }
255
+
256
+ this._toggleHide(true);
257
+
258
+ if (this._endTimer) {
259
+ clearTimeout(this._endTimer);
260
+ }
261
+ this._endTimer = setTimeout(this._finalizeZoom.bind(this), map.options.butterSmoothEndDelay);
262
+ },
263
+
264
+ _resetLineWeights: function () {
265
+ if (!this._lineBaseWeights) {
266
+ return;
267
+ }
268
+ this._lineBaseWeights.forEach((baseWeight, layer) => {
269
+ if (layer && layer.setStyle) {
270
+ layer.setStyle({ weight: baseWeight });
271
+ }
272
+ });
273
+ },
274
+
275
+ _resetCircleRadii: function () {
276
+ if (!this._circleBaseRadii) {
277
+ return;
278
+ }
279
+ this._circleBaseRadii.forEach((baseRadius, layer) => {
280
+ this._setCircleRadius(layer, baseRadius);
281
+ });
282
+ },
283
+
284
+ _cleanup: function () {
285
+ if (this._animationId !== null && this._animationId !== undefined) {
286
+ cancelAnimationFrame(this._animationId);
287
+ }
288
+ if (this._endTimer) {
289
+ clearTimeout(this._endTimer);
290
+ }
291
+ this._toggleHide(false);
292
+ this._resetLineWeights();
293
+ this._resetCircleRadii();
294
+ this._animationId = null;
295
+ this._endTimer = null;
296
+ this._targetZoom = null;
297
+ this._lastFocus = null;
298
+ this._baseCenter = null;
299
+ this._baseZoom = null;
300
+ },
301
+ });
302
+
303
+ L.Map.addInitHook('addHandler', 'butterSmoothZoom', L.Map.ButterSmoothZoom);
package/README.md ADDED
@@ -0,0 +1,106 @@
1
+ # Leaflet Butter Smooth Zoom
2
+
3
+ Butter-smooth wheel zoom handler for Leaflet with inertia-style zooming, plus optional vector hiding for silky tiles.
4
+
5
+ ## Demo
6
+
7
+ Live demo: https://hexadeciman.github.io/leaflet-butter-smooth-zoom/
8
+ Update the username once GitHub Pages is enabled.
9
+
10
+ ## Features
11
+
12
+ - Inertia-style wheel zoom with configurable easing and end delay.
13
+ - Keeps line weights and circle markers in place while zooming (no marker drifting).
14
+ - Works with Leaflet and React-Leaflet.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ npm install leaflet-butter-smooth-zoom
20
+ ```
21
+
22
+ ```bash
23
+ yarn add leaflet-butter-smooth-zoom
24
+ ```
25
+
26
+ ```bash
27
+ pnpm add leaflet-butter-smooth-zoom
28
+ ```
29
+
30
+ ## Usage (Leaflet, ESM)
31
+
32
+ ```ts
33
+ import 'leaflet-butter-smooth-zoom';
34
+ import L from 'leaflet';
35
+
36
+ const map = L.map('map', {
37
+ butterSmoothZoom: true,
38
+ butterSmoothScale: 0.0042,
39
+ butterSmoothEasing: 0.38,
40
+ butterSmoothEndDelay: 140,
41
+ butterSmoothHideVectors: false,
42
+ butterSmoothButtonZoomDelta: 1,
43
+ butterSmoothZoomAnimationDuration: 0.125
44
+ });
45
+ ```
46
+
47
+ ## Usage (React-Leaflet)
48
+
49
+ ```tsx
50
+ import 'leaflet-butter-smooth-zoom';
51
+ import { MapContainer, TileLayer } from 'react-leaflet';
52
+
53
+ <MapContainer
54
+ center={[40.73061, -73.935242]}
55
+ zoom={13}
56
+ scrollWheelZoom={false}
57
+ butterSmoothZoom
58
+ butterSmoothScale={0.0042}
59
+ butterSmoothEasing={0.38}
60
+ butterSmoothEndDelay={140}
61
+ butterSmoothButtonZoomDelta={1}
62
+ >
63
+ <TileLayer
64
+ url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
65
+ attribution="&copy; OpenStreetMap contributors"
66
+ />
67
+ </MapContainer>;
68
+ ```
69
+
70
+ ## Usage (script tag)
71
+
72
+ This repo ships a standalone browser build: `Leaflet.ButterSmoothZoom.js`.
73
+
74
+ ```html
75
+ <link
76
+ rel="stylesheet"
77
+ href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
78
+ />
79
+ <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
80
+ <script src="https://unpkg.com/leaflet-butter-smooth-zoom@latest/Leaflet.ButterSmoothZoom.js"></script>
81
+ ```
82
+
83
+ Load Leaflet first, then the plugin. It registers `L.Map.ButterSmoothZoom` and the `butterSmoothZoom` handler.
84
+
85
+ ## Options
86
+
87
+ - `butterSmoothZoom` (boolean): Enable the handler.
88
+ - `butterSmoothScale` (number): Wheel delta multiplier. Higher values zoom faster.
89
+ - `butterSmoothEasing` (number): Interpolation factor per frame. Higher values feel snappier.
90
+ - `butterSmoothEndDelay` (number): Delay before final tile settle after wheel stops (ms).
91
+ - `butterSmoothHideVectors` (boolean): Toggle `.smooth-wheel-hide` class during zoom.
92
+ - `butterSmoothButtonZoomDelta` (number): Zoom step used by the +/- controls while enabled.
93
+ - `butterSmoothZoomAnimationDuration` (number): Zoom button animation duration in seconds (default `0.125`).
94
+
95
+ ## CSS helper (optional)
96
+
97
+ If you enable `butterSmoothHideVectors`, add this to your app CSS:
98
+
99
+ ```css
100
+ .leaflet-container.smooth-wheel-hide .leaflet-overlay-pane,
101
+ .leaflet-container.smooth-wheel-hide .leaflet-marker-pane,
102
+ .leaflet-container.smooth-wheel-hide .leaflet-shadow-pane {
103
+ opacity: 0;
104
+ transition: opacity 120ms ease;
105
+ }
106
+ ```
package/dist/index.cjs ADDED
@@ -0,0 +1,301 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __copyProps = (to, from, except, desc) => {
9
+ if (from && typeof from === "object" || typeof from === "function") {
10
+ for (let key of __getOwnPropNames(from))
11
+ if (!__hasOwnProp.call(to, key) && key !== except)
12
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
13
+ }
14
+ return to;
15
+ };
16
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
17
+ // If the importer is in node compatibility mode or this is not an ESM
18
+ // file that has been converted to a CommonJS file using a Babel-
19
+ // compatible transform (i.e. "__esModule" has not been set), then set
20
+ // "default" to the CommonJS "module.exports" for node compatibility.
21
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
22
+ mod
23
+ ));
24
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
25
+
26
+ // src/index.ts
27
+ var index_exports = {};
28
+ module.exports = __toCommonJS(index_exports);
29
+ var import_leaflet = __toESM(require("leaflet"), 1);
30
+ import_leaflet.default.Map.mergeOptions({
31
+ butterSmoothZoom: false,
32
+ butterSmoothScale: 42e-4,
33
+ butterSmoothEasing: 0.38,
34
+ butterSmoothEndDelay: 140,
35
+ butterSmoothHideVectors: false,
36
+ butterSmoothButtonZoomDelta: 1,
37
+ butterSmoothZoomAnimationDuration: 0.125
38
+ });
39
+ var clampNumber = (value, min, max) => {
40
+ return Math.min(Math.max(value, min), max);
41
+ };
42
+ var ButterSmoothZoom = import_leaflet.default.Handler.extend({
43
+ addHooks: function() {
44
+ const map = this._map;
45
+ const container = map.getContainer();
46
+ import_leaflet.default.DomEvent.on(container, "wheel", this._onWheel, this);
47
+ this._prevZoomDelta = map.options.zoomDelta;
48
+ if (typeof map.options.butterSmoothButtonZoomDelta === "number") {
49
+ map.options.zoomDelta = map.options.butterSmoothButtonZoomDelta;
50
+ }
51
+ this._ensureZoomAnimationStyle();
52
+ this._addedZoomAnimClass = !container.classList.contains("butter-smooth-zoom-fast");
53
+ container.classList.add("butter-smooth-zoom-fast");
54
+ this._prevZoomAnimDuration = container.style.getPropertyValue(
55
+ "--butter-smooth-zoom-duration"
56
+ );
57
+ const duration = map.options.butterSmoothZoomAnimationDuration;
58
+ if (typeof duration === "number") {
59
+ container.style.setProperty("--butter-smooth-zoom-duration", `${duration}s`);
60
+ }
61
+ },
62
+ removeHooks: function() {
63
+ const map = this._map;
64
+ const container = map.getContainer();
65
+ import_leaflet.default.DomEvent.off(container, "wheel", this._onWheel, this);
66
+ if (typeof this._prevZoomDelta === "number") {
67
+ map.options.zoomDelta = this._prevZoomDelta;
68
+ }
69
+ if (this._addedZoomAnimClass) {
70
+ container.classList.remove("butter-smooth-zoom-fast");
71
+ }
72
+ if (typeof this._prevZoomAnimDuration === "string") {
73
+ if (this._prevZoomAnimDuration.length > 0) {
74
+ container.style.setProperty("--butter-smooth-zoom-duration", this._prevZoomAnimDuration);
75
+ } else {
76
+ container.style.removeProperty("--butter-smooth-zoom-duration");
77
+ }
78
+ }
79
+ this._cleanup();
80
+ },
81
+ _ensureZoomAnimationStyle: function() {
82
+ if (typeof document === "undefined") {
83
+ return;
84
+ }
85
+ if (document.getElementById("butter-smooth-zoom-style")) {
86
+ return;
87
+ }
88
+ const style = document.createElement("style");
89
+ style.id = "butter-smooth-zoom-style";
90
+ style.textContent = ".butter-smooth-zoom-fast .leaflet-zoom-anim .leaflet-zoom-animated{-webkit-transition-duration:var(--butter-smooth-zoom-duration)!important;-moz-transition-duration:var(--butter-smooth-zoom-duration)!important;transition-duration:var(--butter-smooth-zoom-duration)!important;}";
91
+ document.head.appendChild(style);
92
+ },
93
+ _toggleHide: function(active) {
94
+ if (!this._map.options.butterSmoothHideVectors) {
95
+ return;
96
+ }
97
+ const container = this._map.getContainer();
98
+ if (!container) {
99
+ return;
100
+ }
101
+ container.classList.toggle("smooth-wheel-hide", active);
102
+ },
103
+ _getZoomAroundCenter: function(focus, zoom) {
104
+ const map = this._map;
105
+ const scale = map.getZoomScale(zoom);
106
+ const viewHalf = map.getSize().divideBy(2);
107
+ const containerPoint = map.latLngToContainerPoint(focus);
108
+ const centerOffset = containerPoint.subtract(viewHalf).multiplyBy(1 - 1 / scale);
109
+ return map.containerPointToLatLng(viewHalf.add(centerOffset));
110
+ },
111
+ _syncRenderers: function() {
112
+ if (!this._baseCenter || this._baseZoom === null || this._baseZoom === void 0) {
113
+ return;
114
+ }
115
+ this._map.eachLayer((layer) => {
116
+ if (layer instanceof import_leaflet.default.Renderer) {
117
+ const renderer = layer;
118
+ renderer._center = this._baseCenter;
119
+ renderer._zoom = this._baseZoom;
120
+ }
121
+ });
122
+ },
123
+ _cacheLineWeights: function() {
124
+ if (!this._lineBaseWeights) {
125
+ this._lineBaseWeights = /* @__PURE__ */ new Map();
126
+ }
127
+ this._map.eachLayer((layer) => {
128
+ if (layer instanceof import_leaflet.default.Path && !this._lineBaseWeights.has(layer)) {
129
+ const baseWeight = layer.options && typeof layer.options.weight === "number" ? layer.options.weight : 3;
130
+ this._lineBaseWeights.set(layer, baseWeight);
131
+ }
132
+ });
133
+ },
134
+ _cacheCircleRadii: function() {
135
+ if (!this._circleBaseRadii) {
136
+ this._circleBaseRadii = /* @__PURE__ */ new Map();
137
+ }
138
+ this._map.eachLayer((layer) => {
139
+ if (layer instanceof import_leaflet.default.CircleMarker && !(layer instanceof import_leaflet.default.Circle) && !this._circleBaseRadii.has(layer)) {
140
+ this._circleBaseRadii.set(layer, layer.getRadius());
141
+ }
142
+ });
143
+ },
144
+ _setCircleRadius: function(layer, radius) {
145
+ const circle = layer;
146
+ if (!circle) {
147
+ return;
148
+ }
149
+ circle.options.radius = radius;
150
+ circle._radius = radius;
151
+ if (circle._radiusY) {
152
+ circle._radiusY = radius;
153
+ }
154
+ if (circle._renderer && circle._renderer instanceof import_leaflet.default.SVG && circle._renderer._updateCircle) {
155
+ circle._updateBounds();
156
+ circle._renderer._updateCircle(circle);
157
+ }
158
+ },
159
+ _projectCircleBase: function(layer) {
160
+ const circle = layer;
161
+ if (!circle || !this._baseCenter || this._baseZoom === null || this._baseZoom === void 0) {
162
+ return;
163
+ }
164
+ const map = this._map;
165
+ const latlng = circle.getLatLng ? circle.getLatLng() : circle._latlng;
166
+ if (!latlng) {
167
+ return;
168
+ }
169
+ circle._point = map._latLngToNewLayerPoint(latlng, this._baseZoom, this._baseCenter);
170
+ },
171
+ _applyZoomFrame: function(zoom, focus) {
172
+ const map = this._map;
173
+ const center = this._getZoomAroundCenter(focus, zoom);
174
+ map.fire("zoomanim", { center, zoom, noUpdate: true });
175
+ map._move(center, zoom, void 0, true);
176
+ this._syncRenderers();
177
+ if (!map.options.butterSmoothHideVectors && this._baseZoom !== null && this._baseZoom !== void 0) {
178
+ const scale = map.getZoomScale(zoom, this._baseZoom);
179
+ if (this._lineBaseWeights) {
180
+ this._lineBaseWeights.forEach((baseWeight, layer) => {
181
+ if (layer && layer.setStyle) {
182
+ layer.setStyle({ weight: baseWeight / scale });
183
+ }
184
+ });
185
+ }
186
+ if (this._circleBaseRadii) {
187
+ this._circleBaseRadii.forEach((baseRadius, layer) => {
188
+ this._projectCircleBase(layer);
189
+ this._setCircleRadius(layer, baseRadius / scale);
190
+ });
191
+ }
192
+ }
193
+ },
194
+ _finalizeZoom: function() {
195
+ if (this._targetZoom === null || this._targetZoom === void 0) {
196
+ return;
197
+ }
198
+ const map = this._map;
199
+ const focus = this._lastFocus || map.getCenter();
200
+ const center = this._getZoomAroundCenter(focus, this._targetZoom);
201
+ map._animatingZoom = true;
202
+ map._animateToCenter = center;
203
+ map._animateToZoom = this._targetZoom;
204
+ map.fire("zoomanim", { center, zoom: this._targetZoom, noUpdate: false });
205
+ map._move(center, this._targetZoom, void 0, true);
206
+ if (map._onZoomTransitionEnd) {
207
+ map._onZoomTransitionEnd();
208
+ }
209
+ this._resetLineWeights();
210
+ this._resetCircleRadii();
211
+ this._toggleHide(false);
212
+ this._targetZoom = null;
213
+ this._lastFocus = null;
214
+ this._baseCenter = null;
215
+ this._baseZoom = null;
216
+ },
217
+ _animate: function() {
218
+ if (this._targetZoom === null || this._targetZoom === void 0) {
219
+ this._animationId = null;
220
+ return;
221
+ }
222
+ const map = this._map;
223
+ const current = map.getZoom();
224
+ const diff = this._targetZoom - current;
225
+ if (Math.abs(diff) < 5e-4) {
226
+ const focus2 = this._lastFocus || map.getCenter();
227
+ this._applyZoomFrame(this._targetZoom, focus2);
228
+ this._animationId = null;
229
+ return;
230
+ }
231
+ const easing = map.options.butterSmoothEasing ?? 0.38;
232
+ const next = current + diff * easing;
233
+ const focus = this._lastFocus || map.getCenter();
234
+ this._applyZoomFrame(next, focus);
235
+ this._animationId = requestAnimationFrame(this._animate.bind(this));
236
+ },
237
+ _onWheel: function(event) {
238
+ event.preventDefault();
239
+ const map = this._map;
240
+ const deltaModeFactor = event.deltaMode === 1 ? 20 : event.deltaMode === 2 ? 60 : 1;
241
+ const scaledDelta = -event.deltaY * deltaModeFactor;
242
+ const zoomScale = map.options.butterSmoothScale ?? 42e-4;
243
+ const zoomChange = scaledDelta * zoomScale;
244
+ if (this._baseCenter === null || this._baseCenter === void 0 || this._baseZoom === null || this._baseZoom === void 0) {
245
+ this._baseCenter = map.getCenter();
246
+ this._baseZoom = map.getZoom();
247
+ this._syncRenderers();
248
+ this._cacheLineWeights();
249
+ this._cacheCircleRadii();
250
+ }
251
+ const current = this._targetZoom === null || this._targetZoom === void 0 ? map.getZoom() : this._targetZoom;
252
+ this._targetZoom = clampNumber(current + zoomChange, map.getMinZoom(), map.getMaxZoom());
253
+ this._lastFocus = map.mouseEventToLatLng(event);
254
+ if (this._animationId === null || this._animationId === void 0) {
255
+ this._animationId = requestAnimationFrame(this._animate.bind(this));
256
+ }
257
+ this._toggleHide(true);
258
+ if (this._endTimer) {
259
+ clearTimeout(this._endTimer);
260
+ }
261
+ const endDelay = map.options.butterSmoothEndDelay ?? 140;
262
+ this._endTimer = setTimeout(this._finalizeZoom.bind(this), endDelay);
263
+ },
264
+ _resetLineWeights: function() {
265
+ if (!this._lineBaseWeights) {
266
+ return;
267
+ }
268
+ this._lineBaseWeights.forEach((baseWeight, layer) => {
269
+ if (layer && layer.setStyle) {
270
+ layer.setStyle({ weight: baseWeight });
271
+ }
272
+ });
273
+ },
274
+ _resetCircleRadii: function() {
275
+ if (!this._circleBaseRadii) {
276
+ return;
277
+ }
278
+ this._circleBaseRadii.forEach((baseRadius, layer) => {
279
+ this._setCircleRadius(layer, baseRadius);
280
+ });
281
+ },
282
+ _cleanup: function() {
283
+ if (this._animationId !== null && this._animationId !== void 0) {
284
+ cancelAnimationFrame(this._animationId);
285
+ }
286
+ if (this._endTimer) {
287
+ clearTimeout(this._endTimer);
288
+ }
289
+ this._toggleHide(false);
290
+ this._resetLineWeights();
291
+ this._resetCircleRadii();
292
+ this._animationId = null;
293
+ this._endTimer = null;
294
+ this._targetZoom = null;
295
+ this._lastFocus = null;
296
+ this._baseCenter = null;
297
+ this._baseZoom = null;
298
+ }
299
+ });
300
+ import_leaflet.default.Map.ButterSmoothZoom = ButterSmoothZoom;
301
+ import_leaflet.default.Map.addInitHook("addHandler", "butterSmoothZoom", import_leaflet.default.Map.ButterSmoothZoom);
@@ -0,0 +1,17 @@
1
+ declare module 'leaflet' {
2
+ interface MapOptions {
3
+ butterSmoothZoom?: boolean;
4
+ butterSmoothScale?: number;
5
+ butterSmoothEasing?: number;
6
+ butterSmoothEndDelay?: number;
7
+ butterSmoothHideVectors?: boolean;
8
+ butterSmoothButtonZoomDelta?: number;
9
+ butterSmoothZoomAnimationDuration?: number;
10
+ }
11
+ interface Map {
12
+ butterSmoothZoom?: Handler;
13
+ }
14
+ namespace Map {
15
+ let ButterSmoothZoom: typeof Handler;
16
+ }
17
+ }
@@ -0,0 +1,17 @@
1
+ declare module 'leaflet' {
2
+ interface MapOptions {
3
+ butterSmoothZoom?: boolean;
4
+ butterSmoothScale?: number;
5
+ butterSmoothEasing?: number;
6
+ butterSmoothEndDelay?: number;
7
+ butterSmoothHideVectors?: boolean;
8
+ butterSmoothButtonZoomDelta?: number;
9
+ butterSmoothZoomAnimationDuration?: number;
10
+ }
11
+ interface Map {
12
+ butterSmoothZoom?: Handler;
13
+ }
14
+ namespace Map {
15
+ let ButterSmoothZoom: typeof Handler;
16
+ }
17
+ }
package/dist/index.js ADDED
@@ -0,0 +1,274 @@
1
+ // src/index.ts
2
+ import L from "leaflet";
3
+ L.Map.mergeOptions({
4
+ butterSmoothZoom: false,
5
+ butterSmoothScale: 42e-4,
6
+ butterSmoothEasing: 0.38,
7
+ butterSmoothEndDelay: 140,
8
+ butterSmoothHideVectors: false,
9
+ butterSmoothButtonZoomDelta: 1,
10
+ butterSmoothZoomAnimationDuration: 0.125
11
+ });
12
+ var clampNumber = (value, min, max) => {
13
+ return Math.min(Math.max(value, min), max);
14
+ };
15
+ var ButterSmoothZoom = L.Handler.extend({
16
+ addHooks: function() {
17
+ const map = this._map;
18
+ const container = map.getContainer();
19
+ L.DomEvent.on(container, "wheel", this._onWheel, this);
20
+ this._prevZoomDelta = map.options.zoomDelta;
21
+ if (typeof map.options.butterSmoothButtonZoomDelta === "number") {
22
+ map.options.zoomDelta = map.options.butterSmoothButtonZoomDelta;
23
+ }
24
+ this._ensureZoomAnimationStyle();
25
+ this._addedZoomAnimClass = !container.classList.contains("butter-smooth-zoom-fast");
26
+ container.classList.add("butter-smooth-zoom-fast");
27
+ this._prevZoomAnimDuration = container.style.getPropertyValue(
28
+ "--butter-smooth-zoom-duration"
29
+ );
30
+ const duration = map.options.butterSmoothZoomAnimationDuration;
31
+ if (typeof duration === "number") {
32
+ container.style.setProperty("--butter-smooth-zoom-duration", `${duration}s`);
33
+ }
34
+ },
35
+ removeHooks: function() {
36
+ const map = this._map;
37
+ const container = map.getContainer();
38
+ L.DomEvent.off(container, "wheel", this._onWheel, this);
39
+ if (typeof this._prevZoomDelta === "number") {
40
+ map.options.zoomDelta = this._prevZoomDelta;
41
+ }
42
+ if (this._addedZoomAnimClass) {
43
+ container.classList.remove("butter-smooth-zoom-fast");
44
+ }
45
+ if (typeof this._prevZoomAnimDuration === "string") {
46
+ if (this._prevZoomAnimDuration.length > 0) {
47
+ container.style.setProperty("--butter-smooth-zoom-duration", this._prevZoomAnimDuration);
48
+ } else {
49
+ container.style.removeProperty("--butter-smooth-zoom-duration");
50
+ }
51
+ }
52
+ this._cleanup();
53
+ },
54
+ _ensureZoomAnimationStyle: function() {
55
+ if (typeof document === "undefined") {
56
+ return;
57
+ }
58
+ if (document.getElementById("butter-smooth-zoom-style")) {
59
+ return;
60
+ }
61
+ const style = document.createElement("style");
62
+ style.id = "butter-smooth-zoom-style";
63
+ style.textContent = ".butter-smooth-zoom-fast .leaflet-zoom-anim .leaflet-zoom-animated{-webkit-transition-duration:var(--butter-smooth-zoom-duration)!important;-moz-transition-duration:var(--butter-smooth-zoom-duration)!important;transition-duration:var(--butter-smooth-zoom-duration)!important;}";
64
+ document.head.appendChild(style);
65
+ },
66
+ _toggleHide: function(active) {
67
+ if (!this._map.options.butterSmoothHideVectors) {
68
+ return;
69
+ }
70
+ const container = this._map.getContainer();
71
+ if (!container) {
72
+ return;
73
+ }
74
+ container.classList.toggle("smooth-wheel-hide", active);
75
+ },
76
+ _getZoomAroundCenter: function(focus, zoom) {
77
+ const map = this._map;
78
+ const scale = map.getZoomScale(zoom);
79
+ const viewHalf = map.getSize().divideBy(2);
80
+ const containerPoint = map.latLngToContainerPoint(focus);
81
+ const centerOffset = containerPoint.subtract(viewHalf).multiplyBy(1 - 1 / scale);
82
+ return map.containerPointToLatLng(viewHalf.add(centerOffset));
83
+ },
84
+ _syncRenderers: function() {
85
+ if (!this._baseCenter || this._baseZoom === null || this._baseZoom === void 0) {
86
+ return;
87
+ }
88
+ this._map.eachLayer((layer) => {
89
+ if (layer instanceof L.Renderer) {
90
+ const renderer = layer;
91
+ renderer._center = this._baseCenter;
92
+ renderer._zoom = this._baseZoom;
93
+ }
94
+ });
95
+ },
96
+ _cacheLineWeights: function() {
97
+ if (!this._lineBaseWeights) {
98
+ this._lineBaseWeights = /* @__PURE__ */ new Map();
99
+ }
100
+ this._map.eachLayer((layer) => {
101
+ if (layer instanceof L.Path && !this._lineBaseWeights.has(layer)) {
102
+ const baseWeight = layer.options && typeof layer.options.weight === "number" ? layer.options.weight : 3;
103
+ this._lineBaseWeights.set(layer, baseWeight);
104
+ }
105
+ });
106
+ },
107
+ _cacheCircleRadii: function() {
108
+ if (!this._circleBaseRadii) {
109
+ this._circleBaseRadii = /* @__PURE__ */ new Map();
110
+ }
111
+ this._map.eachLayer((layer) => {
112
+ if (layer instanceof L.CircleMarker && !(layer instanceof L.Circle) && !this._circleBaseRadii.has(layer)) {
113
+ this._circleBaseRadii.set(layer, layer.getRadius());
114
+ }
115
+ });
116
+ },
117
+ _setCircleRadius: function(layer, radius) {
118
+ const circle = layer;
119
+ if (!circle) {
120
+ return;
121
+ }
122
+ circle.options.radius = radius;
123
+ circle._radius = radius;
124
+ if (circle._radiusY) {
125
+ circle._radiusY = radius;
126
+ }
127
+ if (circle._renderer && circle._renderer instanceof L.SVG && circle._renderer._updateCircle) {
128
+ circle._updateBounds();
129
+ circle._renderer._updateCircle(circle);
130
+ }
131
+ },
132
+ _projectCircleBase: function(layer) {
133
+ const circle = layer;
134
+ if (!circle || !this._baseCenter || this._baseZoom === null || this._baseZoom === void 0) {
135
+ return;
136
+ }
137
+ const map = this._map;
138
+ const latlng = circle.getLatLng ? circle.getLatLng() : circle._latlng;
139
+ if (!latlng) {
140
+ return;
141
+ }
142
+ circle._point = map._latLngToNewLayerPoint(latlng, this._baseZoom, this._baseCenter);
143
+ },
144
+ _applyZoomFrame: function(zoom, focus) {
145
+ const map = this._map;
146
+ const center = this._getZoomAroundCenter(focus, zoom);
147
+ map.fire("zoomanim", { center, zoom, noUpdate: true });
148
+ map._move(center, zoom, void 0, true);
149
+ this._syncRenderers();
150
+ if (!map.options.butterSmoothHideVectors && this._baseZoom !== null && this._baseZoom !== void 0) {
151
+ const scale = map.getZoomScale(zoom, this._baseZoom);
152
+ if (this._lineBaseWeights) {
153
+ this._lineBaseWeights.forEach((baseWeight, layer) => {
154
+ if (layer && layer.setStyle) {
155
+ layer.setStyle({ weight: baseWeight / scale });
156
+ }
157
+ });
158
+ }
159
+ if (this._circleBaseRadii) {
160
+ this._circleBaseRadii.forEach((baseRadius, layer) => {
161
+ this._projectCircleBase(layer);
162
+ this._setCircleRadius(layer, baseRadius / scale);
163
+ });
164
+ }
165
+ }
166
+ },
167
+ _finalizeZoom: function() {
168
+ if (this._targetZoom === null || this._targetZoom === void 0) {
169
+ return;
170
+ }
171
+ const map = this._map;
172
+ const focus = this._lastFocus || map.getCenter();
173
+ const center = this._getZoomAroundCenter(focus, this._targetZoom);
174
+ map._animatingZoom = true;
175
+ map._animateToCenter = center;
176
+ map._animateToZoom = this._targetZoom;
177
+ map.fire("zoomanim", { center, zoom: this._targetZoom, noUpdate: false });
178
+ map._move(center, this._targetZoom, void 0, true);
179
+ if (map._onZoomTransitionEnd) {
180
+ map._onZoomTransitionEnd();
181
+ }
182
+ this._resetLineWeights();
183
+ this._resetCircleRadii();
184
+ this._toggleHide(false);
185
+ this._targetZoom = null;
186
+ this._lastFocus = null;
187
+ this._baseCenter = null;
188
+ this._baseZoom = null;
189
+ },
190
+ _animate: function() {
191
+ if (this._targetZoom === null || this._targetZoom === void 0) {
192
+ this._animationId = null;
193
+ return;
194
+ }
195
+ const map = this._map;
196
+ const current = map.getZoom();
197
+ const diff = this._targetZoom - current;
198
+ if (Math.abs(diff) < 5e-4) {
199
+ const focus2 = this._lastFocus || map.getCenter();
200
+ this._applyZoomFrame(this._targetZoom, focus2);
201
+ this._animationId = null;
202
+ return;
203
+ }
204
+ const easing = map.options.butterSmoothEasing ?? 0.38;
205
+ const next = current + diff * easing;
206
+ const focus = this._lastFocus || map.getCenter();
207
+ this._applyZoomFrame(next, focus);
208
+ this._animationId = requestAnimationFrame(this._animate.bind(this));
209
+ },
210
+ _onWheel: function(event) {
211
+ event.preventDefault();
212
+ const map = this._map;
213
+ const deltaModeFactor = event.deltaMode === 1 ? 20 : event.deltaMode === 2 ? 60 : 1;
214
+ const scaledDelta = -event.deltaY * deltaModeFactor;
215
+ const zoomScale = map.options.butterSmoothScale ?? 42e-4;
216
+ const zoomChange = scaledDelta * zoomScale;
217
+ if (this._baseCenter === null || this._baseCenter === void 0 || this._baseZoom === null || this._baseZoom === void 0) {
218
+ this._baseCenter = map.getCenter();
219
+ this._baseZoom = map.getZoom();
220
+ this._syncRenderers();
221
+ this._cacheLineWeights();
222
+ this._cacheCircleRadii();
223
+ }
224
+ const current = this._targetZoom === null || this._targetZoom === void 0 ? map.getZoom() : this._targetZoom;
225
+ this._targetZoom = clampNumber(current + zoomChange, map.getMinZoom(), map.getMaxZoom());
226
+ this._lastFocus = map.mouseEventToLatLng(event);
227
+ if (this._animationId === null || this._animationId === void 0) {
228
+ this._animationId = requestAnimationFrame(this._animate.bind(this));
229
+ }
230
+ this._toggleHide(true);
231
+ if (this._endTimer) {
232
+ clearTimeout(this._endTimer);
233
+ }
234
+ const endDelay = map.options.butterSmoothEndDelay ?? 140;
235
+ this._endTimer = setTimeout(this._finalizeZoom.bind(this), endDelay);
236
+ },
237
+ _resetLineWeights: function() {
238
+ if (!this._lineBaseWeights) {
239
+ return;
240
+ }
241
+ this._lineBaseWeights.forEach((baseWeight, layer) => {
242
+ if (layer && layer.setStyle) {
243
+ layer.setStyle({ weight: baseWeight });
244
+ }
245
+ });
246
+ },
247
+ _resetCircleRadii: function() {
248
+ if (!this._circleBaseRadii) {
249
+ return;
250
+ }
251
+ this._circleBaseRadii.forEach((baseRadius, layer) => {
252
+ this._setCircleRadius(layer, baseRadius);
253
+ });
254
+ },
255
+ _cleanup: function() {
256
+ if (this._animationId !== null && this._animationId !== void 0) {
257
+ cancelAnimationFrame(this._animationId);
258
+ }
259
+ if (this._endTimer) {
260
+ clearTimeout(this._endTimer);
261
+ }
262
+ this._toggleHide(false);
263
+ this._resetLineWeights();
264
+ this._resetCircleRadii();
265
+ this._animationId = null;
266
+ this._endTimer = null;
267
+ this._targetZoom = null;
268
+ this._lastFocus = null;
269
+ this._baseCenter = null;
270
+ this._baseZoom = null;
271
+ }
272
+ });
273
+ L.Map.ButterSmoothZoom = ButterSmoothZoom;
274
+ L.Map.addInitHook("addHandler", "butterSmoothZoom", L.Map.ButterSmoothZoom);
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "leaflet-butter-smooth-zoom",
3
+ "version": "0.1.0",
4
+ "description": "Butter-smooth wheel zoom handler for Leaflet.",
5
+ "type": "module",
6
+ "main": "dist/index.cjs",
7
+ "module": "dist/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "sideEffects": true,
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js",
14
+ "require": "./dist/index.cjs"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "Leaflet.ButterSmoothZoom.js",
20
+ "README.md"
21
+ ],
22
+ "scripts": {
23
+ "build": "tsup src/index.ts --format esm,cjs --dts --clean",
24
+ "dev": "tsup src/index.ts --format esm --dts --watch"
25
+ },
26
+ "peerDependencies": {
27
+ "leaflet": "^1.9.4"
28
+ },
29
+ "devDependencies": {
30
+ "@types/leaflet": "^1.9.12",
31
+ "tsup": "^8.0.1",
32
+ "typescript": "^5.5.4"
33
+ }
34
+ }