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
@@ -30,11 +30,15 @@ var leafletRotate = (() => {
30
30
  * Resets the 3D CSS transform of `el` so it is translated by `offset` pixels,
31
31
  * optionally scaled by `scale`, and rotated around `pivot` when `bearing` is set.
32
32
  *
33
- * @param {HTMLElement} el
34
- * @param {L.Point} offset
35
- * @param {Number} scale
36
- * @param {Number} bearing
37
- * @param {L.Point} pivot
33
+ * @param {HTMLElement} el Element to transform.
34
+ * @param {L.Point} [offset] Translation in pixels; defaults to the origin.
35
+ * @param {number} [scale] Optional scale factor.
36
+ * @param {number} [bearing] Optional rotation in radians.
37
+ * @param {L.Point} [pivot] Point in pixels around which to rotate.
38
+ *
39
+ * @returns {void}
40
+ *
41
+ * @since leaflet-rotate (v0.1.0)
38
42
  */
39
43
  setTransform(el, offset, scale, bearing, pivot) {
40
44
  let pos = offset || new L.Point(0, 0);
@@ -48,11 +52,15 @@ var leafletRotate = (() => {
48
52
  * Sets the position of `el` to `point`, using CSS transforms or top/left positioning
49
53
  * depending on browser support, and optionally rotating it around `pivot`.
50
54
  *
51
- * @param {HTMLElement} el
52
- * @param {L.Point} point
53
- * @param {Number} bearing
54
- * @param {L.Point} pivot
55
- * @param {Number} scale
55
+ * @param {HTMLElement} el Element to position.
56
+ * @param {L.Point} point Position in pixels.
57
+ * @param {number} [bearing] Rotation in radians; zero or omitted uses Leaflet positioning.
58
+ * @param {L.Point} [pivot] Point in pixels around which to rotate.
59
+ * @param {number} [scale] Optional scale factor.
60
+ *
61
+ * @returns {void}
62
+ *
63
+ * @since leaflet-rotate (v0.1.0)
56
64
  */
57
65
  setPosition(el, point, bearing, pivot, scale) {
58
66
  if (!bearing) {
@@ -64,8 +72,11 @@ var leafletRotate = (() => {
64
72
  /**
65
73
  * Returns the position assigned to `el`.
66
74
  *
67
- * @param {HTMLElement} el
68
- * @returns {L.Point}
75
+ * @param {HTMLElement} el Element whose Leaflet position is requested.
76
+ *
77
+ * @returns {L.Point} Stored Leaflet position, or the position measured by Leaflet.
78
+ *
79
+ * @since leaflet-rotate (v0.1.0)
69
80
  */
70
81
  getPosition(el) {
71
82
  return el._leaflet_pos || domUtilProto.getPosition(el);
@@ -88,7 +99,10 @@ var leafletRotate = (() => {
88
99
  * ⎡ x' ⎤ = ⎡ cos θ -sin θ ⎤ ⎡ x ⎤
89
100
  * ⎣ y' ⎦ ⎣ sin θ cos θ ⎦ ⎣ y ⎦
90
101
  *
91
- * @param theta must be given in radians.
102
+ * @param {number} theta Rotation angle in radians, counterclockwise.
103
+ * @returns {L.Point} Rotated point (or this point when theta is zero).
104
+ *
105
+ * @since leaflet-rotate (v0.1.0)
92
106
  */
93
107
  rotate(theta) {
94
108
  return this.rotateFrom(theta, new L.Point(0, 0));
@@ -102,10 +116,12 @@ var leafletRotate = (() => {
102
116
  *
103
117
  * same as `this.subtract(pivot).rotate(theta).add(pivot)`
104
118
  *
105
- * @param {Number} theta
106
- * @param {L.Point} pivot
119
+ * @param {number} theta Rotation angle in radians, counterclockwise.
120
+ * @param {L.Point} pivot Point to rotate around.
107
121
  *
108
122
  * @returns {L.Point}
123
+ *
124
+ * @since leaflet-rotate (v0.1.0)
109
125
  */
110
126
  rotateFrom(theta, pivot) {
111
127
  if (!theta) {
@@ -132,17 +148,41 @@ var leafletRotate = (() => {
132
148
  var divOverlayProto = Object.defineProperties({}, Object.getOwnPropertyDescriptors(L.DivOverlay.prototype));
133
149
  L.DivOverlay.include({
134
150
  /**
135
- * Update L.Popup and L.Tooltip anchor positions after
136
- * the map is moved by calling `map.setBearing(theta)`
151
+ * Extend overlay event subscriptions to refresh position after map rotation and pitch.
137
152
  *
138
153
  * @listens L.Map~rotate
154
+ * @listens L.Map~pitch
155
+ *
156
+ * @returns {Object} Leaflet event map for the overlay.
157
+ *
158
+ * @since leaflet-rotate (v0.1.0)
139
159
  */
140
160
  getEvents() {
141
- return Object.assign(divOverlayProto.getEvents.apply(this, arguments), { rotate: this._updatePosition });
161
+ const events = Object.assign(divOverlayProto.getEvents.apply(this, arguments), {
162
+ rotate: this._updatePosition,
163
+ pitch: this._updatePitchEvents
164
+ });
165
+ if (this._map && this._map._pitch) {
166
+ events.move = this._updatePosition;
167
+ }
168
+ return events;
169
+ },
170
+ /**
171
+ * Listen to map movement only while pitch is non-zero.
172
+ *
173
+ * @since leaflet-rotate (0.5.0)
174
+ */
175
+ _updatePitchEvents() {
176
+ if (this._map._pitch) {
177
+ this._map.on("move", this._updatePosition, this);
178
+ } else {
179
+ this._map.off("move", this._updatePosition, this);
180
+ }
142
181
  },
143
182
  /**
144
- * 0. update element anchor point (divOverlayProto v1.9.3)
145
- * 1. rotate around anchor point (subtract anchor -> rotate point -> add anchor)
183
+ * Reposition the overlay after map movement, applying rotation/pitch-aware coordinates.
184
+ *
185
+ * @since leaflet-rotate (v0.1.0)
146
186
  */
147
187
  _updatePosition() {
148
188
  if (!this._map) {
@@ -170,8 +210,11 @@ var leafletRotate = (() => {
170
210
  var popupProto = Object.defineProperties({}, Object.getOwnPropertyDescriptors(L.Popup.prototype));
171
211
  L.Popup.include({
172
212
  /**
173
- * 0. update element anchor point (popupProto v1.9.3)
174
- * 1. rotate around anchor point (subtract anchor -> rotate point -> add anchor)
213
+ * Update the popup position during zoom animation and transform it into map-pane coordinates.
214
+ *
215
+ * @param {Object} e Zoom animation event.
216
+ *
217
+ * @since leaflet-rotate (v0.1.0)
175
218
  */
176
219
  _animateZoom(e) {
177
220
  popupProto._animateZoom.apply(this, arguments);
@@ -182,9 +225,13 @@ var leafletRotate = (() => {
182
225
  }
183
226
  },
184
227
  /**
185
- * Fix for L.popup({ keepInView = true })
228
+ * Pan the map when a popup with auto-pan enabled would extend beyond the viewport.
186
229
  *
187
230
  * @see https://github.com/fnicollet/Leaflet/pull/21
231
+ *
232
+ * @fires L.Map#autopanstart
233
+ *
234
+ * @since leaflet-rotate (v0.1.0)
188
235
  */
189
236
  _adjustPan() {
190
237
  if (!this.options.autoPan || this._map._panAnim && this._map._panAnim._inProgress) {
@@ -222,6 +269,13 @@ var leafletRotate = (() => {
222
269
  // src/layer/Tooltip.js
223
270
  var tooltipProto = Object.defineProperties({}, Object.getOwnPropertyDescriptors(L.Tooltip.prototype));
224
271
  L.Tooltip.include({
272
+ /**
273
+ * Reposition the tooltip during zoom animation using transformed map coordinates.
274
+ *
275
+ * @param {Object} e Zoom animation event.
276
+ *
277
+ * @since leaflet-rotate (v0.1.0)
278
+ */
225
279
  _animateZoom(e) {
226
280
  if (!this._map._rotate) {
227
281
  return tooltipProto._animateZoom.apply(this, arguments);
@@ -230,6 +284,11 @@ var leafletRotate = (() => {
230
284
  pos = this._map.rotatedPointToMapPanePoint(pos);
231
285
  this._setPosition(pos);
232
286
  },
287
+ /**
288
+ * Recompute tooltip screen position after map movement.
289
+ *
290
+ * @since leaflet-rotate (v0.1.0)
291
+ */
233
292
  _updatePosition() {
234
293
  if (!this._map._rotate) {
235
294
  return tooltipProto._updatePosition.apply(this, arguments);
@@ -243,6 +302,14 @@ var leafletRotate = (() => {
243
302
  // src/layer/marker/Icon.js
244
303
  var iconProto = Object.defineProperties({}, Object.getOwnPropertyDescriptors(L.Icon.prototype));
245
304
  L.Icon.include({
305
+ /**
306
+ * Set marker icon dimensions and anchor styles, including the rotation origin.
307
+ *
308
+ * @param {HTMLImageElement} img Icon or shadow element.
309
+ * @param {string} name Icon style key (`icon` or `shadow`).
310
+ *
311
+ * @since leaflet-rotate (v0.1.0)
312
+ */
246
313
  _setIconStyles(img, name) {
247
314
  const options = this.options;
248
315
  let sizeOption = options[name + "Size"];
@@ -267,27 +334,38 @@ var leafletRotate = (() => {
267
334
  var markerProto = Object.defineProperties({}, Object.getOwnPropertyDescriptors(L.Marker.prototype));
268
335
  L.Marker.mergeOptions({
269
336
  /**
270
- * Rotation of this marker in rad
337
+ * Rotation of the marker icon in radians.
271
338
  *
272
339
  * @type {Number}
340
+ *
341
+ * @since leaflet-rotate (v0.1.0)
273
342
  */
274
343
  rotation: 0,
275
344
  /**
276
- * Rotate this marker when map rotates
345
+ * Rotate the marker icon when the map bearing changes.
346
+ *
347
+ * Combine with `keepUpright: true` to keep the marker readable while
348
+ * preserving its line alignment.
277
349
  *
278
350
  * @type {Boolean}
351
+ *
352
+ * @since leaflet-rotate (v0.1.0)
279
353
  */
280
354
  rotateWithView: false,
281
355
  /**
282
- * Flip a rotated marker when needed to keep it upright
356
+ * Flip the icon when needed to keep it upright while map-aligned.
283
357
  *
284
358
  * @type {Boolean}
359
+ *
360
+ * @since leaflet-rotate (v0.1.0)
285
361
  */
286
362
  keepUpright: false,
287
363
  /**
288
- * Scale of the marker icon
364
+ * Optional scale of the marker icon and shadow.
289
365
  *
290
366
  * @type {Number}
367
+ *
368
+ * @since leaflet-rotate (v0.1.0)
291
369
  */
292
370
  scale: void 0
293
371
  });
@@ -299,6 +377,13 @@ var leafletRotate = (() => {
299
377
  // }
300
378
  // this._draggable.updateMapBearing(this._marker._map._bearing);
301
379
  // },
380
+ /**
381
+ * Update a dragged marker's geographic position from its transformed icon position.
382
+ *
383
+ * @param {Object} e Marker drag event with `latlng` and `oldLatLng` fields.
384
+ *
385
+ * @since leaflet-rotate (v0.1.0)
386
+ */
302
387
  _onDrag(e) {
303
388
  const marker = this._marker, rotated_marker = marker.options.rotation || marker.options.rotateWithView, shadow = marker._shadow;
304
389
  let iconPos = L.DomUtil.getPosition(marker._icon);
@@ -316,6 +401,13 @@ var leafletRotate = (() => {
316
401
  else marker.fire("move", e);
317
402
  marker.fire("drag", e);
318
403
  },
404
+ /**
405
+ * Reposition the marker after a drag ends, then run Leaflet's drag-end logic.
406
+ *
407
+ * @param {Object} e Marker drag-end event.
408
+ *
409
+ * @since leaflet-rotate (v0.1.0)
410
+ */
319
411
  _onDragEnd(e) {
320
412
  if (this._marker._map._rotate) {
321
413
  this._marker.update();
@@ -325,14 +417,42 @@ var leafletRotate = (() => {
325
417
  };
326
418
  L.Marker.include({
327
419
  /**
328
- * Update L.Marker anchor position after the map
329
- * is moved by calling `map.setBearing(theta)`
420
+ * Extend marker event subscriptions to reposition its icon after bearing or pitch changes.
330
421
  *
331
422
  * @listens L.Map~rotate
423
+ * @listens L.Map~pitch
424
+ *
425
+ * @returns {Object} Leaflet event map for the marker.
426
+ *
427
+ * @since leaflet-rotate (v0.1.0)
332
428
  */
333
429
  getEvents() {
334
- return Object.assign(markerProto.getEvents.apply(this, arguments), { rotate: this.update });
430
+ const events = Object.assign(markerProto.getEvents.apply(this, arguments), {
431
+ rotate: this.update,
432
+ pitch: this._updatePitchEvents
433
+ });
434
+ if (this._map && this._map._pitch) {
435
+ events.move = this.update;
436
+ }
437
+ return events;
438
+ },
439
+ /**
440
+ * Subscribe to map movement while pitch is active to keep the icon aligned.
441
+ *
442
+ * @since leaflet-rotate (0.5.0)
443
+ */
444
+ _updatePitchEvents() {
445
+ if (this._map._pitch) {
446
+ this._map.on("move", this.update, this);
447
+ } else {
448
+ this._map.off("move", this.update, this);
449
+ }
335
450
  },
451
+ /**
452
+ * Install rotation-aware drag callbacks when marker dragging is enabled.
453
+ *
454
+ * @since leaflet-rotate (v0.1.0)
455
+ */
336
456
  _initInteraction() {
337
457
  const ret = markerProto._initInteraction.apply(this, arguments);
338
458
  if (this.dragging && this.dragging.enabled() && this._map && this._map._rotate) {
@@ -347,6 +467,13 @@ var leafletRotate = (() => {
347
467
  }
348
468
  return ret;
349
469
  },
470
+ /**
471
+ * Position and rotate the marker icon and shadow in map-pane coordinates.
472
+ *
473
+ * @param {L.Point} pos Marker anchor in layer-point coordinates.
474
+ *
475
+ * @since leaflet-rotate (v0.1.0)
476
+ */
350
477
  _setPos(pos) {
351
478
  if (this._map._rotate) {
352
479
  pos = this._map.rotatedPointToMapPanePoint(pos);
@@ -377,6 +504,15 @@ var leafletRotate = (() => {
377
504
  // }
378
505
  // this._icon.style.zIndex = Math.round(this._zIndex + offset);
379
506
  // },
507
+ /**
508
+ * Set the marker icon's rotation and redraw it.
509
+ *
510
+ * @param {number} rotation Angle in radians.
511
+ *
512
+ * @returns {void}
513
+ *
514
+ * @since leaflet-rotate (v0.1.0)
515
+ */
380
516
  setRotation(rotation) {
381
517
  this.options.rotation = rotation;
382
518
  this.update();
@@ -387,10 +523,13 @@ var leafletRotate = (() => {
387
523
  var gridLayerProto = Object.defineProperties({}, Object.getOwnPropertyDescriptors(L.GridLayer.prototype));
388
524
  L.GridLayer.include({
389
525
  /**
390
- * Redraw L.TileLayer bounds after the map is
391
- * moved by just calling `map.setBearing(theta)`
526
+ * Add a throttled tile refresh after map bearing changes.
392
527
  *
393
528
  * @listens L.Map~rotate
529
+ *
530
+ * @returns {Object} Leaflet event map for the grid layer.
531
+ *
532
+ * @since leaflet-rotate (v0.1.0)
394
533
  */
395
534
  getEvents() {
396
535
  const events = gridLayerProto.getEvents.apply(this, arguments);
@@ -398,10 +537,39 @@ var leafletRotate = (() => {
398
537
  if (!this._onRotate) {
399
538
  this._onRotate = L.Util.throttle(this._onMoveEnd, this.options.updateInterval, this);
400
539
  }
401
- events.rotate = this._onRotate;
540
+ events.rotate = this._onRotateEvent;
402
541
  }
403
542
  return events;
404
543
  },
544
+ /**
545
+ * Keep tile levels aligned when a bearing/pitch change shifts the pixel origin
546
+ * (eg. when the rotation pivot is not the view center), then refresh tiles (throttled).
547
+ *
548
+ * @listens L.Map~rotate
549
+ *
550
+ * @since leaflet-rotate (0.5.1)
551
+ */
552
+ _onRotateEvent() {
553
+ const map = this._map;
554
+ if (!map) {
555
+ return;
556
+ }
557
+ const origin = map.getPixelOrigin();
558
+ if (this._rotateOrigin && !this._rotateOrigin.equals(origin) && !map._animatingZoom && this._levels) {
559
+ this._setZoomTransforms(map.getCenter(), map.getZoom());
560
+ }
561
+ this._rotateOrigin = origin;
562
+ this._onRotate();
563
+ },
564
+ /**
565
+ * Return tile pixel bounds that cover the rotated viewport.
566
+ *
567
+ * @param {L.LatLng} center View center used to calculate bounds.
568
+ *
569
+ * @returns {L.Bounds} Pixel bounds covered by the layer's tiles.
570
+ *
571
+ * @since leaflet-rotate (v0.1.0)
572
+ */
405
573
  _getTiledPixelBounds(center) {
406
574
  if (!this._map._rotate) {
407
575
  return gridLayerProto._getTiledPixelBounds.apply(this, arguments);
@@ -414,21 +582,73 @@ var leafletRotate = (() => {
414
582
  var rendererProto = Object.defineProperties({}, Object.getOwnPropertyDescriptors(L.Renderer.prototype));
415
583
  var rendererBase = Object.getPrototypeOf(L.Renderer.prototype);
416
584
  var rendererGetEvents = rendererProto.getEvents || rendererBase.getEvents;
585
+ var rendererOnMoveEnd = rendererProto._onMoveEnd || rendererBase._onMoveEnd;
417
586
  L.Renderer.include({
418
587
  /**
419
- * Redraw L.Canvas and L.SVG renderer bounds after the
420
- * map is moved by just calling `map.setBearing(theta)`
588
+ * Subscribe the renderer to map rotation so its visible bounds are recalculated.
421
589
  *
422
590
  * @listens L.Map~rotate
591
+ *
592
+ * @returns {Object} Leaflet event map for the renderer.
593
+ *
594
+ * @since leaflet-rotate (v0.1.0)
423
595
  */
424
596
  getEvents() {
425
597
  const events = rendererGetEvents.apply(this, arguments);
426
- return L.version.startsWith("2.") ? events : Object.assign(events, { rotate: this._update });
598
+ return Object.assign(events, { rotate: this._onRotate });
599
+ },
600
+ /**
601
+ * Refresh renderer bounds after bearing or pitch changes.
602
+ *
603
+ * Paths are reprojected only when the pixel origin actually changed (eg. fractional centers
604
+ * after an off-center zoom), since reprojection is the main cost with long polylines.
605
+ *
606
+ * @since leaflet-rotate (v0.1.0)
607
+ */
608
+ _onRotate() {
609
+ if (!this._map._rotate) {
610
+ return;
611
+ }
612
+ const origin = this._map.getPixelOrigin();
613
+ if (!this._rotateOrigin || !this._rotateOrigin.equals(origin)) {
614
+ this._rotateOrigin = origin;
615
+ clearTimeout(this._rotateTimer);
616
+ this._onZoomEnd();
617
+ this._updateAfterRotate();
618
+ return;
619
+ }
620
+ if (this._bounds && this._bounds.contains(this._map._getPaddedPixelBounds(0))) {
621
+ return;
622
+ }
623
+ clearTimeout(this._rotateTimer);
624
+ const wait = (this._lastRotateUpdate || 0) + 100 - performance.now();
625
+ if (wait > 0) {
626
+ this._rotateTimer = setTimeout(() => this._map && this._updateAfterRotate(), wait);
627
+ } else {
628
+ this._updateAfterRotate();
629
+ }
630
+ },
631
+ /**
632
+ * Recalculate renderer bounds and redraw after bearing or pitch changes.
633
+ *
634
+ * @since leaflet-rotate (0.5.1)
635
+ */
636
+ _updateAfterRotate() {
637
+ this._lastRotateUpdate = performance.now();
638
+ if (L.version.startsWith("2.")) {
639
+ this._onMoveEnd();
640
+ } else {
641
+ this._update();
642
+ }
427
643
  },
428
644
  /**
429
- * Fix for `map.flyTo()` when `false === map.options.zoomAnimation`
645
+ * Add the zoom-animation class needed for correct renderer transforms in Leaflet 1.
646
+ *
647
+ * Fixes `map.flyTo()` when `map.options.zoomAnimation` is false.
430
648
  *
431
649
  * @see https://github.com/Leaflet/Leaflet/pull/8794
650
+ *
651
+ * @since leaflet-rotate (v0.2.0)
432
652
  */
433
653
  onAdd() {
434
654
  rendererProto.onAdd.apply(this, arguments);
@@ -437,45 +657,38 @@ var leafletRotate = (() => {
437
657
  }
438
658
  },
439
659
  /**
440
- * @FIXME layer drifts on `map.setZoom()` (eg. zoom during animation)
441
- *
442
- * the main cause seems to be related to `this._updateTransform(path._center, path._zoom))`
443
- * and `this._topLeft = this._map.layerPointToLatLng(this._bounds.min);`
444
- *
445
- * @example
446
- * map.setZoom(2);
447
- * path._renderer._update();
448
- * path._renderer._updateTransform(path._renderer._center, path._renderer._zoom);
660
+ * Update the renderer transform during zoom animation, accounting for rotated coordinates.
449
661
  *
450
662
  * @see https://github.com/Leaflet/Leaflet/pull/8794
451
663
  * @see https://github.com/Leaflet/Leaflet/pull/8103
452
664
  * @see https://github.com/Leaflet/Leaflet/issues/7466
453
665
  *
454
- * @TODO rechek this changes from leaflet@v1.9.3
666
+ * @param {L.LatLng} center Target map center.
667
+ * @param {number} zoom Target zoom level.
455
668
  *
456
- * @see https://github.com/Leaflet/Leaflet/compare/v1.7.0...v1.9.3
669
+ * @since leaflet-rotate (v0.1.0)
457
670
  */
458
671
  _updateTransform(center, zoom) {
459
- if (L.version.startsWith("2.")) {
460
- if (this._map._rotate) {
461
- const scale2 = this._map.getZoomScale(zoom, this._zoom), offset2 = this._topLeftPoint.multiplyBy(scale2).subtract(this._map._getNewPixelOrigin(center, zoom));
462
- L.DomUtil.setTransform(this._container, offset2, scale2);
463
- return;
464
- }
465
- return rendererBase._updateTransform.apply(this, arguments);
466
- }
672
+ const v2 = L.version.startsWith("2.");
467
673
  if (!this._map._rotate) {
468
- return rendererProto._updateTransform.apply(this, arguments);
674
+ return (v2 ? rendererBase : rendererProto)._updateTransform.apply(this, arguments);
469
675
  }
470
- if (zoom === this._zoom) {
676
+ if (!v2 && zoom === this._zoom) {
471
677
  return;
472
678
  }
473
- const scale = this._map.getZoomScale(zoom, this._zoom), offset = this._map._latLngToNewLayerPoint(this._topLeft, zoom, center);
679
+ const scale = this._map.getZoomScale(zoom, this._zoom), offset = this._topLeftPoint.multiplyBy(scale).subtract(this._map._getNewPixelOrigin(center, zoom));
474
680
  L.DomUtil.setTransform(this._container, offset, scale);
475
681
  },
682
+ /**
683
+ * Recalculate bounds and finish renderer updates after movement on Leaflet 2.
684
+ *
685
+ * @param {Object} ev Map movement event.
686
+ *
687
+ * @since leaflet-rotate (v0.4.0)
688
+ */
476
689
  _onMoveEnd(ev) {
477
690
  if (!L.version.startsWith("2.") || !this._map._rotate) {
478
- return rendererBase._onMoveEnd.apply(this, arguments);
691
+ return rendererOnMoveEnd.apply(this, arguments);
479
692
  }
480
693
  this._bounds = this._map._getPaddedPixelBounds(this.options.padding);
481
694
  this._topLeft = this._map.layerPointToLatLng(this._bounds.min);
@@ -486,6 +699,11 @@ var leafletRotate = (() => {
486
699
  this._resizeContainer();
487
700
  this._onSettled(ev);
488
701
  },
702
+ /**
703
+ * Resize the renderer container to cover the pitch-aware map bounds.
704
+ *
705
+ * @since leaflet-rotate (v0.4.0)
706
+ */
489
707
  _resizeContainer() {
490
708
  if (!L.version.startsWith("2.") || !this._map._rotate || !this._bounds) {
491
709
  return rendererBase._resizeContainer.apply(this, arguments);
@@ -530,6 +748,11 @@ var leafletRotate = (() => {
530
748
  // this._layers[id]._update();
531
749
  // }
532
750
  // },
751
+ /**
752
+ * Recalculate renderer bounds and cached view state.
753
+ *
754
+ * @since leaflet-rotate (v0.1.0)
755
+ */
533
756
  _update() {
534
757
  if (!this._map._rotate) {
535
758
  return rendererProto._update.apply(this, arguments);
@@ -538,13 +761,49 @@ var leafletRotate = (() => {
538
761
  this._topLeft = this._map.layerPointToLatLng(this._bounds.min);
539
762
  this._center = this._map.getCenter();
540
763
  this._zoom = this._map.getZoom();
764
+ this._topLeftPoint = this._map._getNewPixelOrigin(this._center, this._zoom).add(this._bounds.min);
541
765
  }
542
766
  });
543
767
 
544
768
  // src/map/Map.js
545
769
  var mapProto = Object.defineProperties({}, Object.getOwnPropertyDescriptors(L.Map.prototype));
546
- L.Map.mergeOptions({ rotate: false, bearing: 0 });
770
+ L.Map.mergeOptions({
771
+ /**
772
+ * @type {boolean} Enable bearing and pitch transformations.
773
+ *
774
+ * @since leaflet-rotate (v0.1.0)
775
+ */
776
+ rotate: false,
777
+ /**
778
+ * @type {number} Initial map bearing in degrees.
779
+ *
780
+ * @since leaflet-rotate (v0.1.0)
781
+ */
782
+ bearing: 0,
783
+ /**
784
+ * @type {number} Initial map pitch in degrees, clamped to 0–85.
785
+ *
786
+ * @since leaflet-rotate (0.5.0)
787
+ */
788
+ pitch: 0,
789
+ /**
790
+ * @type {number} Maximum distance of the pitched map plane from the map center, in multiples
791
+ * of the map height. Tiles, vectors and `getBounds()` stop there (and always
792
+ * before the horizon); a `.leaflet-pitch-sky` element fills the area above.
793
+ *
794
+ * @since leaflet-rotate (0.5.1)
795
+ */
796
+ pitchMaxDistance: 3
797
+ });
547
798
  L.Map.include({
799
+ /**
800
+ * Apply an immediate animated-zoom frame without waiting for a transition delay.
801
+ *
802
+ * @param {L.LatLng} center Target center.
803
+ * @param {number} zoom Target zoom level.
804
+ *
805
+ * @since leaflet-rotate (v0.1.0)
806
+ */
548
807
  _animateZoomNoDelay(center, zoom) {
549
808
  if (!this._mapPane) {
550
809
  return;
@@ -556,17 +815,25 @@ var leafletRotate = (() => {
556
815
  this._onZoomTransitionEnd();
557
816
  },
558
817
  /**
559
- * @param {(HTMLElement|String)} id html selector
560
- * @param {Object} [options={}] leaflet map options
818
+ * Initialize the Leaflet map and apply its initial bearing and pitch.
819
+ *
820
+ * @param {HTMLElement|string} id Map container element or its ID.
821
+ * @param {Object} [options={}] Leaflet map options, including `rotate`, `bearing`, and `pitch`.
822
+ *
823
+ * @since leaflet-rotate (v0.1.0)
561
824
  */
562
825
  initialize(id, options) {
563
826
  if (options.rotate) {
564
827
  this._rotate = true;
565
828
  this._bearing = 0;
829
+ this._pitch = 0;
566
830
  }
567
831
  mapProto.initialize.apply(this, arguments);
568
832
  if (this.options.rotate) {
833
+ this.on("resize", this._updatePitchTransform, this);
834
+ this.on("move", this._updatePitchSky, this);
569
835
  this.setBearing(this.options.bearing);
836
+ this.setPitch(this.options.pitch);
570
837
  }
571
838
  },
572
839
  /**
@@ -574,26 +841,41 @@ var leafletRotate = (() => {
574
841
  * returns the corresponding pixel coordinate relative to
575
842
  * the [origin pixel](#map-getpixelorigin).
576
843
  *
577
- * @param {L.Point} point pixel screen coordinates
578
- * @returns {L.Point} transformed pixel point
844
+ * @param {L.Point|number[]} point Pixel coordinate in the map container.
845
+ *
846
+ * @returns {L.Point} Corresponding layer-point coordinate.
847
+ *
848
+ * @since leaflet-rotate (v0.1.0)
579
849
  */
580
850
  containerPointToLayerPoint(point) {
581
851
  if (!this._rotate) {
582
852
  return mapProto.containerPointToLayerPoint.apply(this, arguments);
583
853
  }
854
+ if (this._pitch) {
855
+ return this._transformPitchPoint(
856
+ toPoint(point).subtract(this._getMapPanePos()),
857
+ this._pitchInverseTransform
858
+ );
859
+ }
584
860
  return toPoint(point).subtract(this._getMapPanePos()).rotateFrom(-this._bearing, this._getRotatePanePos()).subtract(this._getRotatePanePos());
585
861
  },
586
862
  /**
587
863
  * Given a pixel coordinate relative to the [origin pixel](#map-getpixelorigin),
588
864
  * returns the corresponding pixel coordinate relative to the map container.
589
865
  *
590
- * @param {L.Point} point pixel screen coordinates
591
- * @returns {L.Point} transformed pixel point
866
+ * @param {L.Point|number[]} point Pixel coordinate in layer-point space.
867
+ *
868
+ * @returns {L.Point} Corresponding pixel coordinate in the map container.
869
+ *
870
+ * @since leaflet-rotate (v0.1.0)
592
871
  */
593
872
  layerPointToContainerPoint(point) {
594
873
  if (!this._rotate) {
595
874
  return mapProto.layerPointToContainerPoint.apply(this, arguments);
596
875
  }
876
+ if (this._pitch) {
877
+ return this._transformPitchPoint(point, this._pitchTransform).add(this._getMapPanePos());
878
+ }
597
879
  return toPoint(point).add(this._getRotatePanePos()).rotateFrom(this._bearing, this._getRotatePanePos()).add(this._getMapPanePos());
598
880
  },
599
881
  /**
@@ -603,12 +885,16 @@ var leafletRotate = (() => {
603
885
  * (rotatePane) --> (mapPane)
604
886
  * (rotatePane) --> (norotatePane)
605
887
  *
606
- * @param {L.Point} point pixel screen coordinates
607
- * @returns {L.Point}
888
+ * @param {L.Point|number[]} point Point in the rotated pane.
608
889
  *
609
- * @since leaflet-rotate (v0.1)
890
+ * @returns {L.Point} Corresponding point in map-pane coordinates.
891
+ *
892
+ * @since leaflet-rotate (v0.1.0)
610
893
  */
611
894
  rotatedPointToMapPanePoint(point) {
895
+ if (this._pitch) {
896
+ return this._transformPitchPoint(point, this._pitchTransform);
897
+ }
612
898
  return toPoint(point).rotate(this._bearing)._add(this._getRotatePanePos());
613
899
  },
614
900
  /**
@@ -618,11 +904,16 @@ var leafletRotate = (() => {
618
904
  * (mapPane) --> (rotatePane)
619
905
  * (norotatePane) --> (rotatePane)
620
906
  *
621
- * @param {L.Point} point pixel screen coordinates
907
+ * @param {L.Point|number[]} point Point in map-pane coordinates.
622
908
  *
623
- * @since leaflet-rotate (v0.1)
909
+ * @returns {L.Point} Corresponding point in the rotated pane.
910
+ *
911
+ * @since leaflet-rotate (v0.1.0)
624
912
  */
625
913
  mapPanePointToRotatedPoint(point) {
914
+ if (this._pitch) {
915
+ return this._transformPitchPoint(point, this._pitchInverseTransform);
916
+ }
626
917
  return toPoint(point)._subtract(this._getRotatePanePos()).rotate(-this._bearing);
627
918
  },
628
919
  // latLngToLayerPoint(latlng) {
@@ -638,10 +929,11 @@ var leafletRotate = (() => {
638
929
  *
639
930
  * @see https://github.com/ronikar/Leaflet/blob/5c480ef959b947c3beed7065425a5a36c486262b/src/map/Map.js#L1114-L1135
640
931
  *
641
- * @param {L.LatLngBounds} bounds
642
- * @returns {L.Bounds}
932
+ * @param {L.LatLngBounds} bounds Geographic bounds to project.
933
+ *
934
+ * @returns {L.Bounds} Axis-aligned bounds in container-pixel coordinates.
643
935
  *
644
- * @since leaflet-rotate (v0.2)
936
+ * @since leaflet-rotate (v0.2.0)
645
937
  */
646
938
  mapBoundsToContainerBounds(bounds) {
647
939
  if (!this._rotate && mapProto.mapBoundsToContainerBounds) {
@@ -657,31 +949,72 @@ var leafletRotate = (() => {
657
949
  ]);
658
950
  },
659
951
  /**
660
- * Returns geographical bounds visible in the current map view
952
+ * Return the geographic bounds of the current viewport, accounting for rotation and pitch.
661
953
  *
662
- * @TODO find out if map bounds calculated by `L.Map::getBounds()`
663
- * function should match the `rotatePane` or `norotatePane` bounds
954
+ * The result is the smallest geographic bounds containing all four viewport corners.
664
955
  *
665
- * @see https://github.com/fnicollet/Leaflet/issues/7
956
+ * @returns {L.LatLngBounds} Geographic bounds visible in the map viewport.
666
957
  *
667
- * @returns {L.LatLngBounds}
958
+ * @since leaflet-rotate (v0.1.0)
668
959
  */
669
960
  getBounds() {
670
961
  if (!this._rotate) {
671
962
  return mapProto.getBounds.apply(this, arguments);
672
963
  }
673
- const size = this.getSize();
964
+ const [min, max] = this._getPitchedContainerBounds(0);
674
965
  return new L.LatLngBounds([
675
- this.containerPointToLatLng([0, 0]),
966
+ this.containerPointToLatLng([min.x, min.y]),
676
967
  // topleft
677
- this.containerPointToLatLng([size.x, 0]),
968
+ this.containerPointToLatLng([max.x, min.y]),
678
969
  // topright
679
- this.containerPointToLatLng([size.x, size.y]),
970
+ this.containerPointToLatLng([max.x, max.y]),
680
971
  // bottomright
681
- this.containerPointToLatLng([0, size.y])
972
+ this.containerPointToLatLng([min.x, max.y])
682
973
  // bottomleft
683
974
  ]);
684
975
  },
976
+ /**
977
+ * Return the container rectangle covering the visible map plane, optionally padded by a
978
+ * fraction of the map size. When pitched, its top is cut at the far plane (see `pitchMaxDistance`)
979
+ * so that it never reaches the horizon, where the plane would extend to infinity.
980
+ *
981
+ * @param {number} padding Padding as a fraction of the map size.
982
+ *
983
+ * @returns {L.Point[]} Top-left and bottom-right container points.
984
+ *
985
+ * @since leaflet-rotate (0.5.1)
986
+ */
987
+ _getPitchedContainerBounds(padding) {
988
+ const size = this.getSize(), min = size.multiplyBy(-padding), max = size.multiplyBy(1 + padding);
989
+ if (this._pitch) {
990
+ min.y = Math.min(Math.max(min.y, this._getPitchFarY()), max.y);
991
+ }
992
+ return [min, max];
993
+ },
994
+ /**
995
+ * Return the container y coordinate of the far plane: points above it are farther than
996
+ * `pitchMaxDistance` from the map center, or beyond the horizon.
997
+ *
998
+ * @returns {number} Container y coordinate (`-Infinity` when the whole map is close enough).
999
+ *
1000
+ * @since leaflet-rotate (0.5.1)
1001
+ */
1002
+ _getPitchFarY() {
1003
+ const size = this.getSize(), inverse = this._pitchInverseTransform, panePos = this._getMapPanePos(), x = size.x / 2 - panePos.x, centerY = size.y / 2 - panePos.y, maxDistance = this.options.pitchMaxDistance * size.y, w = (y) => inverse.g * x + inverse.h * y + inverse.i, centerW = w(centerY), center = this._transformPitchPoint([x, centerY], inverse), isNear = (y) => w(y) * centerW > 0 && this._transformPitchPoint([x, y], inverse).distanceTo(center) <= maxDistance;
1004
+ let far = -size.y - panePos.y, near = centerY;
1005
+ if (isNear(far)) {
1006
+ return -Infinity;
1007
+ }
1008
+ for (let i = 0; i < 20; i++) {
1009
+ const mid = (far + near) / 2;
1010
+ if (isNear(mid)) {
1011
+ near = mid;
1012
+ } else {
1013
+ far = mid;
1014
+ }
1015
+ }
1016
+ return near + panePos.y;
1017
+ },
685
1018
  /**
686
1019
  * Returns the bounds of the current map view in projected pixel
687
1020
  * coordinates (sometimes useful in layer and overlay implementations).
@@ -699,95 +1032,346 @@ var leafletRotate = (() => {
699
1032
  // return new L.Bounds(topLeftPoint, topLeftPoint.add(this.getSize()));
700
1033
  // },
701
1034
  /**
702
- * Change map rotation
1035
+ * Set the map bearing, optionally animating to the new value.
1036
+ *
1037
+ * @param {number} theta Target bearing in degrees; normalized to [0, 360).
1038
+ * @param {Object} [options] Animation options.
1039
+ * @param {boolean} [options.animate=false] Animate the change.
1040
+ * @param {number} [options.duration=0.25] Animation duration in seconds.
1041
+ * @param {number} [options.easeLinearity=0.5] Easing curve linearity.
703
1042
  *
704
- * @param {number} theta map degrees
705
- * @param {Object} [options] animation options
706
- * @param {boolean} [options.animate=false]
707
- * @param {number} [options.duration=0.25] animation duration in seconds
708
- * @param {number} [options.easeLinearity=0.5]
1043
+ * @returns {void}
709
1044
  *
710
- * @since leaflet-rotate (v0.1)
1045
+ * @fires L.Map#rotate
1046
+ *
1047
+ * @since leaflet-rotate (v0.1.0)
711
1048
  */
712
1049
  setBearing(theta, options) {
713
1050
  if (!this._rotate) {
714
1051
  return;
715
1052
  }
716
1053
  if (options && options.animate) {
717
- this._bearingAnimationId = (this._bearingAnimationId || 0) + 1;
718
- const animationId = this._bearingAnimationId;
719
- if (this._bearingAnimationFrame) {
720
- cancelAnimationFrame(this._bearingAnimationFrame);
721
- }
722
- const startBearing = this.getBearing(), endBearing = L.Util.wrapNum(theta, [0, 360]), delta = (endBearing - startBearing + 540) % 360 - 180, duration = Math.max(0, options.duration == null ? 0.25 : options.duration) * 1e3, easeLinearity = Math.max(options.easeLinearity || 0.5, 0.2), startTime = performance.now();
723
- const animate = function() {
724
- if (!this._mapPane) {
725
- delete this._bearingAnimationFrame;
726
- return;
727
- }
728
- const progress = duration ? Math.min((performance.now() - startTime) / duration, 1) : 1, eased = 1 - Math.pow(1 - progress, 1 / easeLinearity);
729
- this._setBearing(progress === 1 ? endBearing : startBearing + delta * eased);
730
- if (this._bearingAnimationId !== animationId) {
731
- return;
732
- }
733
- if (progress < 1) {
734
- this._bearingAnimationFrame = requestAnimationFrame(animate.bind(this));
735
- } else {
736
- delete this._bearingAnimationFrame;
737
- }
738
- };
739
- this._bearingAnimationFrame = requestAnimationFrame(animate.bind(this));
1054
+ this._animatePitchBearing(this.getPitch(), theta, options);
740
1055
  return;
741
1056
  }
1057
+ this._stopPitchBearingAnimation();
1058
+ this._setBearing(theta);
1059
+ },
1060
+ /**
1061
+ * Animate pitch and bearing together (bearing follows the shortest path).
1062
+ *
1063
+ * @param {number} pitch Target pitch in degrees.
1064
+ * @param {number} bearing Target bearing in degrees.
1065
+ * @param {Object} [options] Animation options.
1066
+ * @param {number} [options.duration=0.25] Animation duration in seconds.
1067
+ * @param {number} [options.easeLinearity=0.5] Easing curve linearity.
1068
+ *
1069
+ * @since leaflet-rotate (0.5.1)
1070
+ */
1071
+ _animatePitchBearing(pitch, bearing, options) {
1072
+ this._stopPitchBearingAnimation();
1073
+ const animationId = this._bearingAnimationId;
1074
+ const startBearing = this.getBearing(), startPitch = this.getPitch(), endBearing = L.Util.wrapNum(bearing, [0, 360]), endPitch = Math.max(0, Math.min(pitch, 85)), delta = (endBearing - startBearing + 540) % 360 - 180, duration = Math.max(0, options.duration == null ? 0.25 : options.duration) * 1e3, easeLinearity = Math.max(options.easeLinearity || 0.5, 0.2), startTime = performance.now();
1075
+ const animate = function() {
1076
+ if (!this._mapPane || this._bearingAnimationId !== animationId) {
1077
+ return;
1078
+ }
1079
+ const progress = duration ? Math.min((performance.now() - startTime) / duration, 1) : 1, eased = 1 - Math.pow(1 - progress, 1 / easeLinearity);
1080
+ if (progress < 1) {
1081
+ this._bearingAnimationFrame = requestAnimationFrame(animate.bind(this));
1082
+ } else {
1083
+ delete this._bearingAnimationFrame;
1084
+ }
1085
+ this._setPitchAndBearing(
1086
+ progress === 1 ? endPitch : startPitch + (endPitch - startPitch) * eased,
1087
+ progress === 1 ? endBearing : startBearing + delta * eased
1088
+ );
1089
+ };
1090
+ this._bearingAnimationFrame = requestAnimationFrame(animate.bind(this));
1091
+ },
1092
+ /**
1093
+ * Cancel a running pitch/bearing animation.
1094
+ *
1095
+ * @since leaflet-rotate (0.5.1)
1096
+ */
1097
+ _stopPitchBearingAnimation() {
742
1098
  this._bearingAnimationId = (this._bearingAnimationId || 0) + 1;
743
1099
  if (this._bearingAnimationFrame) {
744
1100
  cancelAnimationFrame(this._bearingAnimationFrame);
745
1101
  delete this._bearingAnimationFrame;
746
1102
  }
747
- this._setBearing(theta);
748
1103
  },
749
- _setBearing(theta) {
1104
+ /**
1105
+ * Rotate the map back to north (bearing 0), animated by default.
1106
+ *
1107
+ * @param {Object} [options] Animation options (`animate`, `duration`, `easeLinearity`).
1108
+ *
1109
+ * @returns {L.Map} This map instance.
1110
+ *
1111
+ * @since leaflet-rotate (0.5.1)
1112
+ */
1113
+ resetNorth(options) {
1114
+ options = Object.assign({ animate: true, duration: 0.5 }, options);
1115
+ if (options.animate) {
1116
+ this._animatePitchBearing(this.getPitch(), 0, options);
1117
+ } else {
1118
+ this.setBearing(0);
1119
+ }
1120
+ return this;
1121
+ },
1122
+ /**
1123
+ * Rotate the map back to north and remove the pitch, animated by default.
1124
+ *
1125
+ * @param {Object} [options] Animation options (`animate`, `duration`, `easeLinearity`).
1126
+ *
1127
+ * @returns {L.Map} This map instance.
1128
+ *
1129
+ * @since leaflet-rotate (0.5.1)
1130
+ */
1131
+ resetNorthPitch(options) {
1132
+ options = Object.assign({ animate: true, duration: 0.5 }, options);
1133
+ if (options.animate) {
1134
+ this._animatePitchBearing(0, 0, options);
1135
+ } else {
1136
+ this._stopPitchBearingAnimation();
1137
+ this._setPitchAndBearing(0, 0);
1138
+ }
1139
+ return this;
1140
+ },
1141
+ /**
1142
+ * Apply a bearing immediately and update the rotated pane and map coordinates.
1143
+ *
1144
+ * @param {number} theta Bearing in degrees.
1145
+ * @param {L.LatLng} [mapCenter] Center to preserve while applying the change.
1146
+ *
1147
+ * @fires L.Map#rotate
1148
+ *
1149
+ * @since leaflet-rotate (v0.1.0)
1150
+ */
1151
+ _setBearing(theta, mapCenter) {
1152
+ if (!mapCenter && this._loaded) {
1153
+ mapCenter = this.getCenter();
1154
+ }
750
1155
  const bearing = L.Util.wrapNum(theta, [0, 360]) * (Math.PI / 180), center = this._getPixelCenter(), oldPos = this._getRotatePanePos().rotateFrom(-this._bearing, center), newPos = oldPos.rotateFrom(bearing, center);
751
- L.DomUtil.setPosition(this._rotatePane, oldPos, bearing, center);
752
1156
  this._pivot = center;
753
1157
  this._bearing = bearing;
754
1158
  this._rotatePanePos = newPos;
1159
+ if (this._pitch) {
1160
+ this._rotatePane._leaflet_pos = oldPos;
1161
+ this._updatePitchTransform();
1162
+ } else {
1163
+ L.DomUtil.setPosition(this._rotatePane, oldPos, bearing, center);
1164
+ }
1165
+ if (mapCenter) {
1166
+ this._pixelOrigin = this.project(mapCenter, this._zoom).subtract(this.containerPointToLayerPoint(this.getSize().divideBy(2)));
1167
+ delete this._lastCenter;
1168
+ this.fire("move");
1169
+ }
755
1170
  this.fire("rotate");
756
1171
  },
757
1172
  /**
758
- * Get current map rotation
1173
+ * Transform a point with a projective matrix, accepting DOMMatrix or its 2D inverse.
759
1174
  *
760
- * @returns {number} theta map degrees
1175
+ * @param {L.Point|number[]} point Input pixel coordinate.
1176
+ * @param {DOMMatrix|Object} matrix Forward or inverse transformation matrix.
761
1177
  *
762
- * @since leaflet-rotate (v0.1)
1178
+ * @returns {L.Point} Transformed pixel coordinate.
1179
+ *
1180
+ * @since leaflet-rotate (0.5.0)
763
1181
  */
764
- getBearing() {
765
- return this._bearing * (180 / Math.PI);
1182
+ _transformPitchPoint(point, matrix) {
1183
+ point = toPoint(point);
1184
+ if (!(matrix instanceof DOMMatrix)) {
1185
+ const denominator = matrix.g * point.x + matrix.h * point.y + matrix.i;
1186
+ return new L.Point(
1187
+ (matrix.a * point.x + matrix.b * point.y + matrix.c) / denominator,
1188
+ (matrix.d * point.x + matrix.e * point.y + matrix.f) / denominator
1189
+ );
1190
+ }
1191
+ const transformed = new DOMPoint(point.x, point.y).matrixTransform(matrix);
1192
+ return new L.Point(transformed.x / transformed.w, transformed.y / transformed.w);
766
1193
  },
767
1194
  /**
768
- * Creates a new [map pane](#map-pane) with the given name if it doesn't
769
- * exist already, then returns it. The pane is created as a child of
770
- * `container`, or as a child of the main map pane if not set.
1195
+ * Apply the current pitch/bearing CSS transform and cache its forward and inverse matrices.
771
1196
  *
772
- * @param {String} name leaflet pane
773
- * @param {HTMLElement} [container] parent element
774
- * @returns {HTMLElement} pane container
1197
+ * @since leaflet-rotate (0.5.0)
775
1198
  */
776
- // createPane(name, container) {
777
- // if (!this._rotate || name == 'mapPane') {
778
- // return mapProto.createPane.apply(this, arguments);
779
- // }
780
- // // init "rotatePane"
781
- // if (!this._rotatePane) {
782
- // // this._pivot = this.getSize().divideBy(2);
783
- // this._rotatePane = mapProto.createPane.call(this, 'rotatePane', this._mapPane);
784
- // L.DomUtil.setPosition(this._rotatePane, new L.Point(0, 0), this._bearing, this._pivot);
785
- // }
786
- // return mapProto.createPane.call(this, name, container || this._rotatePane);
787
- // },
788
- /**
789
- * Panes are DOM elements used to control the ordering of layers on
790
- * the map. You can access panes with [`map.getPane`](#map-getpane)
1199
+ _updatePitchTransform() {
1200
+ if (!this._pitch || !this._rotatePane) {
1201
+ return;
1202
+ }
1203
+ const pivot = this._getPixelCenter(), size = this.getSize(), perspective = Math.max(1, size.y * 1.5), position = this._rotatePane._leaflet_pos || new L.Point(0, 0), 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)`;
1204
+ this._rotatePane.style.transformOrigin = "0 0";
1205
+ this._rotatePane.style.transform = transform;
1206
+ const matrix = this._pitchTransform = new DOMMatrix(transform), a = matrix.m11, b = matrix.m21, c = matrix.m41, d = matrix.m12, e = matrix.m22, f = matrix.m42, g = matrix.m14, h = matrix.m24, i = matrix.m44, determinant = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g);
1207
+ this._pitchInverseTransform = {
1208
+ a: (e * i - f * h) / determinant,
1209
+ b: (c * h - b * i) / determinant,
1210
+ c: (b * f - c * e) / determinant,
1211
+ d: (f * g - d * i) / determinant,
1212
+ e: (a * i - c * g) / determinant,
1213
+ f: (c * d - a * f) / determinant,
1214
+ g: (d * h - e * g) / determinant,
1215
+ h: (b * g - a * h) / determinant,
1216
+ i: (a * e - b * d) / determinant
1217
+ };
1218
+ this._updatePitchSky();
1219
+ },
1220
+ /**
1221
+ * Fill the area above the far plane (where no tiles are loaded) with a sky gradient.
1222
+ * Style it through the `.leaflet-pitch-sky` class.
1223
+ *
1224
+ * @since leaflet-rotate (0.5.1)
1225
+ */
1226
+ _updatePitchSky() {
1227
+ const farY = this._pitch && this._pitchInverseTransform ? this._getPitchFarY() : -Infinity;
1228
+ if (farY <= 0) {
1229
+ if (this._pitchSky) {
1230
+ this._pitchSky.style.display = "none";
1231
+ }
1232
+ return;
1233
+ }
1234
+ if (!this._pitchSky) {
1235
+ this._pitchSky = L.DomUtil.create("div", "leaflet-pitch-sky", this._container);
1236
+ Object.assign(this._pitchSky.style, {
1237
+ position: "absolute",
1238
+ left: 0,
1239
+ top: 0,
1240
+ width: "100%",
1241
+ zIndex: 0,
1242
+ pointerEvents: "none",
1243
+ background: "linear-gradient(#9ec5e8, #dfe9f0)"
1244
+ });
1245
+ }
1246
+ this._pitchSky.style.display = "";
1247
+ this._pitchSky.style.height = `${Math.ceil(farY) + 1}px`;
1248
+ },
1249
+ /**
1250
+ * Set the map pitch in degrees. Values are clamped to the range 0–85.
1251
+ *
1252
+ * @param {number} pitch Pitch angle in degrees; clamped to [0, 85].
1253
+ * @param {Object} [options] Animation options (`animate`, `duration`, `easeLinearity`).
1254
+ *
1255
+ * @returns {L.Map} This map instance.
1256
+ *
1257
+ * @fires L.Map#pitch
1258
+ *
1259
+ * @since leaflet-rotate (0.5.0)
1260
+ */
1261
+ setPitch(pitch, options) {
1262
+ if (options && options.animate) {
1263
+ this._animatePitchBearing(pitch, this.getBearing(), options);
1264
+ return this;
1265
+ }
1266
+ this._stopPitchBearingAnimation();
1267
+ this._setPitchAndBearing(pitch, this.getBearing());
1268
+ return this;
1269
+ },
1270
+ /**
1271
+ * Update pitch and bearing together, preserving the current map center.
1272
+ * Used by the Ctrl+drag handler to avoid applying two separate transforms.
1273
+ *
1274
+ * @param {number} pitch Pitch angle in degrees.
1275
+ * @param {number} bearing Bearing angle in degrees.
1276
+ *
1277
+ * @fires L.Map#pitch
1278
+ * @fires L.Map#rotate
1279
+ *
1280
+ * @since leaflet-rotate (0.5.0)
1281
+ */
1282
+ _setPitchAndBearing(pitch, bearing) {
1283
+ if (!this._rotate) {
1284
+ return;
1285
+ }
1286
+ const clampedPitch = Math.max(0, Math.min(pitch, 85)) * (Math.PI / 180), clampedBearing = L.Util.wrapNum(bearing, [0, 360]) * (Math.PI / 180), pitchChanged = clampedPitch !== this._pitch;
1287
+ if (!pitchChanged && clampedBearing === this._bearing) {
1288
+ return;
1289
+ }
1290
+ const mapCenter = this._loaded ? this.getCenter() : void 0;
1291
+ if (pitchChanged) {
1292
+ this._pitch = clampedPitch;
1293
+ this._rotatePane.style.transformOrigin = L.version.startsWith("2.") ? "0 0" : "";
1294
+ }
1295
+ this._setBearing(bearing, mapCenter);
1296
+ if (pitchChanged) {
1297
+ if (!this._pitch) {
1298
+ this._updatePitchSky();
1299
+ }
1300
+ this.fire("pitch");
1301
+ }
1302
+ },
1303
+ /**
1304
+ * Get the current map pitch in degrees.
1305
+ *
1306
+ * @returns {number} Pitch angle in degrees.
1307
+ *
1308
+ * @since leaflet-rotate (0.5.0)
1309
+ */
1310
+ getPitch() {
1311
+ return (this._pitch || 0) * (180 / Math.PI);
1312
+ },
1313
+ /**
1314
+ * Zoom while keeping the given point fixed on screen, also when the map is pitched.
1315
+ *
1316
+ * Leaflet interpolates in container space, which is not linear under perspective;
1317
+ * interpolating in layer space keeps the point under the cursor.
1318
+ *
1319
+ * @param {L.LatLng|L.Point} latlng Geographic point or container point to keep fixed.
1320
+ * @param {number} zoom Target zoom level.
1321
+ * @param {Object} [options] Zoom options.
1322
+ *
1323
+ * @returns {L.Map} This map instance.
1324
+ *
1325
+ * @since leaflet-rotate (0.5.1)
1326
+ */
1327
+ setZoomAround(latlng, zoom, options) {
1328
+ if (!this._pitch) {
1329
+ return mapProto.setZoomAround.apply(this, arguments);
1330
+ }
1331
+ const scale = this.getZoomScale(zoom), size = this.getSize(), center = this.containerPointToLayerPoint(size.divideBy(2)), containerPoint = latlng instanceof L.Point ? latlng : this.latLngToContainerPoint(latlng), point = this.containerPointToLayerPoint(containerPoint), newCenter = this.layerPointToLatLng(center.add(point.subtract(center).multiplyBy(1 - 1 / scale)));
1332
+ options = Object.assign({}, options);
1333
+ if (options.animate === void 0 && new L.Bounds(new L.Point(0, 0), size).contains(containerPoint)) {
1334
+ options.animate = true;
1335
+ }
1336
+ return this.setView(newCenter, zoom, { zoom: options });
1337
+ },
1338
+ /**
1339
+ * Get the current map bearing. Enable the `rotate` map option before calling.
1340
+ *
1341
+ * @returns {number} Bearing in degrees, normalized to [0, 360).
1342
+ *
1343
+ * @since leaflet-rotate (v0.1.0)
1344
+ */
1345
+ getBearing() {
1346
+ return this._bearing * (180 / Math.PI);
1347
+ },
1348
+ /**
1349
+ * Creates a new [map pane](#map-pane) with the given name if it doesn't
1350
+ * exist already, then returns it. The pane is created as a child of
1351
+ * `container`, or as a child of the main map pane if not set.
1352
+ *
1353
+ * @param {String} name leaflet pane
1354
+ * @param {HTMLElement} [container] parent element
1355
+ *
1356
+ * @returns {HTMLElement} pane container
1357
+ *
1358
+ * @since leaflet-rotate (v0.1.0)
1359
+ */
1360
+ // createPane(name, container) {
1361
+ // if (!this._rotate || name == 'mapPane') {
1362
+ // return mapProto.createPane.apply(this, arguments);
1363
+ // }
1364
+ // // init "rotatePane"
1365
+ // if (!this._rotatePane) {
1366
+ // // this._pivot = this.getSize().divideBy(2);
1367
+ // this._rotatePane = mapProto.createPane.call(this, 'rotatePane', this._mapPane);
1368
+ // L.DomUtil.setPosition(this._rotatePane, new L.Point(0, 0), this._bearing, this._pivot);
1369
+ // }
1370
+ // return mapProto.createPane.call(this, name, container || this._rotatePane);
1371
+ // },
1372
+ /**
1373
+ * Panes are DOM elements used to control the ordering of layers on
1374
+ * the map. You can access panes with [`map.getPane`](#map-getpane)
791
1375
  * or [`map.getPanes`](#map-getpanes) methods. New panes can be created
792
1376
  * with the [`map.createPane`](#map-createpane) method.
793
1377
  *
@@ -801,6 +1385,12 @@ var leafletRotate = (() => {
801
1385
  * - tooltipPane [HTMLElement = 650] - Pane for tooltips.
802
1386
  * - popupPane [HTMLElement = 700] - Pane for popups.
803
1387
  */
1388
+ /**
1389
+ * Create the map's panes, placing tile/vector layers in the transformed pane
1390
+ * and markers/overlays in the pane that remains upright.
1391
+ *
1392
+ * @since leaflet-rotate (v0.1.0)
1393
+ */
804
1394
  _initPanes() {
805
1395
  const panes = this._panes = {};
806
1396
  this._paneRenderers = {};
@@ -839,13 +1429,15 @@ var leafletRotate = (() => {
839
1429
  *
840
1430
  * @see https://github.com/Raruto/leaflet-rotate/issues/18
841
1431
  *
842
- * @param {L.LatLng} latlng coordinates
843
- * @param {Object} [options={}] padding options
1432
+ * @param {L.LatLng} latlng Geographic point to keep visible.
1433
+ * @param {Object} [options={}] Padding options (`padding`, `paddingTopLeft`, `paddingBottomRight`).
844
1434
  *
845
1435
  * @returns {L.Map} current map instance
1436
+ *
1437
+ * @since leaflet-rotate (v0.1.4)
846
1438
  */
847
1439
  panInside(latlng, options) {
848
- if (!this._rotate || Math.abs(this._bearing).toFixed(1) < 0.1) {
1440
+ if (!this._rotate || !this._pitch && Math.abs(this._bearing).toFixed(1) < 0.1) {
849
1441
  return mapProto.panInside.apply(this, arguments);
850
1442
  }
851
1443
  options = options || {};
@@ -871,9 +1463,12 @@ var leafletRotate = (() => {
871
1463
  *
872
1464
  * @see https://github.com/fnicollet/Leaflet/issues/7
873
1465
  *
874
- * @param {L.LatLngBounds} bounds coordinates
875
- * @param {Object} [options] pan options
876
- * @returns {L.Map} current map instance
1466
+ * @param {L.LatLngBounds} bounds Geographic bounds to fit inside the viewport.
1467
+ * @param {Object} [options] Pan animation options.
1468
+ *
1469
+ * @returns {L.Map} This map instance.
1470
+ *
1471
+ * @since leaflet-rotate (v0.1.0)
877
1472
  */
878
1473
  // panInsideBounds(bounds, options) {
879
1474
  // this._enforcingBounds = true;
@@ -938,14 +1533,16 @@ var leafletRotate = (() => {
938
1533
  * instead returns the minimum zoom level on which the map view fits into
939
1534
  * the given bounds in its entirety.
940
1535
  *
941
- * @param {L.LatLngBounds} bounds
942
- * @param {Boolean} [inside=false]
943
- * @param {L.Point} [padding=[0,0]]
1536
+ * @param {L.LatLngBounds} bounds Geographic bounds to fit.
1537
+ * @param {boolean} [inside=false] Fit the viewport inside bounds instead of bounds inside the viewport.
1538
+ * @param {L.Point|number[]} [padding=[0,0]] Pixel padding around the viewport.
944
1539
  *
945
- * @returns {Number} zoom level
1540
+ * @returns {number} Zoom level satisfying the fit.
1541
+ *
1542
+ * @since leaflet-rotate (v0.1.0)
946
1543
  */
947
1544
  getBoundsZoom(bounds, inside, padding) {
948
- if (!this._rotate || Math.abs(this._bearing).toFixed(1) < 0.1) {
1545
+ if (!this._rotate || !this._pitch && Math.abs(this._bearing).toFixed(1) < 0.1) {
949
1546
  return mapProto.getBoundsZoom.apply(this, arguments);
950
1547
  }
951
1548
  bounds = bounds instanceof L.LatLngBounds ? bounds : new L.LatLngBounds(bounds);
@@ -968,19 +1565,26 @@ var leafletRotate = (() => {
968
1565
  // return this.containerPointToLayerPoint(this.getSize()._divideBy(2));
969
1566
  // },
970
1567
  /**
971
- * Offset of the specified place to the current center in pixels
1568
+ * Return the pixel offset from the current center to the given geographic point.
1569
+ *
1570
+ * @param {L.LatLng} latlng Geographic point.
1571
+ *
1572
+ * @returns {L.Point} Offset in container pixels (the map pane is panned in screen space), adjusted for bearing and pitch.
972
1573
  *
973
- * @param {L.LatLng} latlng map coordinates
1574
+ * @since leaflet-rotate (v0.1.0)
974
1575
  */
975
1576
  _getCenterOffset(latlng) {
976
- let centerOffset = mapProto._getCenterOffset.apply(this, arguments);
977
- if (this._rotate) {
978
- centerOffset = centerOffset.rotate(this._bearing);
1577
+ if (!this._rotate) {
1578
+ return mapProto._getCenterOffset.apply(this, arguments);
979
1579
  }
980
- return centerOffset;
1580
+ return this.latLngToContainerPoint(latlng)._subtract(this.getSize()._divideBy(2));
981
1581
  },
982
1582
  /**
983
- * @since leaflet-rotate (v0.1)
1583
+ * Return the current position of the rotated pane.
1584
+ *
1585
+ * @returns {L.Point} Rotated-pane position in map-pane coordinates.
1586
+ *
1587
+ * @since leaflet-rotate (v0.1.0)
984
1588
  */
985
1589
  _getRotatePanePos() {
986
1590
  return this._rotatePanePos || new L.Point(0, 0);
@@ -989,15 +1593,31 @@ var leafletRotate = (() => {
989
1593
  // const topLeft = this._getNewPixelOrigin(center, zoom);
990
1594
  // return this.project(latlng, zoom)._subtract(topLeft);
991
1595
  //},
1596
+ /**
1597
+ * Calculate the pixel origin for a target view while preserving bearing-aware alignment.
1598
+ *
1599
+ * @param {L.LatLng} center Target center.
1600
+ * @param {number} zoom Target zoom level.
1601
+ *
1602
+ * @returns {L.Point} New pixel origin.
1603
+ *
1604
+ * @since leaflet-rotate (v0.1.0)
1605
+ */
992
1606
  _getNewPixelOrigin(center, zoom) {
993
- if (!this._rotate || L.version.startsWith("2.")) {
1607
+ if (!this._rotate) {
994
1608
  return mapProto._getNewPixelOrigin.apply(this, arguments);
995
1609
  }
996
- const viewHalf = this.getSize()._divideBy(2);
997
- return this.project(center, zoom).rotate(this._bearing)._subtract(viewHalf)._add(this._getMapPanePos())._add(this._getRotatePanePos()).rotate(-this._bearing)._round();
1610
+ return this.project(center, zoom)._subtract(this.containerPointToLayerPoint(this.getSize()._divideBy(2)))._round();
998
1611
  },
999
1612
  /**
1000
- * @since leaflet-rotate (v0.2)
1613
+ * Calculate the pixel bounds needed to cover the transformed viewport.
1614
+ *
1615
+ * @param {L.LatLng} [center=this.getCenter()] Target center.
1616
+ * @param {number} [zoom=this.getZoom()] Target zoom level.
1617
+ *
1618
+ * @returns {L.Bounds} Pixel bounds in projected map coordinates.
1619
+ *
1620
+ * @since leaflet-rotate (v0.2.0)
1001
1621
  *
1002
1622
  * @see src\layer\tile\GridLayer::_getTiledPixelBounds()
1003
1623
  */
@@ -1007,18 +1627,18 @@ var leafletRotate = (() => {
1007
1627
  if (!this._rotate && mapProto._getNewPixelBounds) {
1008
1628
  return mapProto._getNewPixelBounds.apply(this, arguments);
1009
1629
  }
1010
- const mapZoom = this._animatingZoom ? Math.max(this._animateToZoom, this.getZoom()) : this.getZoom(), scale = this.getZoomScale(mapZoom, zoom), pixelCenter = this.project(center, zoom).floor(), size = this.getSize(), halfSize = new L.Bounds([
1011
- this.containerPointToLayerPoint([0, 0]).floor(),
1012
- this.containerPointToLayerPoint([size.x, 0]).floor(),
1013
- this.containerPointToLayerPoint([0, size.y]).floor(),
1014
- this.containerPointToLayerPoint([size.x, size.y]).floor()
1015
- ]).getSize().divideBy(scale * 2);
1630
+ const mapZoom = this._animatingZoom ? Math.max(this._animateToZoom, this.getZoom()) : this.getZoom(), scale = this.getZoomScale(mapZoom, zoom), pixelCenter = this.project(center, zoom).floor(), [min, max] = this._getPitchedContainerBounds(0), corners = [[min.x, min.y], [max.x, min.y], [min.x, max.y], [max.x, max.y]].map((point) => this.containerPointToLayerPoint(point));
1631
+ if (this._pitch) {
1632
+ const viewCenter = this.containerPointToLayerPoint(this.getSize()._divideBy(2));
1633
+ return new L.Bounds(corners.map((point) => pixelCenter.add(point.subtract(viewCenter).divideBy(scale))));
1634
+ }
1635
+ const halfSize = new L.Bounds(corners.map((point) => point.floor())).getSize().divideBy(scale * 2);
1016
1636
  return new L.Bounds(pixelCenter.subtract(halfSize), pixelCenter.add(halfSize));
1017
1637
  },
1018
1638
  /**
1019
- * @since leaflet-rotate (v0.2)
1639
+ * @since leaflet-rotate (v0.2.0)
1020
1640
  *
1021
- * @return {L.Point} map pivot point (center)
1641
+ * @returns {L.Point} Map pivot point in pixel coordinates.
1022
1642
  */
1023
1643
  _getPixelCenter() {
1024
1644
  if (!this._rotate && mapProto._getPixelCenter) {
@@ -1027,15 +1647,19 @@ var leafletRotate = (() => {
1027
1647
  return this.getSize()._divideBy(2)._subtract(this._getMapPanePos());
1028
1648
  },
1029
1649
  /**
1030
- * @since leaflet-rotate (v0.2)
1031
- *
1032
1650
  * @see src\layer\vector\Renderer::_update()
1651
+ *
1652
+ * @param {number} padding Bounds padding as a fraction of map size.
1653
+ *
1654
+ * @returns {L.Bounds} Padded bounds in layer-point coordinates.
1655
+ *
1656
+ * @since leaflet-rotate (v0.2.0)
1033
1657
  */
1034
1658
  _getPaddedPixelBounds(padding) {
1035
1659
  if (!this._rotate && mapProto._getPaddedPixelBounds) {
1036
1660
  return mapProto._getPaddedPixelBounds.apply(this, arguments);
1037
1661
  }
1038
- const p = padding, size = this.getSize(), padMin = size.multiplyBy(-p), padMax = size.multiplyBy(1 + p);
1662
+ const [padMin, padMax] = this._getPitchedContainerBounds(padding);
1039
1663
  return new L.Bounds([
1040
1664
  this.containerPointToLayerPoint([padMin.x, padMin.y]).floor(),
1041
1665
  this.containerPointToLayerPoint([padMin.x, padMax.y]).floor(),
@@ -1043,6 +1667,15 @@ var leafletRotate = (() => {
1043
1667
  this.containerPointToLayerPoint([padMax.x, padMax.y]).floor()
1044
1668
  ]);
1045
1669
  },
1670
+ /**
1671
+ * Build and fire Leaflet's location-found event, including the device heading.
1672
+ *
1673
+ * @param {GeolocationPosition} pos Browser geolocation result.
1674
+ *
1675
+ * @fires L.Map#locationfound
1676
+ *
1677
+ * @since leaflet-rotate (v0.1.0)
1678
+ */
1046
1679
  _handleGeolocationResponse(pos) {
1047
1680
  if (!this._container._leaflet_id) {
1048
1681
  return;
@@ -1070,6 +1703,7 @@ var leafletRotate = (() => {
1070
1703
  * @see https://github.com/ronikar/Leaflet/blob/5c480ef959b947c3beed7065425a5a36c486262b/src/geo/LatLngBounds.js#L253-L264
1071
1704
  *
1072
1705
  * @param {L.Bounds} points
1706
+ *
1073
1707
  * @returns {L.Bounds}
1074
1708
  */
1075
1709
  // toCircumscribedBounds(points) {
@@ -1084,6 +1718,11 @@ var leafletRotate = (() => {
1084
1718
 
1085
1719
  // src/map/handler/CompassBearing.js
1086
1720
  L.Map.CompassBearing = L.Handler.extend({
1721
+ /**
1722
+ * @param {L.Map} map Map whose bearing follows device orientation.
1723
+ *
1724
+ * @since leaflet-rotate (v0.1.0)
1725
+ */
1087
1726
  initialize(map) {
1088
1727
  this._map = map;
1089
1728
  if ("ondeviceorientationabsolute" in window) {
@@ -1093,19 +1732,37 @@ var leafletRotate = (() => {
1093
1732
  }
1094
1733
  this._throttled = L.Util.throttle(this._onDeviceOrientation, 100, this);
1095
1734
  },
1735
+ /**
1736
+ * Subscribe to the supported device-orientation event, if available.
1737
+ * On iOS, permission is requested first (call `enable()` from a user gesture).
1738
+ *
1739
+ * @since leaflet-rotate (v0.1.0)
1740
+ */
1096
1741
  addHooks() {
1097
1742
  if (this._map._rotate && this.__deviceOrientationEvent) {
1098
1743
  L.DomEvent.on(window, this.__deviceOrientationEvent, this._throttled, this);
1744
+ const DOE = window.DeviceOrientationEvent;
1745
+ if (DOE && DOE.requestPermission) {
1746
+ DOE.requestPermission().then((state) => "denied" === state && this.enabled() && this.disable()).catch(() => {
1747
+ });
1748
+ }
1099
1749
  } else {
1100
1750
  this.disable();
1101
1751
  }
1102
1752
  },
1753
+ /**
1754
+ * Unsubscribe from device orientation when the handler is disabled.
1755
+ *
1756
+ * @since leaflet-rotate (v0.1.0)
1757
+ */
1103
1758
  removeHooks() {
1104
1759
  if (this._map._rotate && this.__deviceOrientationEvent) {
1105
1760
  L.DomEvent.off(window, this.__deviceOrientationEvent, this._throttled, this);
1106
1761
  }
1107
1762
  },
1108
1763
  /**
1764
+ * Apply a device orientation reading to the map bearing.
1765
+ *
1109
1766
  * `DeviceOrientationEvent.absolute` - Indicates whether the device is providing absolute
1110
1767
  * orientation values (relatives to Magnetic North) or
1111
1768
  * using some arbitrary frame determined by the device.
@@ -1122,6 +1779,10 @@ var leafletRotate = (() => {
1122
1779
  * @see https://developer.mozilla.org/en-US/docs/Web/API/DeviceOrientationEvent/absolute
1123
1780
  * @see https://developer.mozilla.org/en-US/docs/Web/API/DeviceOrientationEvent/alpha
1124
1781
  * @see https://developer.mozilla.org/en-US/docs/Web/API/Window/orientation
1782
+ *
1783
+ * @param {DeviceOrientationEvent} e Orientation reading from the browser.
1784
+ *
1785
+ * @since leaflet-rotate (v0.1.0)
1125
1786
  */
1126
1787
  _onDeviceOrientation(e) {
1127
1788
  let angle = e.webkitCompassHeading != null ? e.webkitCompassHeading : e.alpha;
@@ -1150,11 +1811,18 @@ var leafletRotate = (() => {
1150
1811
  *
1151
1812
  * @type {Boolean}
1152
1813
  *
1814
+ * @since leaflet-rotate (v0.1.0)
1815
+ *
1153
1816
  * @see https://developer.mozilla.org/docs/Web/API/MutationObserver
1154
1817
  */
1155
1818
  trackContainerMutation: false
1156
1819
  });
1157
1820
  L.Map.ContainerMutation = L.Handler.extend({
1821
+ /**
1822
+ * Start observing the map container's style attribute.
1823
+ *
1824
+ * @since leaflet-rotate (v0.1.0)
1825
+ */
1158
1826
  addHooks() {
1159
1827
  if (!this._observer) {
1160
1828
  this._observer = new MutationObserver(L.Util.bind(this._map.invalidateSize, this._map));
@@ -1167,6 +1835,11 @@ var leafletRotate = (() => {
1167
1835
  attributeFilter: ["style"]
1168
1836
  });
1169
1837
  },
1838
+ /**
1839
+ * Stop observing container mutations.
1840
+ *
1841
+ * @since leaflet-rotate (v0.1.0)
1842
+ */
1170
1843
  removeHooks() {
1171
1844
  this._observer.disconnect();
1172
1845
  }
@@ -1183,32 +1856,65 @@ var leafletRotate = (() => {
1183
1856
  }
1184
1857
  L.Map.mergeOptions({
1185
1858
  /**
1186
- * Set it to false if you don't want the map to
1187
- * zoom beyond min/max zoom and then bounce back
1188
- * when pinch-zooming.
1859
+ * Whether pinch zoom may temporarily exceed min/max zoom and bounce back.
1189
1860
  *
1190
1861
  * @type {Boolean}
1862
+ *
1863
+ * @since leaflet-rotate (v0.1.0)
1191
1864
  */
1192
1865
  bounceAtZoomLimits: true
1193
1866
  });
1194
1867
  L.Map.TouchGestures = L.Handler.extend({
1868
+ /**
1869
+ * Initialize the shared handler from the map's touch zoom/rotation options.
1870
+ *
1871
+ * @param {L.Map} map Map whose touch gestures are handled.
1872
+ *
1873
+ * @since leaflet-rotate (v0.1.0)
1874
+ */
1195
1875
  initialize(map) {
1196
1876
  this._map = map;
1197
1877
  this.rotate = !!this._map.options.touchRotate;
1198
1878
  this.zoom = !!this._map.options.touchZoom;
1879
+ this.pitch = !!this._map.options.touchPitch;
1199
1880
  },
1881
+ /**
1882
+ * Listen for the start of two-finger gestures on the map container.
1883
+ *
1884
+ * @since leaflet-rotate (v0.1.0)
1885
+ */
1200
1886
  addHooks() {
1201
1887
  L.DomEvent.on(this._map._container, "touchstart", this._onTouchStart, this);
1202
1888
  },
1889
+ /**
1890
+ * Remove the touch-start listener.
1891
+ *
1892
+ * @since leaflet-rotate (v0.1.0)
1893
+ */
1203
1894
  removeHooks() {
1204
1895
  L.DomEvent.off(this._map._container, "touchstart", this._onTouchStart, this);
1205
1896
  },
1897
+ /**
1898
+ * Record initial touch points and start tracking a two-finger gesture.
1899
+ *
1900
+ * @param {TouchEvent} e Touch-start event.
1901
+ *
1902
+ * @since leaflet-rotate (v0.1.0)
1903
+ */
1206
1904
  _onTouchStart(e) {
1207
1905
  const map = this._map;
1208
- if (!e.touches || e.touches.length !== 2 || map._animatingZoom || this._zooming || this._rotating) {
1906
+ if (!e.touches || e.touches.length !== 2 || map._animatingZoom || this._zooming || this._rotating || this._gesture) {
1209
1907
  return;
1210
1908
  }
1211
1909
  const p1 = touchToContainerPoint(map, e.touches[0]), p2 = touchToContainerPoint(map, e.touches[1]), vector = p1.subtract(p2);
1910
+ const canPitch = this.pitch && map._rotate && Math.abs(vector.x) > Math.abs(vector.y);
1911
+ if (!canPitch && !this.zoom && !this.rotate) {
1912
+ return;
1913
+ }
1914
+ this._gesture = canPitch ? "pending" : "pinch";
1915
+ this._startP1 = p1;
1916
+ this._startP2 = p2;
1917
+ map._stopPitchBearingAnimation();
1212
1918
  this._centerPoint = map.getSize()._divideBy(2);
1213
1919
  this._startLatLng = map.containerPointToLatLng(this._centerPoint);
1214
1920
  if (this.zoom) {
@@ -1236,12 +1942,44 @@ var leafletRotate = (() => {
1236
1942
  L.DomEvent.on(document, "touchmove", this._onTouchMove, this).on(document, "touchend touchcancel", this._onTouchEnd, this);
1237
1943
  L.DomEvent.preventDefault(e);
1238
1944
  },
1945
+ /**
1946
+ * Update bearing and/or zoom while the two touch points move.
1947
+ *
1948
+ * @param {TouchEvent} e Touch-move event.
1949
+ *
1950
+ * @since leaflet-rotate (v0.1.0)
1951
+ */
1239
1952
  _onTouchMove(e) {
1240
- if (!e.touches || e.touches.length !== 2 || !(this._zooming || this._rotating)) {
1953
+ if (!e.touches || e.touches.length !== 2 || !this._gesture) {
1241
1954
  return;
1242
1955
  }
1243
1956
  const map = this._map, p1 = touchToContainerPoint(map, e.touches[0]), p2 = touchToContainerPoint(map, e.touches[1]), vector = p1.subtract(p2), scale = p1.distanceTo(p2) / this._startDist;
1244
1957
  let delta;
1958
+ if (this._gesture === "pending") {
1959
+ const d1 = p1.subtract(this._startP1), d2 = p2.subtract(this._startP2);
1960
+ if (Math.max(d1.x * d1.x + d1.y * d1.y, d2.x * d2.x + d2.y * d2.y) < 25) {
1961
+ L.DomEvent.preventDefault(e);
1962
+ return;
1963
+ }
1964
+ const vertical = (d) => Math.abs(d.y) > Math.abs(d.x);
1965
+ if (vertical(d1) && vertical(d2) && Math.sign(d1.y) === Math.sign(d2.y)) {
1966
+ this._gesture = "pitch";
1967
+ this._lastPitchY = (this._startP1.y + this._startP2.y) / 2;
1968
+ this._zooming = this._rotating = false;
1969
+ } else {
1970
+ this._gesture = "pinch";
1971
+ }
1972
+ }
1973
+ if (this._gesture === "pitch") {
1974
+ const y = (p1.y + p2.y) / 2;
1975
+ map._setPitchAndBearing(map.getPitch() - (y - this._lastPitchY) * 0.5, map.getBearing());
1976
+ this._lastPitchY = y;
1977
+ L.DomEvent.preventDefault(e);
1978
+ return;
1979
+ }
1980
+ if (!(this._zooming || this._rotating)) {
1981
+ return;
1982
+ }
1245
1983
  if (this._rotating) {
1246
1984
  const theta = Math.atan(vector.x / vector.y);
1247
1985
  let bearingDelta = (theta - this._startTheta) * (180 / Math.PI);
@@ -1267,8 +2005,8 @@ var leafletRotate = (() => {
1267
2005
  if (scale === 1 && delta.x === 0 && delta.y === 0) {
1268
2006
  return;
1269
2007
  }
1270
- const alpha = -map.getBearing() * (Math.PI / 180);
1271
- this._center = map.unproject(map.project(this._pinchStartLatLng).subtract(delta.rotate(alpha)));
2008
+ const mapDelta = map._pitch ? map.containerPointToLayerPoint(this._centerPoint.add(delta)).subtract(map.containerPointToLayerPoint(this._centerPoint)) : delta.rotate(-map.getBearing() * (Math.PI / 180));
2009
+ this._center = map.unproject(map.project(this._pinchStartLatLng).subtract(mapDelta));
1272
2010
  }
1273
2011
  }
1274
2012
  if (!this._moved) {
@@ -1286,9 +2024,18 @@ var leafletRotate = (() => {
1286
2024
  }
1287
2025
  L.DomEvent.preventDefault(e);
1288
2026
  },
2027
+ /**
2028
+ * Finish the gesture, settle the zoom and remove document listeners.
2029
+ *
2030
+ * @since leaflet-rotate (v0.1.0)
2031
+ */
1289
2032
  _onTouchEnd() {
1290
- if (!this._moved || !(this._zooming || this._rotating)) {
2033
+ const gesture = this._gesture;
2034
+ delete this._gesture;
2035
+ if (gesture === "pitch" || gesture === "pending" || !this._moved || !(this._zooming || this._rotating)) {
1291
2036
  this._zooming = false;
2037
+ this._rotating = false;
2038
+ L.DomEvent.off(document, "touchmove", this._onTouchMove, this).off(document, "touchend touchcancel", this._onTouchEnd, this);
1292
2039
  return;
1293
2040
  }
1294
2041
  this._zooming = false;
@@ -1311,50 +2058,279 @@ var leafletRotate = (() => {
1311
2058
  * Whether the map can be rotated with a two-finger rotation gesture
1312
2059
  *
1313
2060
  * @type {Boolean}
2061
+ *
2062
+ * @since leaflet-rotate (v0.1.0)
1314
2063
  */
1315
2064
  touchRotate: false
1316
2065
  });
1317
2066
  L.Map.TouchRotate = L.Handler.extend({
2067
+ /**
2068
+ * Enable rotation handling in the map's shared touch gesture handler.
2069
+ *
2070
+ * @since leaflet-rotate (v0.1.0)
2071
+ */
1318
2072
  addHooks() {
1319
2073
  this._map.touchGestures.enable();
1320
2074
  this._map.touchGestures.rotate = true;
1321
2075
  },
2076
+ /**
2077
+ * Disable rotation handling without affecting pinch zoom.
2078
+ *
2079
+ * @since leaflet-rotate (v0.1.0)
2080
+ */
1322
2081
  removeHooks() {
1323
2082
  this._map.touchGestures.rotate = false;
1324
2083
  }
1325
2084
  });
1326
2085
  L.Map.addInitHook("addHandler", "touchRotate", L.Map.TouchRotate);
1327
2086
 
2087
+ // src/map/handler/TouchPitch.js
2088
+ L.Map.mergeOptions({
2089
+ /**
2090
+ * Whether the map can be pitched by dragging two fingers vertically
2091
+ *
2092
+ * @type {Boolean}
2093
+ *
2094
+ * @since leaflet-rotate (0.5.1)
2095
+ */
2096
+ touchPitch: false
2097
+ });
2098
+ L.Map.TouchPitch = L.Handler.extend({
2099
+ /**
2100
+ * Enable pitch handling in the map's shared touch gesture handler.
2101
+ *
2102
+ * @since leaflet-rotate (0.5.1)
2103
+ */
2104
+ addHooks() {
2105
+ this._map.touchGestures.enable();
2106
+ this._map.touchGestures.pitch = true;
2107
+ },
2108
+ /**
2109
+ * Disable pitch handling without affecting pinch zoom and rotation.
2110
+ *
2111
+ * @since leaflet-rotate (0.5.1)
2112
+ */
2113
+ removeHooks() {
2114
+ this._map.touchGestures.pitch = false;
2115
+ }
2116
+ });
2117
+ L.Map.addInitHook("addHandler", "touchPitch", L.Map.TouchPitch);
2118
+
1328
2119
  // src/map/handler/ShiftKeyRotate.js
1329
2120
  L.Map.mergeOptions({
1330
2121
  /**
1331
- * Whether the map can be rotated with shift + wheel scroll
2122
+ * Whether Shift+wheel changes the map bearing.
2123
+ *
1332
2124
  * @type {Boolean}
2125
+ *
2126
+ * @since leaflet-rotate (v0.1.0)
2127
+ */
2128
+ shiftKeyRotate: true,
2129
+ /**
2130
+ * Whether Ctrl+drag (or right-button drag) changes bearing and pitch.
2131
+ *
2132
+ * @type {Boolean}
2133
+ *
2134
+ * @since leaflet-rotate (0.5.0)
1333
2135
  */
1334
- shiftKeyRotate: true
2136
+ pitchDrag: true
1335
2137
  });
1336
2138
  L.Map.ShiftKeyRotate = L.Handler.extend({
2139
+ /**
2140
+ * @param {L.Map} map Map controlled by this handler.
2141
+ *
2142
+ * @since leaflet-rotate (v0.1.0)
2143
+ */
2144
+ initialize(map) {
2145
+ this._map = map;
2146
+ this._onMouseDownBound = this._onMouseDown.bind(this);
2147
+ this._onMouseMoveBound = this._onMouseMove.bind(this);
2148
+ this._onMouseUpBound = this._onMouseUp.bind(this);
2149
+ this._onContextMenuBound = this._onContextMenu.bind(this);
2150
+ this._applyBound = this._apply.bind(this);
2151
+ },
2152
+ /**
2153
+ * Attach enabled wheel and pointer/mouse gesture listeners.
2154
+ *
2155
+ * @since leaflet-rotate (v0.1.0)
2156
+ */
1337
2157
  addHooks() {
1338
- L.DomEvent.on(this._map._container, "wheel", this._handleShiftScroll, this);
1339
- this._map.shiftKeyRotate.rotate = true;
2158
+ if (this._map.options.shiftKeyRotate) {
2159
+ L.DomEvent.on(this._map._container, "wheel", this._handleShiftScroll, this);
2160
+ }
2161
+ if (this._map.options.pitchDrag) {
2162
+ this.addPitchHooks();
2163
+ }
1340
2164
  },
2165
+ /**
2166
+ * Detach gesture listeners and cancel any active drag.
2167
+ *
2168
+ * @since leaflet-rotate (v0.1.0)
2169
+ */
1341
2170
  removeHooks() {
1342
2171
  L.DomEvent.off(this._map._container, "wheel", this._handleShiftScroll, this);
1343
- this._map.shiftKeyRotate.rotate = false;
2172
+ this.removePitchHooks();
2173
+ },
2174
+ /**
2175
+ * Listen for the start of Ctrl+drag pitch gestures.
2176
+ *
2177
+ * @since leaflet-rotate (0.5.0)
2178
+ */
2179
+ addPitchHooks() {
2180
+ if (this._pitchHooksAdded) {
2181
+ return;
2182
+ }
2183
+ this._pitchEvents = L.version.startsWith("2.") ? ["pointerdown", "pointermove", "pointerup pointercancel"] : ["mousedown", "mousemove", "mouseup"];
2184
+ this._map._container.addEventListener(this._pitchEvents[0], this._onMouseDownBound, true);
2185
+ this._map._container.addEventListener("contextmenu", this._onContextMenuBound, true);
2186
+ this._pitchHooksAdded = true;
2187
+ },
2188
+ /**
2189
+ * Stop listening for pitch gestures and remove active document listeners.
2190
+ *
2191
+ * @since leaflet-rotate (0.5.0)
2192
+ */
2193
+ removePitchHooks() {
2194
+ if (!this._pitchHooksAdded) {
2195
+ return;
2196
+ }
2197
+ this._map._container.removeEventListener(this._pitchEvents[0], this._onMouseDownBound, true);
2198
+ this._map._container.removeEventListener("contextmenu", this._onContextMenuBound, true);
2199
+ this._onMouseUp();
2200
+ delete this._pitchEvents;
2201
+ this._pitchHooksAdded = false;
1344
2202
  },
2203
+ /**
2204
+ * Rotate the map in fixed increments while Shift is held during wheel input.
2205
+ *
2206
+ * @param {WheelEvent} e Wheel event from the map container.
2207
+ *
2208
+ * @since leaflet-rotate (v0.1.0)
2209
+ */
1345
2210
  _handleShiftScroll(e) {
1346
2211
  if (e.shiftKey) {
1347
2212
  e.preventDefault();
1348
2213
  this._map.scrollWheelZoom.disable();
1349
- this._map.setBearing(this._map._bearing * (180 / Math.PI) + Math.sign(e.deltaY) * 5);
2214
+ this._map.setBearing(this._map.getBearing() + Math.sign(e.deltaY) * 5);
1350
2215
  } else {
1351
2216
  this._map.scrollWheelZoom.enable();
1352
2217
  }
2218
+ },
2219
+ /**
2220
+ * Start Ctrl+drag (or right-button drag) and capture subsequent movement outside the map.
2221
+ *
2222
+ * @param {MouseEvent|PointerEvent} e Gesture start event.
2223
+ *
2224
+ * @since leaflet-rotate (0.5.0)
2225
+ */
2226
+ _onMouseDown(e) {
2227
+ if (this._dragging) {
2228
+ return;
2229
+ }
2230
+ this._moved = false;
2231
+ if (!this._map._rotate || !this._map.options.pitchDrag || !(e.button === 2 || e.ctrlKey && e.button === 0)) {
2232
+ return;
2233
+ }
2234
+ const scrollWheelZoom = this._map.scrollWheelZoom;
2235
+ if (scrollWheelZoom && scrollWheelZoom._timer) {
2236
+ clearTimeout(scrollWheelZoom._timer);
2237
+ scrollWheelZoom._timer = null;
2238
+ scrollWheelZoom._delta = 0;
2239
+ scrollWheelZoom._startTime = null;
2240
+ }
2241
+ e.preventDefault();
2242
+ e.stopPropagation();
2243
+ e.stopImmediatePropagation();
2244
+ this._map._stopPitchBearingAnimation();
2245
+ this._dragging = true;
2246
+ this._lastX = e.clientX;
2247
+ this._lastY = e.clientY;
2248
+ this._pitchDelta = this._bearingDelta = 0;
2249
+ document.addEventListener(this._pitchEvents[1], this._onMouseMoveBound);
2250
+ for (const event of this._pitchEvents[2].split(" ")) {
2251
+ document.addEventListener(event, this._onMouseUpBound);
2252
+ }
2253
+ },
2254
+ /**
2255
+ * Accumulate pointer movement as pitch (vertical) and bearing (horizontal).
2256
+ * Far from the horizontal center line the bearing follows the pointer angle
2257
+ * around the map center.
2258
+ *
2259
+ * @param {MouseEvent|PointerEvent} e Gesture movement event.
2260
+ *
2261
+ * @since leaflet-rotate (0.5.0)
2262
+ */
2263
+ _onMouseMove(e) {
2264
+ const rect = this._map._container.getBoundingClientRect(), cx = rect.left + rect.width / 2, cy = rect.top + rect.height / 2, x = e.clientX, y = e.clientY;
2265
+ this._pitchDelta += (this._lastY - y) * 0.5;
2266
+ this._bearingDelta += Math.abs(cy - this._lastY) > 100 ? (Math.atan2(y - cy, x - cx) - Math.atan2(y - cy, this._lastX - cx)) * (180 / Math.PI) : (x - this._lastX) * 0.8 * (y < cy ? 1 : -1);
2267
+ this._lastX = x;
2268
+ this._lastY = y;
2269
+ this._moved = true;
2270
+ if (!this._frame) {
2271
+ this._frame = requestAnimationFrame(this._applyBound);
2272
+ }
2273
+ e.preventDefault();
2274
+ },
2275
+ /**
2276
+ * Apply accumulated movement once per animation frame.
2277
+ *
2278
+ * @since leaflet-rotate (0.5.1)
2279
+ */
2280
+ _apply() {
2281
+ this._frame = null;
2282
+ if (!this._pitchDelta && !this._bearingDelta) {
2283
+ return;
2284
+ }
2285
+ this._map._setPitchAndBearing(this._map.getPitch() + this._pitchDelta, this._map.getBearing() + this._bearingDelta);
2286
+ this._pitchDelta = this._bearingDelta = 0;
2287
+ },
2288
+ /**
2289
+ * Suppress the context menu that follows a right-button drag.
2290
+ *
2291
+ * @param {MouseEvent} e Context menu event.
2292
+ *
2293
+ * @since leaflet-rotate (0.5.1)
2294
+ */
2295
+ _onContextMenu(e) {
2296
+ if (this._dragging || this._moved) {
2297
+ e.preventDefault();
2298
+ e.stopPropagation();
2299
+ }
2300
+ },
2301
+ /**
2302
+ * End Ctrl+drag, flush pending movement and clear document-level listeners.
2303
+ *
2304
+ * @since leaflet-rotate (0.5.0)
2305
+ */
2306
+ _onMouseUp() {
2307
+ if (!this._pitchEvents || !this._dragging) {
2308
+ return;
2309
+ }
2310
+ document.removeEventListener(this._pitchEvents[1], this._onMouseMoveBound);
2311
+ for (const event of this._pitchEvents[2].split(" ")) {
2312
+ document.removeEventListener(event, this._onMouseUpBound);
2313
+ }
2314
+ cancelAnimationFrame(this._frame);
2315
+ if (this._map._mapPane) {
2316
+ this._apply();
2317
+ }
2318
+ this._frame = null;
2319
+ this._dragging = false;
2320
+ delete this._lastX;
2321
+ delete this._lastY;
1353
2322
  }
1354
2323
  });
1355
- L.Map.addInitHook("addHandler", "shiftKeyRotate", L.Map.ShiftKeyRotate);
1356
2324
  L.Map.addInitHook(function() {
1357
- if (this.scrollWheelZoom.enabled() && this.shiftKeyRotate.enabled()) {
2325
+ if (this.options.shiftKeyRotate) {
2326
+ this.shiftKeyRotate = new L.Map.ShiftKeyRotate(this);
2327
+ this.shiftKeyRotate.enable();
2328
+ } else if (this.options.pitchDrag) {
2329
+ this.shiftKeyRotate = new L.Map.ShiftKeyRotate(this);
2330
+ this.shiftKeyRotate.addPitchHooks();
2331
+ this.on("unload", this.shiftKeyRotate.removePitchHooks, this.shiftKeyRotate);
2332
+ }
2333
+ if (this.scrollWheelZoom.enabled() && this.options.shiftKeyRotate) {
1358
2334
  this.scrollWheelZoom.disable();
1359
2335
  this.scrollWheelZoom.enable();
1360
2336
  }
@@ -1370,6 +2346,8 @@ var leafletRotate = (() => {
1370
2346
  * for touch-capable web browsers.
1371
2347
  *
1372
2348
  * @type {(Boolean|String)}
2349
+ *
2350
+ * @since leaflet-rotate (v0.1.0)
1373
2351
  */
1374
2352
  touchZoom: L.Browser.touch,
1375
2353
  /**
@@ -1380,15 +2358,27 @@ var leafletRotate = (() => {
1380
2358
  * when pinch-zooming.
1381
2359
  *
1382
2360
  * @type {Boolean}
2361
+ *
2362
+ * @since leaflet-rotate (v0.1.0)
1383
2363
  */
1384
2364
  bounceAtZoomLimits: false
1385
2365
  });
1386
2366
  L.Map.TouchZoom = L.Handler.extend({
2367
+ /**
2368
+ * Enable pinch zoom handling and mark the shared touch handler as active.
2369
+ *
2370
+ * @since leaflet-rotate (v0.1.0)
2371
+ */
1387
2372
  addHooks() {
1388
2373
  this._map._container.classList.add("leaflet-touch-zoom");
1389
2374
  this._map.touchGestures.enable();
1390
2375
  this._map.touchGestures.zoom = true;
1391
2376
  },
2377
+ /**
2378
+ * Disable pinch zoom handling while preserving touch rotation state.
2379
+ *
2380
+ * @since leaflet-rotate (v0.1.0)
2381
+ */
1392
2382
  removeHooks() {
1393
2383
  this._map._container.classList.remove("leaflet-touch-zoom");
1394
2384
  this._map.touchGestures.zoom = false;
@@ -1400,93 +2390,166 @@ var leafletRotate = (() => {
1400
2390
  L.Control.Rotate = L.Control.extend({
1401
2391
  options: {
1402
2392
  position: "topleft",
1403
- closeOnZeroBearing: true
2393
+ closeOnZeroBearing: true,
2394
+ visualizePitch: true
1404
2395
  },
2396
+ /**
2397
+ * Create the control element and subscribe to map rotation and pitch.
2398
+ *
2399
+ * @param {L.Map} map
2400
+ *
2401
+ * @returns {HTMLElement} Control container.
2402
+ *
2403
+ * @since leaflet-rotate (v0.1.0)
2404
+ */
1405
2405
  onAdd(map) {
1406
2406
  const container = this._container = L.DomUtil.create("div", "leaflet-control-rotate leaflet-bar");
1407
2407
  const arrow = this._arrow = L.DomUtil.create("span", "leaflet-control-rotate-arrow");
1408
2408
  arrow.style.backgroundImage = `url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg' fill='%23333'%3E%3Cpath d='M10.5 14l4-8 4 8h-8z'/%3E%3Cpath d='M10.5 16l4 8 4-8h-8z' fill='%23ccc'/%3E%3C/svg%3E")`;
1409
- arrow.style.cursor = "grab";
1410
2409
  arrow.style.display = "block";
1411
2410
  arrow.style.width = "100%";
1412
2411
  arrow.style.height = "100%";
1413
2412
  arrow.style.backgroundRepeat = "no-repeat";
1414
2413
  arrow.style.backgroundPosition = "50%";
2414
+ arrow.style.pointerEvents = "none";
1415
2415
  const link = this._link = L.DomUtil.create("a", "leaflet-control-rotate-toggle", container);
1416
2416
  link.appendChild(arrow);
1417
2417
  link.href = "#";
1418
- link.title = "Rotate map";
1419
- L.DomEvent.on(link, "dblclick", L.DomEvent.stopPropagation).on(link, "mousedown", this._handleMouseDown, this).on(link, "click", L.DomEvent.stop).on(link, "click", this._cycleState, this).on(link, "click", this._refocusOnMap, this);
2418
+ link.title = this.options.visualizePitch ? "Reset bearing and pitch" : "Reset bearing to north";
2419
+ link.setAttribute("role", "button");
2420
+ link.setAttribute("aria-label", link.title);
2421
+ link.style.cursor = "grab";
2422
+ link.style.touchAction = "none";
2423
+ this._onPointerMoveBound = this._onPointerMove.bind(this);
2424
+ this._onPointerUpBound = this._onPointerUp.bind(this);
2425
+ L.DomEvent.on(link, "dblclick contextmenu", L.DomEvent.stop).on(link, "click", L.DomEvent.stop).on(link, "click", this._onClick, this).on(link, "click", this._refocusOnMap, this);
2426
+ link.addEventListener("pointerdown", this._onPointerDown.bind(this));
2427
+ map.on("rotate pitch", this._restyle, this);
2428
+ this._map = map;
1420
2429
  this._restyle();
1421
- map.on("rotate", this._restyle, this);
1422
- this._follow = false;
1423
- this._canFollow = false;
1424
- if (this.options.closeOnZeroBearing && map.getBearing() === 0) {
1425
- container.style.display = "none";
1426
- }
1427
2430
  return container;
1428
2431
  },
2432
+ /**
2433
+ * Remove the map event listener when the control is detached.
2434
+ *
2435
+ * @param {L.Map} map
2436
+ *
2437
+ * @since leaflet-rotate (v0.1.0)
2438
+ */
1429
2439
  onRemove(map) {
1430
- map.off("rotate", this._restyle, this);
1431
- },
1432
- _handleMouseDown(e) {
1433
- L.DomEvent.stop(e);
1434
- this.dragging = true;
1435
- this.dragstartX = e.pageX;
1436
- this.dragstartY = e.pageY;
1437
- L.DomEvent.on(document, "mousemove", this._handleMouseDrag, this).on(document, "mouseup", this._handleMouseUp, this);
1438
- },
1439
- _handleMouseUp(e) {
1440
- L.DomEvent.stop(e);
1441
- this.dragging = false;
1442
- L.DomEvent.off(document, "mousemove", this._handleMouseDrag, this).off(document, "mouseup", this._handleMouseUp, this);
1443
- },
1444
- _handleMouseDrag(e) {
1445
- if (!this.dragging) {
2440
+ this._onPointerUp();
2441
+ map.off("rotate pitch", this._restyle, this);
2442
+ },
2443
+ /**
2444
+ * Reset bearing (and pitch) unless the click ends a drag.
2445
+ *
2446
+ * @since leaflet-rotate (0.5.1)
2447
+ */
2448
+ _onClick() {
2449
+ if (this._moved) {
2450
+ this._moved = false;
1446
2451
  return;
1447
2452
  }
1448
- const deltaX = e.clientX - this.dragstartX;
1449
- this._map.setBearing(deltaX);
1450
- },
1451
- _cycleState(ev) {
1452
- if (!this._map) {
2453
+ if (!this._map || !this._map._rotate) {
1453
2454
  return;
1454
2455
  }
1455
- const map = this._map;
1456
- if (!map.touchRotate.enabled() && !map.compassBearing.enabled()) {
1457
- map.touchRotate.enable();
1458
- } else if (!map.compassBearing.enabled()) {
1459
- map.touchRotate.disable();
1460
- (DeviceOrientationEvent && DeviceOrientationEvent.requestPermission ? DeviceOrientationEvent.requestPermission() : Promise.resolve("granted")).then((state) => "granted" === state && map.compassBearing.enable());
2456
+ if (this.options.visualizePitch) {
2457
+ this._map.resetNorthPitch();
1461
2458
  } else {
1462
- map.compassBearing.disable();
1463
- map.setBearing(0);
1464
- if (this.options.closeOnZeroBearing) {
1465
- map.touchRotate.enable();
1466
- }
2459
+ this._map.resetNorth();
2460
+ }
2461
+ },
2462
+ /**
2463
+ * Start dragging the control (mouse, pen or touch).
2464
+ *
2465
+ * @param {PointerEvent} e
2466
+ *
2467
+ * @since leaflet-rotate (v0.1.0)
2468
+ */
2469
+ _onPointerDown(e) {
2470
+ if (!this._map || !this._map._rotate || e.button > 0 || this._pointerId != null) {
2471
+ return;
2472
+ }
2473
+ e.preventDefault();
2474
+ e.stopPropagation();
2475
+ this._pointerId = e.pointerId;
2476
+ this._startX = this._lastX = e.clientX;
2477
+ this._startY = this._lastY = e.clientY;
2478
+ this._moved = false;
2479
+ this._link.style.cursor = "grabbing";
2480
+ document.addEventListener("pointermove", this._onPointerMoveBound);
2481
+ document.addEventListener("pointerup", this._onPointerUpBound);
2482
+ document.addEventListener("pointercancel", this._onPointerUpBound);
2483
+ },
2484
+ /**
2485
+ * Horizontal movement changes the bearing, vertical movement the pitch.
2486
+ *
2487
+ * @param {PointerEvent} e
2488
+ *
2489
+ * @since leaflet-rotate (v0.1.0)
2490
+ */
2491
+ _onPointerMove(e) {
2492
+ if (e.pointerId !== this._pointerId) {
2493
+ return;
2494
+ }
2495
+ e.preventDefault();
2496
+ if (!this._moved && Math.hypot(e.clientX - this._startX, e.clientY - this._startY) < 3) {
2497
+ return;
2498
+ }
2499
+ if (!this._moved) {
2500
+ this._moved = true;
2501
+ this._map._stopPitchBearingAnimation();
2502
+ }
2503
+ const map = this._map, pitch = this.options.visualizePitch ? map.getPitch() + (this._lastY - e.clientY) * 0.5 : map.getPitch();
2504
+ map._setPitchAndBearing(pitch, map.getBearing() + (e.clientX - this._lastX) * 0.8);
2505
+ this._lastX = e.clientX;
2506
+ this._lastY = e.clientY;
2507
+ },
2508
+ /**
2509
+ * Stop dragging and remove document listeners.
2510
+ *
2511
+ * @param {PointerEvent} [e]
2512
+ *
2513
+ * @since leaflet-rotate (v0.1.0)
2514
+ */
2515
+ _onPointerUp(e) {
2516
+ if (e && e.pointerId !== this._pointerId) {
2517
+ return;
2518
+ }
2519
+ document.removeEventListener("pointermove", this._onPointerMoveBound);
2520
+ document.removeEventListener("pointerup", this._onPointerUpBound);
2521
+ document.removeEventListener("pointercancel", this._onPointerUpBound);
2522
+ if (e && this._moved && !this._link.contains(e.target)) {
2523
+ this._moved = false;
2524
+ }
2525
+ delete this._pointerId;
2526
+ if (this._link) {
2527
+ this._link.style.cursor = "grab";
2528
+ this._restyle();
1467
2529
  }
1468
- this._restyle();
1469
2530
  },
2531
+ /**
2532
+ * Update the icon (bearing and pitch) and the control visibility.
2533
+ *
2534
+ * @listens L.Map#rotate
2535
+ * @listens L.Map#pitch
2536
+ *
2537
+ * @since leaflet-rotate (v0.1.0)
2538
+ */
1470
2539
  _restyle() {
1471
- if (!this._map.options.rotate) {
2540
+ const map = this._map;
2541
+ if (!map) {
2542
+ return;
2543
+ }
2544
+ if (!map.options.rotate) {
1472
2545
  this._link.classList.add("leaflet-disabled");
1473
- } else {
1474
- const map = this._map;
1475
- const bearing = map.getBearing();
1476
- this._arrow.style.transform = "rotate(" + bearing + "deg)";
1477
- if (bearing && this.options.closeOnZeroBearing) {
1478
- this._container.style.display = "block";
1479
- }
1480
- if (map.compassBearing.enabled()) {
1481
- this._link.style.backgroundColor = "orange";
1482
- } else if (map.touchRotate.enabled()) {
1483
- this._link.style.backgroundColor = null;
1484
- } else {
1485
- this._link.style.backgroundColor = "grey";
1486
- if (0 === bearing && this.options.closeOnZeroBearing) {
1487
- this._container.style.display = "none";
1488
- }
1489
- }
2546
+ return;
2547
+ }
2548
+ const bearing = map.getBearing(), pitch = map.getPitch();
2549
+ this._arrow.style.transform = this.options.visualizePitch && pitch ? `scale(${1 / Math.sqrt(Math.cos(pitch * Math.PI / 180))}) rotateX(${pitch}deg) rotate(${bearing}deg)` : `rotate(${bearing}deg)`;
2550
+ if (this.options.closeOnZeroBearing) {
2551
+ const hidden = !bearing && !(this.options.visualizePitch && pitch) && this._pointerId == null;
2552
+ this._container.style.display = hidden ? "none" : "block";
1490
2553
  }
1491
2554
  }
1492
2555
  });
@@ -1494,6 +2557,14 @@ var leafletRotate = (() => {
1494
2557
  L.control.rotate = (options) => new L.Control.Rotate(options);
1495
2558
  }
1496
2559
  L.Map.mergeOptions({
2560
+ /**
2561
+ * Add the rotation control automatically. Set to `false` to disable,
2562
+ * or pass an options object to configure the control.
2563
+ *
2564
+ * @type {boolean|Object}
2565
+ *
2566
+ * @since leaflet-rotate (v0.1.0)
2567
+ */
1497
2568
  rotateControl: true
1498
2569
  });
1499
2570
  L.Map.addInitHook(function() {