leaflet-rotate 0.4.0 → 0.5.1

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.
Files changed (39) hide show
  1. package/README.md +85 -5
  2. package/dist/leaflet-rotate-src.js +1325 -254
  3. package/dist/leaflet-rotate-src.js.map +4 -4
  4. package/dist/leaflet-rotate.js +1 -1
  5. package/dist/leaflet-rotate.js.map +4 -4
  6. package/lib/debug.js +62 -88
  7. package/lib/debug.min.js +16 -1
  8. package/misc/leaflet-heat-rotate.js +7 -0
  9. package/package.json +1 -1
  10. package/src/control/Rotate.js +160 -103
  11. package/src/dom/DomUtil.js +23 -12
  12. package/src/dom/Draggable.js +5 -0
  13. package/src/geo/LatLngBounds.js +5 -0
  14. package/src/geometry/Bounds.js +5 -0
  15. package/src/geometry/Point.js +17 -3
  16. package/src/index.js +4 -1
  17. package/src/layer/DivOverlay.js +34 -5
  18. package/src/layer/Popup.js +14 -3
  19. package/src/layer/Tooltip.js +16 -0
  20. package/src/layer/marker/Icon.js +12 -0
  21. package/src/layer/marker/Marker.js +86 -7
  22. package/src/layer/tile/GridLayer.js +38 -3
  23. package/src/layer/vector/Renderer.js +88 -34
  24. package/src/map/Map.js +578 -116
  25. package/src/map/handler/CompassBearing.js +34 -1
  26. package/src/map/handler/ContainerMutation.js +18 -1
  27. package/src/map/handler/ShiftKeyRotate.js +204 -13
  28. package/src/map/handler/TouchGestures.js +108 -10
  29. package/src/map/handler/TouchPitch.js +53 -0
  30. package/src/map/handler/TouchRotate.js +18 -1
  31. package/src/map/handler/TouchZoom.js +21 -1
  32. package/test/features/example.feature +8 -4
  33. package/test/features/gestures.feature +4 -0
  34. package/test/features/rotation.feature +60 -0
  35. package/test/index.html +342 -186
  36. package/test/steps/bearing.steps.js +535 -0
  37. package/test/steps/example.steps.js +56 -29
  38. package/test/steps/gestures.steps.js +55 -0
  39. package/test/support/hooks.js +4 -16
package/src/map/Map.js CHANGED
@@ -1,17 +1,73 @@
1
1
  /**
2
+ * Extends Leaflet maps with bearing rotation and 3D pitch.
3
+ *
2
4
  * @external L.Map
3
5
  *
6
+ * @since leaflet-rotate (v0.1.0)
7
+ *
4
8
  * @see https://github.com/Leaflet/Leaflet/blob/v1.9.3/src/map/Map.js
5
9
  */
6
10
 
11
+ /**
12
+ * @event L.Map#rotate Fired after the map bearing changes.
13
+ *
14
+ * @since leaflet-rotate (v0.1.0)
15
+ */
16
+
17
+ /**
18
+ * @event L.Map#pitch Fired after the map pitch changes.
19
+ *
20
+ * @since leaflet-rotate (0.5.0)
21
+ */
22
+
7
23
  import { toPoint } from '../geometry/Point';
8
24
 
9
25
  const mapProto = Object.defineProperties({}, Object.getOwnPropertyDescriptors(L.Map.prototype));
10
26
 
11
- L.Map.mergeOptions({ rotate: false, bearing: 0, });
27
+ L.Map.mergeOptions({
28
+
29
+ /**
30
+ * @type {boolean} Enable bearing and pitch transformations.
31
+ *
32
+ * @since leaflet-rotate (v0.1.0)
33
+ */
34
+ rotate: false,
35
+
36
+ /**
37
+ * @type {number} Initial map bearing in degrees.
38
+ *
39
+ * @since leaflet-rotate (v0.1.0)
40
+ */
41
+ bearing: 0,
42
+
43
+ /**
44
+ * @type {number} Initial map pitch in degrees, clamped to 0–85.
45
+ *
46
+ * @since leaflet-rotate (0.5.0)
47
+ */
48
+ pitch: 0,
49
+
50
+ /**
51
+ * @type {number} Maximum distance of the pitched map plane from the map center, in multiples
52
+ * of the map height. Tiles, vectors and `getBounds()` stop there (and always
53
+ * before the horizon); a `.leaflet-pitch-sky` element fills the area above.
54
+ *
55
+ * @since leaflet-rotate (0.5.1)
56
+ */
57
+ pitchMaxDistance: 3,
58
+
59
+ });
12
60
 
13
61
  L.Map.include({
14
62
 
63
+ /**
64
+ * Apply an immediate animated-zoom frame without waiting for a transition delay.
65
+ *
66
+ * @param {L.LatLng} center Target center.
67
+ * @param {number} zoom Target zoom level.
68
+ *
69
+ * @since leaflet-rotate (v0.1.0)
70
+ */
15
71
  _animateZoomNoDelay(center, zoom) {
16
72
  if (!this._mapPane) { return; }
17
73
 
@@ -23,17 +79,25 @@ L.Map.include({
23
79
  },
24
80
 
25
81
  /**
26
- * @param {(HTMLElement|String)} id html selector
27
- * @param {Object} [options={}] leaflet map options
82
+ * Initialize the Leaflet map and apply its initial bearing and pitch.
83
+ *
84
+ * @param {HTMLElement|string} id Map container element or its ID.
85
+ * @param {Object} [options={}] Leaflet map options, including `rotate`, `bearing`, and `pitch`.
86
+ *
87
+ * @since leaflet-rotate (v0.1.0)
28
88
  */
29
89
  initialize(id, options) {
30
90
  if (options.rotate) {
31
91
  this._rotate = true;
32
92
  this._bearing = 0;
93
+ this._pitch = 0;
33
94
  }
34
95
  mapProto.initialize.apply(this, arguments);
35
- if(this.options.rotate){
36
- this.setBearing(this.options.bearing);
96
+ if (this.options.rotate) {
97
+ this.on('resize', this._updatePitchTransform, this);
98
+ this.on('move', this._updatePitchSky, this);
99
+ this.setBearing(this.options.bearing);
100
+ this.setPitch(this.options.pitch);
37
101
  }
38
102
  },
39
103
 
@@ -42,13 +106,22 @@ L.Map.include({
42
106
  * returns the corresponding pixel coordinate relative to
43
107
  * the [origin pixel](#map-getpixelorigin).
44
108
  *
45
- * @param {L.Point} point pixel screen coordinates
46
- * @returns {L.Point} transformed pixel point
109
+ * @param {L.Point|number[]} point Pixel coordinate in the map container.
110
+ *
111
+ * @returns {L.Point} Corresponding layer-point coordinate.
112
+ *
113
+ * @since leaflet-rotate (v0.1.0)
47
114
  */
48
115
  containerPointToLayerPoint(point) {
49
116
  if (!this._rotate) {
50
117
  return mapProto.containerPointToLayerPoint.apply(this, arguments);
51
118
  }
119
+ if (this._pitch) {
120
+ return this._transformPitchPoint(
121
+ toPoint(point).subtract(this._getMapPanePos()),
122
+ this._pitchInverseTransform
123
+ );
124
+ }
52
125
  return toPoint(point)
53
126
  .subtract(this._getMapPanePos())
54
127
  .rotateFrom(-this._bearing, this._getRotatePanePos())
@@ -59,13 +132,19 @@ L.Map.include({
59
132
  * Given a pixel coordinate relative to the [origin pixel](#map-getpixelorigin),
60
133
  * returns the corresponding pixel coordinate relative to the map container.
61
134
  *
62
- * @param {L.Point} point pixel screen coordinates
63
- * @returns {L.Point} transformed pixel point
135
+ * @param {L.Point|number[]} point Pixel coordinate in layer-point space.
136
+ *
137
+ * @returns {L.Point} Corresponding pixel coordinate in the map container.
138
+ *
139
+ * @since leaflet-rotate (v0.1.0)
64
140
  */
65
141
  layerPointToContainerPoint(point) {
66
142
  if (!this._rotate) {
67
143
  return mapProto.layerPointToContainerPoint.apply(this, arguments);
68
144
  }
145
+ if (this._pitch) {
146
+ return this._transformPitchPoint(point, this._pitchTransform).add(this._getMapPanePos());
147
+ }
69
148
  return toPoint(point)
70
149
  .add(this._getRotatePanePos())
71
150
  .rotateFrom(this._bearing, this._getRotatePanePos())
@@ -79,12 +158,16 @@ L.Map.include({
79
158
  * (rotatePane) --> (mapPane)
80
159
  * (rotatePane) --> (norotatePane)
81
160
  *
82
- * @param {L.Point} point pixel screen coordinates
83
- * @returns {L.Point}
161
+ * @param {L.Point|number[]} point Point in the rotated pane.
84
162
  *
85
- * @since leaflet-rotate (v0.1)
163
+ * @returns {L.Point} Corresponding point in map-pane coordinates.
164
+ *
165
+ * @since leaflet-rotate (v0.1.0)
86
166
  */
87
167
  rotatedPointToMapPanePoint(point) {
168
+ if (this._pitch) {
169
+ return this._transformPitchPoint(point, this._pitchTransform);
170
+ }
88
171
  return toPoint(point)
89
172
  .rotate(this._bearing)
90
173
  ._add(this._getRotatePanePos());
@@ -97,11 +180,16 @@ L.Map.include({
97
180
  * (mapPane) --> (rotatePane)
98
181
  * (norotatePane) --> (rotatePane)
99
182
  *
100
- * @param {L.Point} point pixel screen coordinates
183
+ * @param {L.Point|number[]} point Point in map-pane coordinates.
184
+ *
185
+ * @returns {L.Point} Corresponding point in the rotated pane.
101
186
  *
102
- * @since leaflet-rotate (v0.1)
187
+ * @since leaflet-rotate (v0.1.0)
103
188
  */
104
189
  mapPanePointToRotatedPoint(point) {
190
+ if (this._pitch) {
191
+ return this._transformPitchPoint(point, this._pitchInverseTransform);
192
+ }
105
193
  return toPoint(point)
106
194
  ._subtract(this._getRotatePanePos())
107
195
  .rotate(-this._bearing);
@@ -122,10 +210,11 @@ L.Map.include({
122
210
  *
123
211
  * @see https://github.com/ronikar/Leaflet/blob/5c480ef959b947c3beed7065425a5a36c486262b/src/map/Map.js#L1114-L1135
124
212
  *
125
- * @param {L.LatLngBounds} bounds
126
- * @returns {L.Bounds}
213
+ * @param {L.LatLngBounds} bounds Geographic bounds to project.
127
214
  *
128
- * @since leaflet-rotate (v0.2)
215
+ * @returns {L.Bounds} Axis-aligned bounds in container-pixel coordinates.
216
+ *
217
+ * @since leaflet-rotate (v0.2.0)
129
218
  */
130
219
  mapBoundsToContainerBounds(bounds) {
131
220
  if (!this._rotate && mapProto.mapBoundsToContainerBounds) {
@@ -151,14 +240,13 @@ L.Map.include({
151
240
  },
152
241
 
153
242
  /**
154
- * Returns geographical bounds visible in the current map view
243
+ * Return the geographic bounds of the current viewport, accounting for rotation and pitch.
155
244
  *
156
- * @TODO find out if map bounds calculated by `L.Map::getBounds()`
157
- * function should match the `rotatePane` or `norotatePane` bounds
245
+ * The result is the smallest geographic bounds containing all four viewport corners.
158
246
  *
159
- * @see https://github.com/fnicollet/Leaflet/issues/7
247
+ * @returns {L.LatLngBounds} Geographic bounds visible in the map viewport.
160
248
  *
161
- * @returns {L.LatLngBounds}
249
+ * @since leaflet-rotate (v0.1.0)
162
250
  */
163
251
  getBounds() {
164
252
  if (!this._rotate) {
@@ -175,15 +263,66 @@ L.Map.include({
175
263
 
176
264
  // LatLngBounds' constructor automatically
177
265
  // extends the bounds to fit the passed points
178
- const size = this.getSize();
266
+ const [min, max] = this._getPitchedContainerBounds(0);
179
267
  return new L.LatLngBounds([
180
- this.containerPointToLatLng([0, 0]), // topleft
181
- this.containerPointToLatLng([size.x, 0]), // topright
182
- this.containerPointToLatLng([size.x, size.y]), // bottomright
183
- this.containerPointToLatLng([0, size.y]), // bottomleft
268
+ this.containerPointToLatLng([min.x, min.y]), // topleft
269
+ this.containerPointToLatLng([max.x, min.y]), // topright
270
+ this.containerPointToLatLng([max.x, max.y]), // bottomright
271
+ this.containerPointToLatLng([min.x, max.y]), // bottomleft
184
272
  ]);
185
273
  },
186
274
 
275
+ /**
276
+ * Return the container rectangle covering the visible map plane, optionally padded by a
277
+ * fraction of the map size. When pitched, its top is cut at the far plane (see `pitchMaxDistance`)
278
+ * so that it never reaches the horizon, where the plane would extend to infinity.
279
+ *
280
+ * @param {number} padding Padding as a fraction of the map size.
281
+ *
282
+ * @returns {L.Point[]} Top-left and bottom-right container points.
283
+ *
284
+ * @since leaflet-rotate (0.5.1)
285
+ */
286
+ _getPitchedContainerBounds(padding) {
287
+ const size = this.getSize(),
288
+ min = size.multiplyBy(-padding),
289
+ max = size.multiplyBy(1 + padding);
290
+ if (this._pitch) {
291
+ min.y = Math.min(Math.max(min.y, this._getPitchFarY()), max.y);
292
+ }
293
+ return [min, max];
294
+ },
295
+
296
+ /**
297
+ * Return the container y coordinate of the far plane: points above it are farther than
298
+ * `pitchMaxDistance` from the map center, or beyond the horizon.
299
+ *
300
+ * @returns {number} Container y coordinate (`-Infinity` when the whole map is close enough).
301
+ *
302
+ * @since leaflet-rotate (0.5.1)
303
+ */
304
+ _getPitchFarY() {
305
+ const size = this.getSize(),
306
+ inverse = this._pitchInverseTransform,
307
+ panePos = this._getMapPanePos(),
308
+ x = size.x / 2 - panePos.x,
309
+ centerY = size.y / 2 - panePos.y,
310
+ maxDistance = this.options.pitchMaxDistance * size.y,
311
+ w = y => inverse.g * x + inverse.h * y + inverse.i,
312
+ centerW = w(centerY),
313
+ center = this._transformPitchPoint([x, centerY], inverse),
314
+ // the horizon is horizontal on screen (bearing is applied before the pitch)
315
+ isNear = y => w(y) * centerW > 0 && this._transformPitchPoint([x, y], inverse).distanceTo(center) <= maxDistance;
316
+
317
+ let far = -size.y - panePos.y, near = centerY;
318
+ if (isNear(far)) { return -Infinity; }
319
+ for (let i = 0; i < 20; i++) {
320
+ const mid = (far + near) / 2;
321
+ if (isNear(mid)) { near = mid; } else { far = mid; }
322
+ }
323
+ return near + panePos.y;
324
+ },
325
+
187
326
  /**
188
327
  * Returns the bounds of the current map view in projected pixel
189
328
  * coordinates (sometimes useful in layer and overlay implementations).
@@ -202,86 +341,358 @@ L.Map.include({
202
341
  // },
203
342
 
204
343
  /**
205
- * Change map rotation
344
+ * Set the map bearing, optionally animating to the new value.
345
+ *
346
+ * @param {number} theta Target bearing in degrees; normalized to [0, 360).
347
+ * @param {Object} [options] Animation options.
348
+ * @param {boolean} [options.animate=false] Animate the change.
349
+ * @param {number} [options.duration=0.25] Animation duration in seconds.
350
+ * @param {number} [options.easeLinearity=0.5] Easing curve linearity.
206
351
  *
207
- * @param {number} theta map degrees
208
- * @param {Object} [options] animation options
209
- * @param {boolean} [options.animate=false]
210
- * @param {number} [options.duration=0.25] animation duration in seconds
211
- * @param {number} [options.easeLinearity=0.5]
352
+ * @returns {void}
212
353
  *
213
- * @since leaflet-rotate (v0.1)
354
+ * @fires L.Map#rotate
355
+ *
356
+ * @since leaflet-rotate (v0.1.0)
214
357
  */
215
358
  setBearing(theta, options) {
216
359
  if (!this._rotate) { return; }
217
360
 
218
361
  if (options && options.animate) {
219
- this._bearingAnimationId = (this._bearingAnimationId || 0) + 1;
220
- const animationId = this._bearingAnimationId;
221
- if (this._bearingAnimationFrame) {
222
- cancelAnimationFrame(this._bearingAnimationFrame);
223
- }
224
-
225
- const startBearing = this.getBearing(),
226
- endBearing = L.Util.wrapNum(theta, [0, 360]),
227
- delta = ((endBearing - startBearing + 540) % 360) - 180,
228
- duration = Math.max(0, options.duration == null ? 0.25 : options.duration) * 1000,
229
- easeLinearity = Math.max(options.easeLinearity || 0.5, 0.2),
230
- startTime = performance.now();
231
-
232
- const animate = function() {
233
- if (!this._mapPane) {
234
- delete this._bearingAnimationFrame;
235
- return;
236
- }
237
-
238
- const progress = duration ? Math.min((performance.now() - startTime) / duration, 1) : 1,
239
- eased = 1 - Math.pow(1 - progress, 1 / easeLinearity);
240
-
241
- this._setBearing(progress === 1 ? endBearing : startBearing + delta * eased);
242
- if (this._bearingAnimationId !== animationId) { return; }
243
- if (progress < 1) {
244
- this._bearingAnimationFrame = requestAnimationFrame(animate.bind(this));
245
- } else {
246
- delete this._bearingAnimationFrame;
247
- }
248
- };
249
-
250
- this._bearingAnimationFrame = requestAnimationFrame(animate.bind(this));
362
+ this._animatePitchBearing(this.getPitch(), theta, options);
251
363
  return;
252
364
  }
253
365
 
366
+ this._stopPitchBearingAnimation();
367
+ this._setBearing(theta);
368
+ },
369
+
370
+ /**
371
+ * Animate pitch and bearing together (bearing follows the shortest path).
372
+ *
373
+ * @param {number} pitch Target pitch in degrees.
374
+ * @param {number} bearing Target bearing in degrees.
375
+ * @param {Object} [options] Animation options.
376
+ * @param {number} [options.duration=0.25] Animation duration in seconds.
377
+ * @param {number} [options.easeLinearity=0.5] Easing curve linearity.
378
+ *
379
+ * @since leaflet-rotate (0.5.1)
380
+ */
381
+ _animatePitchBearing(pitch, bearing, options) {
382
+ this._stopPitchBearingAnimation();
383
+ const animationId = this._bearingAnimationId;
384
+
385
+ const startBearing = this.getBearing(),
386
+ startPitch = this.getPitch(),
387
+ endBearing = L.Util.wrapNum(bearing, [0, 360]),
388
+ endPitch = Math.max(0, Math.min(pitch, 85)),
389
+ delta = ((endBearing - startBearing + 540) % 360) - 180,
390
+ duration = Math.max(0, options.duration == null ? 0.25 : options.duration) * 1000,
391
+ easeLinearity = Math.max(options.easeLinearity || 0.5, 0.2),
392
+ startTime = performance.now();
393
+
394
+ const animate = function() {
395
+ if (!this._mapPane || this._bearingAnimationId !== animationId) {
396
+ return;
397
+ }
398
+
399
+ const progress = duration ? Math.min((performance.now() - startTime) / duration, 1) : 1,
400
+ eased = 1 - Math.pow(1 - progress, 1 / easeLinearity);
401
+
402
+ if (progress < 1) {
403
+ this._bearingAnimationFrame = requestAnimationFrame(animate.bind(this));
404
+ } else {
405
+ delete this._bearingAnimationFrame;
406
+ }
407
+ this._setPitchAndBearing(
408
+ progress === 1 ? endPitch : startPitch + (endPitch - startPitch) * eased,
409
+ progress === 1 ? endBearing : startBearing + delta * eased
410
+ );
411
+ };
412
+
413
+ this._bearingAnimationFrame = requestAnimationFrame(animate.bind(this));
414
+ },
415
+
416
+ /**
417
+ * Cancel a running pitch/bearing animation.
418
+ *
419
+ * @since leaflet-rotate (0.5.1)
420
+ */
421
+ _stopPitchBearingAnimation() {
254
422
  this._bearingAnimationId = (this._bearingAnimationId || 0) + 1;
255
423
  if (this._bearingAnimationFrame) {
256
424
  cancelAnimationFrame(this._bearingAnimationFrame);
257
425
  delete this._bearingAnimationFrame;
258
426
  }
427
+ },
259
428
 
260
- this._setBearing(theta);
429
+ /**
430
+ * Rotate the map back to north (bearing 0), animated by default.
431
+ *
432
+ * @param {Object} [options] Animation options (`animate`, `duration`, `easeLinearity`).
433
+ *
434
+ * @returns {L.Map} This map instance.
435
+ *
436
+ * @since leaflet-rotate (0.5.1)
437
+ */
438
+ resetNorth(options) {
439
+ options = Object.assign({ animate: true, duration: 0.5 }, options);
440
+ if (options.animate) {
441
+ this._animatePitchBearing(this.getPitch(), 0, options);
442
+ } else {
443
+ this.setBearing(0);
444
+ }
445
+ return this;
261
446
  },
262
447
 
263
- _setBearing(theta) {
448
+ /**
449
+ * Rotate the map back to north and remove the pitch, animated by default.
450
+ *
451
+ * @param {Object} [options] Animation options (`animate`, `duration`, `easeLinearity`).
452
+ *
453
+ * @returns {L.Map} This map instance.
454
+ *
455
+ * @since leaflet-rotate (0.5.1)
456
+ */
457
+ resetNorthPitch(options) {
458
+ options = Object.assign({ animate: true, duration: 0.5 }, options);
459
+ if (options.animate) {
460
+ this._animatePitchBearing(0, 0, options);
461
+ } else {
462
+ this._stopPitchBearingAnimation();
463
+ this._setPitchAndBearing(0, 0);
464
+ }
465
+ return this;
466
+ },
467
+
468
+ /**
469
+ * Apply a bearing immediately and update the rotated pane and map coordinates.
470
+ *
471
+ * @param {number} theta Bearing in degrees.
472
+ * @param {L.LatLng} [mapCenter] Center to preserve while applying the change.
473
+ *
474
+ * @fires L.Map#rotate
475
+ *
476
+ * @since leaflet-rotate (v0.1.0)
477
+ */
478
+ _setBearing(theta, mapCenter) {
479
+ if (!mapCenter && this._loaded) {
480
+ mapCenter = this.getCenter();
481
+ }
482
+
264
483
  const bearing = L.Util.wrapNum(theta, [0, 360]) * (Math.PI / 180),
265
484
  center = this._getPixelCenter(),
266
485
  oldPos = this._getRotatePanePos().rotateFrom(-this._bearing, center),
267
486
  newPos = oldPos.rotateFrom(bearing, center);
268
487
 
269
- // CSS transform
270
- L.DomUtil.setPosition(this._rotatePane, oldPos, bearing, center);
271
-
272
488
  this._pivot = center;
273
489
  this._bearing = bearing;
274
490
  this._rotatePanePos = newPos;
275
491
 
492
+ if (this._pitch) {
493
+ this._rotatePane._leaflet_pos = oldPos;
494
+ this._updatePitchTransform();
495
+ } else {
496
+ L.DomUtil.setPosition(this._rotatePane, oldPos, bearing, center);
497
+ }
498
+
499
+ if (mapCenter) {
500
+ this._pixelOrigin = this.project(mapCenter, this._zoom)
501
+ .subtract(this.containerPointToLayerPoint(this.getSize().divideBy(2)));
502
+ delete this._lastCenter;
503
+ this.fire('move');
504
+ }
505
+
276
506
  this.fire('rotate');
277
507
  },
278
508
 
279
509
  /**
280
- * Get current map rotation
510
+ * Transform a point with a projective matrix, accepting DOMMatrix or its 2D inverse.
511
+ *
512
+ * @param {L.Point|number[]} point Input pixel coordinate.
513
+ * @param {DOMMatrix|Object} matrix Forward or inverse transformation matrix.
514
+ *
515
+ * @returns {L.Point} Transformed pixel coordinate.
516
+ *
517
+ * @since leaflet-rotate (0.5.0)
518
+ */
519
+ _transformPitchPoint(point, matrix) {
520
+ point = toPoint(point);
521
+ if (!(matrix instanceof DOMMatrix)) {
522
+ const denominator = matrix.g * point.x + matrix.h * point.y + matrix.i;
523
+ return new L.Point(
524
+ (matrix.a * point.x + matrix.b * point.y + matrix.c) / denominator,
525
+ (matrix.d * point.x + matrix.e * point.y + matrix.f) / denominator
526
+ );
527
+ }
528
+ const transformed = new DOMPoint(point.x, point.y).matrixTransform(matrix);
529
+ return new L.Point(transformed.x / transformed.w, transformed.y / transformed.w);
530
+ },
531
+
532
+ /**
533
+ * Apply the current pitch/bearing CSS transform and cache its forward and inverse matrices.
534
+ *
535
+ * @since leaflet-rotate (0.5.0)
536
+ */
537
+ _updatePitchTransform() {
538
+ if (!this._pitch || !this._rotatePane) { return; }
539
+
540
+ const pivot = this._getPixelCenter(),
541
+ size = this.getSize(),
542
+ perspective = Math.max(1, size.y * 1.5),
543
+ position = this._rotatePane._leaflet_pos || new L.Point(0, 0),
544
+ transform = `translate3d(${pivot.x}px,${pivot.y}px,0) perspective(${perspective}px) rotateX(${this.getPitch()}deg) rotateZ(${this.getBearing()}deg) translate3d(${position.x - pivot.x}px,${position.y - pivot.y}px,0)`;
545
+
546
+ this._rotatePane.style.transformOrigin = '0 0';
547
+ this._rotatePane.style.transform = transform;
548
+ // parse the transform directly: getComputedStyle() would force a style recalculation on every frame
549
+ const matrix = this._pitchTransform = new DOMMatrix(transform),
550
+ a = matrix.m11, b = matrix.m21, c = matrix.m41,
551
+ d = matrix.m12, e = matrix.m22, f = matrix.m42,
552
+ g = matrix.m14, h = matrix.m24, i = matrix.m44,
553
+ determinant = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g);
554
+
555
+ this._pitchInverseTransform = {
556
+ a: (e * i - f * h) / determinant,
557
+ b: (c * h - b * i) / determinant,
558
+ c: (b * f - c * e) / determinant,
559
+ d: (f * g - d * i) / determinant,
560
+ e: (a * i - c * g) / determinant,
561
+ f: (c * d - a * f) / determinant,
562
+ g: (d * h - e * g) / determinant,
563
+ h: (b * g - a * h) / determinant,
564
+ i: (a * e - b * d) / determinant,
565
+ };
566
+ this._updatePitchSky();
567
+ },
568
+
569
+ /**
570
+ * Fill the area above the far plane (where no tiles are loaded) with a sky gradient.
571
+ * Style it through the `.leaflet-pitch-sky` class.
572
+ *
573
+ * @since leaflet-rotate (0.5.1)
574
+ */
575
+ _updatePitchSky() {
576
+ const farY = this._pitch && this._pitchInverseTransform ? this._getPitchFarY() : -Infinity;
577
+ if (farY <= 0) {
578
+ if (this._pitchSky) { this._pitchSky.style.display = 'none'; }
579
+ return;
580
+ }
581
+ if (!this._pitchSky) {
582
+ this._pitchSky = L.DomUtil.create('div', 'leaflet-pitch-sky', this._container);
583
+ Object.assign(this._pitchSky.style, {
584
+ position: 'absolute', left: 0, top: 0, width: '100%', zIndex: 0, pointerEvents: 'none',
585
+ background: 'linear-gradient(#9ec5e8, #dfe9f0)',
586
+ });
587
+ }
588
+ this._pitchSky.style.display = '';
589
+ // overlap the far edge slightly to hide antialiasing gaps
590
+ this._pitchSky.style.height = `${Math.ceil(farY) + 1}px`;
591
+ },
592
+
593
+ /**
594
+ * Set the map pitch in degrees. Values are clamped to the range 0–85.
595
+ *
596
+ * @param {number} pitch Pitch angle in degrees; clamped to [0, 85].
597
+ * @param {Object} [options] Animation options (`animate`, `duration`, `easeLinearity`).
598
+ *
599
+ * @returns {L.Map} This map instance.
600
+ *
601
+ * @fires L.Map#pitch
602
+ *
603
+ * @since leaflet-rotate (0.5.0)
604
+ */
605
+ setPitch(pitch, options) {
606
+ if (options && options.animate) {
607
+ this._animatePitchBearing(pitch, this.getBearing(), options);
608
+ return this;
609
+ }
610
+ this._stopPitchBearingAnimation();
611
+ this._setPitchAndBearing(pitch, this.getBearing());
612
+ return this;
613
+ },
614
+
615
+ /**
616
+ * Update pitch and bearing together, preserving the current map center.
617
+ * Used by the Ctrl+drag handler to avoid applying two separate transforms.
618
+ *
619
+ * @param {number} pitch Pitch angle in degrees.
620
+ * @param {number} bearing Bearing angle in degrees.
621
+ *
622
+ * @fires L.Map#pitch
623
+ * @fires L.Map#rotate
624
+ *
625
+ * @since leaflet-rotate (0.5.0)
626
+ */
627
+ _setPitchAndBearing(pitch, bearing) {
628
+ if (!this._rotate) { return; }
629
+
630
+ const clampedPitch = Math.max(0, Math.min(pitch, 85)) * (Math.PI / 180),
631
+ clampedBearing = L.Util.wrapNum(bearing, [0, 360]) * (Math.PI / 180),
632
+ pitchChanged = clampedPitch !== this._pitch;
633
+ if (!pitchChanged && clampedBearing === this._bearing) { return; }
634
+
635
+ const mapCenter = this._loaded ? this.getCenter() : undefined;
636
+ if (pitchChanged) {
637
+ this._pitch = clampedPitch;
638
+ this._rotatePane.style.transformOrigin = L.version.startsWith('2.') ? '0 0' : '';
639
+ }
640
+ this._setBearing(bearing, mapCenter);
641
+ if (pitchChanged) {
642
+ if (!this._pitch) { this._updatePitchSky(); }
643
+ this.fire('pitch');
644
+ }
645
+ },
646
+
647
+ /**
648
+ * Get the current map pitch in degrees.
649
+ *
650
+ * @returns {number} Pitch angle in degrees.
651
+ *
652
+ * @since leaflet-rotate (0.5.0)
653
+ */
654
+ getPitch() {
655
+ return (this._pitch || 0) * (180 / Math.PI);
656
+ },
657
+
658
+ /**
659
+ * Zoom while keeping the given point fixed on screen, also when the map is pitched.
660
+ *
661
+ * Leaflet interpolates in container space, which is not linear under perspective;
662
+ * interpolating in layer space keeps the point under the cursor.
663
+ *
664
+ * @param {L.LatLng|L.Point} latlng Geographic point or container point to keep fixed.
665
+ * @param {number} zoom Target zoom level.
666
+ * @param {Object} [options] Zoom options.
667
+ *
668
+ * @returns {L.Map} This map instance.
669
+ *
670
+ * @since leaflet-rotate (0.5.1)
671
+ */
672
+ setZoomAround(latlng, zoom, options) {
673
+ if (!this._pitch) {
674
+ return mapProto.setZoomAround.apply(this, arguments);
675
+ }
676
+ const scale = this.getZoomScale(zoom),
677
+ size = this.getSize(),
678
+ center = this.containerPointToLayerPoint(size.divideBy(2)),
679
+ containerPoint = latlng instanceof L.Point ? latlng : this.latLngToContainerPoint(latlng),
680
+ point = this.containerPointToLayerPoint(containerPoint),
681
+ newCenter = this.layerPointToLatLng(center.add(point.subtract(center).multiplyBy(1 - 1 / scale)));
682
+ options = Object.assign({}, options);
683
+ // Leaflet skips the animation when the origin is "far" in layer space, which is always the case near the horizon.
684
+ if (options.animate === undefined && new L.Bounds(new L.Point(0, 0), size).contains(containerPoint)) {
685
+ options.animate = true;
686
+ }
687
+ return this.setView(newCenter, zoom, { zoom: options });
688
+ },
689
+
690
+ /**
691
+ * Get the current map bearing. Enable the `rotate` map option before calling.
281
692
  *
282
- * @returns {number} theta map degrees
693
+ * @returns {number} Bearing in degrees, normalized to [0, 360).
283
694
  *
284
- * @since leaflet-rotate (v0.1)
695
+ * @since leaflet-rotate (v0.1.0)
285
696
  */
286
697
  getBearing() {
287
698
  return this._bearing * (180 / Math.PI);
@@ -294,7 +705,10 @@ L.Map.include({
294
705
  *
295
706
  * @param {String} name leaflet pane
296
707
  * @param {HTMLElement} [container] parent element
708
+ *
297
709
  * @returns {HTMLElement} pane container
710
+ *
711
+ * @since leaflet-rotate (v0.1.0)
298
712
  */
299
713
  // createPane(name, container) {
300
714
  // if (!this._rotate || name == 'mapPane') {
@@ -325,6 +739,12 @@ L.Map.include({
325
739
  * - tooltipPane [HTMLElement = 650] - Pane for tooltips.
326
740
  * - popupPane [HTMLElement = 700] - Pane for popups.
327
741
  */
742
+ /**
743
+ * Create the map's panes, placing tile/vector layers in the transformed pane
744
+ * and markers/overlays in the pane that remains upright.
745
+ *
746
+ * @since leaflet-rotate (v0.1.0)
747
+ */
328
748
  _initPanes() {
329
749
  const panes = this._panes = {};
330
750
  this._paneRenderers = {};
@@ -369,13 +789,15 @@ L.Map.include({
369
789
  *
370
790
  * @see https://github.com/Raruto/leaflet-rotate/issues/18
371
791
  *
372
- * @param {L.LatLng} latlng coordinates
373
- * @param {Object} [options={}] padding options
792
+ * @param {L.LatLng} latlng Geographic point to keep visible.
793
+ * @param {Object} [options={}] Padding options (`padding`, `paddingTopLeft`, `paddingBottomRight`).
374
794
  *
375
795
  * @returns {L.Map} current map instance
796
+ *
797
+ * @since leaflet-rotate (v0.1.4)
376
798
  */
377
799
  panInside(latlng, options) {
378
- if (!this._rotate || Math.abs(this._bearing).toFixed(1) < 0.1) {
800
+ if (!this._rotate || (!this._pitch && Math.abs(this._bearing).toFixed(1) < 0.1)) {
379
801
  return mapProto.panInside.apply(this, arguments);
380
802
  }
381
803
 
@@ -419,9 +841,12 @@ L.Map.include({
419
841
  *
420
842
  * @see https://github.com/fnicollet/Leaflet/issues/7
421
843
  *
422
- * @param {L.LatLngBounds} bounds coordinates
423
- * @param {Object} [options] pan options
424
- * @returns {L.Map} current map instance
844
+ * @param {L.LatLngBounds} bounds Geographic bounds to fit inside the viewport.
845
+ * @param {Object} [options] Pan animation options.
846
+ *
847
+ * @returns {L.Map} This map instance.
848
+ *
849
+ * @since leaflet-rotate (v0.1.0)
425
850
  */
426
851
  // panInsideBounds(bounds, options) {
427
852
  // this._enforcingBounds = true;
@@ -490,14 +915,16 @@ L.Map.include({
490
915
  * instead returns the minimum zoom level on which the map view fits into
491
916
  * the given bounds in its entirety.
492
917
  *
493
- * @param {L.LatLngBounds} bounds
494
- * @param {Boolean} [inside=false]
495
- * @param {L.Point} [padding=[0,0]]
918
+ * @param {L.LatLngBounds} bounds Geographic bounds to fit.
919
+ * @param {boolean} [inside=false] Fit the viewport inside bounds instead of bounds inside the viewport.
920
+ * @param {L.Point|number[]} [padding=[0,0]] Pixel padding around the viewport.
921
+ *
922
+ * @returns {number} Zoom level satisfying the fit.
496
923
  *
497
- * @returns {Number} zoom level
924
+ * @since leaflet-rotate (v0.1.0)
498
925
  */
499
926
  getBoundsZoom(bounds, inside, padding) {
500
- if (!this._rotate || Math.abs(this._bearing).toFixed(1) < 0.1) {
927
+ if (!this._rotate || (!this._pitch && Math.abs(this._bearing).toFixed(1) < 0.1)) {
501
928
  return mapProto.getBoundsZoom.apply(this, arguments);
502
929
  }
503
930
 
@@ -539,20 +966,27 @@ L.Map.include({
539
966
  // },
540
967
 
541
968
  /**
542
- * Offset of the specified place to the current center in pixels
969
+ * Return the pixel offset from the current center to the given geographic point.
543
970
  *
544
- * @param {L.LatLng} latlng map coordinates
971
+ * @param {L.LatLng} latlng Geographic point.
972
+ *
973
+ * @returns {L.Point} Offset in container pixels (the map pane is panned in screen space), adjusted for bearing and pitch.
974
+ *
975
+ * @since leaflet-rotate (v0.1.0)
545
976
  */
546
977
  _getCenterOffset(latlng) {
547
- let centerOffset = mapProto._getCenterOffset.apply(this, arguments);
548
- if (this._rotate) {
549
- centerOffset = centerOffset.rotate(this._bearing);
978
+ if (!this._rotate) {
979
+ return mapProto._getCenterOffset.apply(this, arguments);
550
980
  }
551
- return centerOffset;
981
+ return this.latLngToContainerPoint(latlng)._subtract(this.getSize()._divideBy(2));
552
982
  },
553
983
 
554
984
  /**
555
- * @since leaflet-rotate (v0.1)
985
+ * Return the current position of the rotated pane.
986
+ *
987
+ * @returns {L.Point} Rotated-pane position in map-pane coordinates.
988
+ *
989
+ * @since leaflet-rotate (v0.1.0)
556
990
  */
557
991
  _getRotatePanePos() {
558
992
  return this._rotatePanePos || new L.Point(0, 0);
@@ -564,22 +998,36 @@ L.Map.include({
564
998
  // return this.project(latlng, zoom)._subtract(topLeft);
565
999
  //},
566
1000
 
1001
+ /**
1002
+ * Calculate the pixel origin for a target view while preserving bearing-aware alignment.
1003
+ *
1004
+ * @param {L.LatLng} center Target center.
1005
+ * @param {number} zoom Target zoom level.
1006
+ *
1007
+ * @returns {L.Point} New pixel origin.
1008
+ *
1009
+ * @since leaflet-rotate (v0.1.0)
1010
+ */
567
1011
  _getNewPixelOrigin(center, zoom) {
568
- if (!this._rotate || L.version.startsWith('2.')) {
1012
+ if (!this._rotate) {
569
1013
  return mapProto._getNewPixelOrigin.apply(this, arguments);
570
1014
  }
571
- const viewHalf = this.getSize()._divideBy(2);
1015
+ // the layer point under the view center doesn't depend on zoom (pane transforms only rotate/pitch around the pivot),
1016
+ // so this matches `_setBearing` also when the pivot is no longer the view center (eg. after panning)
572
1017
  return this.project(center, zoom)
573
- .rotate(this._bearing)
574
- ._subtract(viewHalf)
575
- ._add(this._getMapPanePos())
576
- ._add(this._getRotatePanePos())
577
- .rotate(-this._bearing)
1018
+ ._subtract(this.containerPointToLayerPoint(this.getSize()._divideBy(2)))
578
1019
  ._round();
579
1020
  },
580
1021
 
581
1022
  /**
582
- * @since leaflet-rotate (v0.2)
1023
+ * Calculate the pixel bounds needed to cover the transformed viewport.
1024
+ *
1025
+ * @param {L.LatLng} [center=this.getCenter()] Target center.
1026
+ * @param {number} [zoom=this.getZoom()] Target zoom level.
1027
+ *
1028
+ * @returns {L.Bounds} Pixel bounds in projected map coordinates.
1029
+ *
1030
+ * @since leaflet-rotate (v0.2.0)
583
1031
  *
584
1032
  * @see src\layer\tile\GridLayer::_getTiledPixelBounds()
585
1033
  */
@@ -592,21 +1040,25 @@ L.Map.include({
592
1040
  const mapZoom = this._animatingZoom ? Math.max(this._animateToZoom, this.getZoom()) : this.getZoom(),
593
1041
  scale = this.getZoomScale(mapZoom, zoom),
594
1042
  pixelCenter = this.project(center, zoom).floor(),
595
- size = this.getSize(),
596
- halfSize = new L.Bounds([
597
- this.containerPointToLayerPoint([0, 0]).floor(),
598
- this.containerPointToLayerPoint([size.x, 0]).floor(),
599
- this.containerPointToLayerPoint([0, size.y]).floor(),
600
- this.containerPointToLayerPoint([size.x, size.y]).floor()
601
- ]).getSize().divideBy(scale * 2);
1043
+ [min, max] = this._getPitchedContainerBounds(0),
1044
+ corners = [[min.x, min.y], [max.x, min.y], [min.x, max.y], [max.x, max.y]]
1045
+ .map(point => this.containerPointToLayerPoint(point));
1046
+
1047
+ if (this._pitch) {
1048
+ // the pitched view is not symmetric: the far side covers much more than the near one
1049
+ const viewCenter = this.containerPointToLayerPoint(this.getSize()._divideBy(2));
1050
+ return new L.Bounds(corners.map(point => pixelCenter.add(point.subtract(viewCenter).divideBy(scale))));
1051
+ }
1052
+
1053
+ const halfSize = new L.Bounds(corners.map(point => point.floor())).getSize().divideBy(scale * 2);
602
1054
 
603
1055
  return new L.Bounds(pixelCenter.subtract(halfSize), pixelCenter.add(halfSize));
604
1056
  },
605
1057
 
606
1058
  /**
607
- * @since leaflet-rotate (v0.2)
1059
+ * @since leaflet-rotate (v0.2.0)
608
1060
  *
609
- * @return {L.Point} map pivot point (center)
1061
+ * @returns {L.Point} Map pivot point in pixel coordinates.
610
1062
  */
611
1063
  _getPixelCenter() {
612
1064
  if (!this._rotate && mapProto._getPixelCenter) {
@@ -616,19 +1068,19 @@ L.Map.include({
616
1068
  },
617
1069
 
618
1070
  /**
619
- * @since leaflet-rotate (v0.2)
620
- *
621
1071
  * @see src\layer\vector\Renderer::_update()
1072
+ *
1073
+ * @param {number} padding Bounds padding as a fraction of map size.
1074
+ *
1075
+ * @returns {L.Bounds} Padded bounds in layer-point coordinates.
1076
+ *
1077
+ * @since leaflet-rotate (v0.2.0)
622
1078
  */
623
1079
  _getPaddedPixelBounds(padding) {
624
1080
  if (!this._rotate && mapProto._getPaddedPixelBounds) {
625
1081
  return mapProto._getPaddedPixelBounds.apply(this, arguments);
626
1082
  }
627
- const p = padding,
628
- size = this.getSize(),
629
- padMin = size.multiplyBy(-p),
630
- padMax = size.multiplyBy(1 + p);
631
- //min = this.containerPointToLayerPoint(size.multiplyBy(-p)).round();
1083
+ const [padMin, padMax] = this._getPitchedContainerBounds(padding);
632
1084
 
633
1085
  return new L.Bounds([
634
1086
  this.containerPointToLayerPoint([padMin.x, padMin.y]).floor(),
@@ -638,6 +1090,15 @@ L.Map.include({
638
1090
  ]);
639
1091
  },
640
1092
 
1093
+ /**
1094
+ * Build and fire Leaflet's location-found event, including the device heading.
1095
+ *
1096
+ * @param {GeolocationPosition} pos Browser geolocation result.
1097
+ *
1098
+ * @fires L.Map#locationfound
1099
+ *
1100
+ * @since leaflet-rotate (v0.1.0)
1101
+ */
641
1102
  _handleGeolocationResponse(pos) {
642
1103
  if (!this._container._leaflet_id) { return; }
643
1104
 
@@ -678,6 +1139,7 @@ L.Map.include({
678
1139
  * @see https://github.com/ronikar/Leaflet/blob/5c480ef959b947c3beed7065425a5a36c486262b/src/geo/LatLngBounds.js#L253-L264
679
1140
  *
680
1141
  * @param {L.Bounds} points
1142
+ *
681
1143
  * @returns {L.Bounds}
682
1144
  */
683
1145
  // toCircumscribedBounds(points) {