leaflet-rotate 0.5.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.
@@ -537,10 +537,30 @@ var leafletRotate = (() => {
537
537
  if (!this._onRotate) {
538
538
  this._onRotate = L.Util.throttle(this._onMoveEnd, this.options.updateInterval, this);
539
539
  }
540
- events.rotate = this._onRotate;
540
+ events.rotate = this._onRotateEvent;
541
541
  }
542
542
  return events;
543
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
+ },
544
564
  /**
545
565
  * Return tile pixel bounds that cover the rotated viewport.
546
566
  *
@@ -578,7 +598,10 @@ var leafletRotate = (() => {
578
598
  return Object.assign(events, { rotate: this._onRotate });
579
599
  },
580
600
  /**
581
- * Reproject vector paths and refresh renderer bounds after bearing changes.
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.
582
605
  *
583
606
  * @since leaflet-rotate (v0.1.0)
584
607
  */
@@ -586,7 +609,32 @@ var leafletRotate = (() => {
586
609
  if (!this._map._rotate) {
587
610
  return;
588
611
  }
589
- this._onZoomEnd();
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();
590
638
  if (L.version.startsWith("2.")) {
591
639
  this._onMoveEnd();
592
640
  } else {
@@ -610,46 +658,25 @@ var leafletRotate = (() => {
610
658
  },
611
659
  /**
612
660
  * Update the renderer transform during zoom animation, accounting for rotated coordinates.
613
- *
614
- * @FIXME layer drifts on `map.setZoom()` (eg. zoom during animation)
615
- *
616
- * the main cause seems to be related to `this._updateTransform(path._center, path._zoom))`
617
- * and `this._topLeft = this._map.layerPointToLatLng(this._bounds.min);`
618
- *
619
- * @example
620
- * map.setZoom(2);
621
- * path._renderer._update();
622
- * path._renderer._updateTransform(path._renderer._center, path._renderer._zoom);
623
661
  *
624
662
  * @see https://github.com/Leaflet/Leaflet/pull/8794
625
663
  * @see https://github.com/Leaflet/Leaflet/pull/8103
626
664
  * @see https://github.com/Leaflet/Leaflet/issues/7466
627
665
  *
628
- * @TODO recheck these changes against leaflet@v1.9.3
629
- *
630
- * @see https://github.com/Leaflet/Leaflet/compare/v1.7.0...v1.9.3
631
- *
632
666
  * @param {L.LatLng} center Target map center.
633
667
  * @param {number} zoom Target zoom level.
634
668
  *
635
669
  * @since leaflet-rotate (v0.1.0)
636
670
  */
637
671
  _updateTransform(center, zoom) {
638
- if (L.version.startsWith("2.")) {
639
- if (this._map._rotate) {
640
- const scale2 = this._map.getZoomScale(zoom, this._zoom), offset2 = this._topLeftPoint.multiplyBy(scale2).subtract(this._map._getNewPixelOrigin(center, zoom));
641
- L.DomUtil.setTransform(this._container, offset2, scale2);
642
- return;
643
- }
644
- return rendererBase._updateTransform.apply(this, arguments);
645
- }
672
+ const v2 = L.version.startsWith("2.");
646
673
  if (!this._map._rotate) {
647
- return rendererProto._updateTransform.apply(this, arguments);
674
+ return (v2 ? rendererBase : rendererProto)._updateTransform.apply(this, arguments);
648
675
  }
649
- if (zoom === this._zoom) {
676
+ if (!v2 && zoom === this._zoom) {
650
677
  return;
651
678
  }
652
- 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));
653
680
  L.DomUtil.setTransform(this._container, offset, scale);
654
681
  },
655
682
  /**
@@ -734,6 +761,7 @@ var leafletRotate = (() => {
734
761
  this._topLeft = this._map.layerPointToLatLng(this._bounds.min);
735
762
  this._center = this._map.getCenter();
736
763
  this._zoom = this._map.getZoom();
764
+ this._topLeftPoint = this._map._getNewPixelOrigin(this._center, this._zoom).add(this._bounds.min);
737
765
  }
738
766
  });
739
767
 
@@ -757,7 +785,15 @@ var leafletRotate = (() => {
757
785
  *
758
786
  * @since leaflet-rotate (0.5.0)
759
787
  */
760
- pitch: 0
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
761
797
  });
762
798
  L.Map.include({
763
799
  /**
@@ -795,6 +831,7 @@ var leafletRotate = (() => {
795
831
  mapProto.initialize.apply(this, arguments);
796
832
  if (this.options.rotate) {
797
833
  this.on("resize", this._updatePitchTransform, this);
834
+ this.on("move", this._updatePitchSky, this);
798
835
  this.setBearing(this.options.bearing);
799
836
  this.setPitch(this.options.pitch);
800
837
  }
@@ -924,18 +961,60 @@ var leafletRotate = (() => {
924
961
  if (!this._rotate) {
925
962
  return mapProto.getBounds.apply(this, arguments);
926
963
  }
927
- const size = this.getSize();
964
+ const [min, max] = this._getPitchedContainerBounds(0);
928
965
  return new L.LatLngBounds([
929
- this.containerPointToLatLng([0, 0]),
966
+ this.containerPointToLatLng([min.x, min.y]),
930
967
  // topleft
931
- this.containerPointToLatLng([size.x, 0]),
968
+ this.containerPointToLatLng([max.x, min.y]),
932
969
  // topright
933
- this.containerPointToLatLng([size.x, size.y]),
970
+ this.containerPointToLatLng([max.x, max.y]),
934
971
  // bottomright
935
- this.containerPointToLatLng([0, size.y])
972
+ this.containerPointToLatLng([min.x, max.y])
936
973
  // bottomleft
937
974
  ]);
938
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
+ },
939
1018
  /**
940
1019
  * Returns the bounds of the current map view in projected pixel
941
1020
  * coordinates (sometimes useful in layer and overlay implementations).
@@ -972,37 +1051,92 @@ var leafletRotate = (() => {
972
1051
  return;
973
1052
  }
974
1053
  if (options && options.animate) {
975
- this._bearingAnimationId = (this._bearingAnimationId || 0) + 1;
976
- const animationId = this._bearingAnimationId;
977
- if (this._bearingAnimationFrame) {
978
- cancelAnimationFrame(this._bearingAnimationFrame);
979
- }
980
- 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();
981
- const animate = function() {
982
- if (!this._mapPane) {
983
- delete this._bearingAnimationFrame;
984
- return;
985
- }
986
- const progress = duration ? Math.min((performance.now() - startTime) / duration, 1) : 1, eased = 1 - Math.pow(1 - progress, 1 / easeLinearity);
987
- this._setBearing(progress === 1 ? endBearing : startBearing + delta * eased);
988
- if (this._bearingAnimationId !== animationId) {
989
- return;
990
- }
991
- if (progress < 1) {
992
- this._bearingAnimationFrame = requestAnimationFrame(animate.bind(this));
993
- } else {
994
- delete this._bearingAnimationFrame;
995
- }
996
- };
997
- this._bearingAnimationFrame = requestAnimationFrame(animate.bind(this));
1054
+ this._animatePitchBearing(this.getPitch(), theta, options);
998
1055
  return;
999
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() {
1000
1098
  this._bearingAnimationId = (this._bearingAnimationId || 0) + 1;
1001
1099
  if (this._bearingAnimationFrame) {
1002
1100
  cancelAnimationFrame(this._bearingAnimationFrame);
1003
1101
  delete this._bearingAnimationFrame;
1004
1102
  }
1005
- this._setBearing(theta);
1103
+ },
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;
1006
1140
  },
1007
1141
  /**
1008
1142
  * Apply a bearing immediately and update the rotated pane and map coordinates.
@@ -1069,7 +1203,7 @@ var leafletRotate = (() => {
1069
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)`;
1070
1204
  this._rotatePane.style.transformOrigin = "0 0";
1071
1205
  this._rotatePane.style.transform = transform;
1072
- const matrix = this._pitchTransform = new DOMMatrix(getComputedStyle(this._rotatePane).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);
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);
1073
1207
  this._pitchInverseTransform = {
1074
1208
  a: (e * i - f * h) / determinant,
1075
1209
  b: (c * h - b * i) / determinant,
@@ -1081,11 +1215,42 @@ var leafletRotate = (() => {
1081
1215
  h: (b * g - a * h) / determinant,
1082
1216
  i: (a * e - b * d) / determinant
1083
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`;
1084
1248
  },
1085
1249
  /**
1086
1250
  * Set the map pitch in degrees. Values are clamped to the range 0–85.
1087
1251
  *
1088
1252
  * @param {number} pitch Pitch angle in degrees; clamped to [0, 85].
1253
+ * @param {Object} [options] Animation options (`animate`, `duration`, `easeLinearity`).
1089
1254
  *
1090
1255
  * @returns {L.Map} This map instance.
1091
1256
  *
@@ -1093,7 +1258,12 @@ var leafletRotate = (() => {
1093
1258
  *
1094
1259
  * @since leaflet-rotate (0.5.0)
1095
1260
  */
1096
- setPitch(pitch) {
1261
+ setPitch(pitch, options) {
1262
+ if (options && options.animate) {
1263
+ this._animatePitchBearing(pitch, this.getBearing(), options);
1264
+ return this;
1265
+ }
1266
+ this._stopPitchBearingAnimation();
1097
1267
  this._setPitchAndBearing(pitch, this.getBearing());
1098
1268
  return this;
1099
1269
  },
@@ -1124,6 +1294,9 @@ var leafletRotate = (() => {
1124
1294
  }
1125
1295
  this._setBearing(bearing, mapCenter);
1126
1296
  if (pitchChanged) {
1297
+ if (!this._pitch) {
1298
+ this._updatePitchSky();
1299
+ }
1127
1300
  this.fire("pitch");
1128
1301
  }
1129
1302
  },
@@ -1137,6 +1310,31 @@ var leafletRotate = (() => {
1137
1310
  getPitch() {
1138
1311
  return (this._pitch || 0) * (180 / Math.PI);
1139
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
+ },
1140
1338
  /**
1141
1339
  * Get the current map bearing. Enable the `rotate` map option before calling.
1142
1340
  *
@@ -1371,19 +1569,15 @@ var leafletRotate = (() => {
1371
1569
  *
1372
1570
  * @param {L.LatLng} latlng Geographic point.
1373
1571
  *
1374
- * @returns {L.Point} Offset in layer pixels, adjusted for bearing and pitch.
1572
+ * @returns {L.Point} Offset in container pixels (the map pane is panned in screen space), adjusted for bearing and pitch.
1375
1573
  *
1376
1574
  * @since leaflet-rotate (v0.1.0)
1377
1575
  */
1378
1576
  _getCenterOffset(latlng) {
1379
- let centerOffset = mapProto._getCenterOffset.apply(this, arguments);
1380
- if (this._pitch) {
1381
- const center = this.getSize()._divideBy(2);
1382
- centerOffset = this.containerPointToLayerPoint(center.add(centerOffset)).subtract(this.containerPointToLayerPoint(center));
1383
- } else if (this._rotate) {
1384
- centerOffset = centerOffset.rotate(this._bearing);
1577
+ if (!this._rotate) {
1578
+ return mapProto._getCenterOffset.apply(this, arguments);
1385
1579
  }
1386
- return centerOffset;
1580
+ return this.latLngToContainerPoint(latlng)._subtract(this.getSize()._divideBy(2));
1387
1581
  },
1388
1582
  /**
1389
1583
  * Return the current position of the rotated pane.
@@ -1410,11 +1604,10 @@ var leafletRotate = (() => {
1410
1604
  * @since leaflet-rotate (v0.1.0)
1411
1605
  */
1412
1606
  _getNewPixelOrigin(center, zoom) {
1413
- if (!this._rotate || L.version.startsWith("2.")) {
1607
+ if (!this._rotate) {
1414
1608
  return mapProto._getNewPixelOrigin.apply(this, arguments);
1415
1609
  }
1416
- const viewHalf = this.getSize()._divideBy(2);
1417
- 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();
1418
1611
  },
1419
1612
  /**
1420
1613
  * Calculate the pixel bounds needed to cover the transformed viewport.
@@ -1434,12 +1627,12 @@ var leafletRotate = (() => {
1434
1627
  if (!this._rotate && mapProto._getNewPixelBounds) {
1435
1628
  return mapProto._getNewPixelBounds.apply(this, arguments);
1436
1629
  }
1437
- 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([
1438
- this.containerPointToLayerPoint([0, 0]).floor(),
1439
- this.containerPointToLayerPoint([size.x, 0]).floor(),
1440
- this.containerPointToLayerPoint([0, size.y]).floor(),
1441
- this.containerPointToLayerPoint([size.x, size.y]).floor()
1442
- ]).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);
1443
1636
  return new L.Bounds(pixelCenter.subtract(halfSize), pixelCenter.add(halfSize));
1444
1637
  },
1445
1638
  /**
@@ -1466,7 +1659,7 @@ var leafletRotate = (() => {
1466
1659
  if (!this._rotate && mapProto._getPaddedPixelBounds) {
1467
1660
  return mapProto._getPaddedPixelBounds.apply(this, arguments);
1468
1661
  }
1469
- const p = padding, size = this.getSize(), padMin = size.multiplyBy(-p), padMax = size.multiplyBy(1 + p);
1662
+ const [padMin, padMax] = this._getPitchedContainerBounds(padding);
1470
1663
  return new L.Bounds([
1471
1664
  this.containerPointToLayerPoint([padMin.x, padMin.y]).floor(),
1472
1665
  this.containerPointToLayerPoint([padMin.x, padMax.y]).floor(),
@@ -1541,12 +1734,18 @@ var leafletRotate = (() => {
1541
1734
  },
1542
1735
  /**
1543
1736
  * Subscribe to the supported device-orientation event, if available.
1737
+ * On iOS, permission is requested first (call `enable()` from a user gesture).
1544
1738
  *
1545
1739
  * @since leaflet-rotate (v0.1.0)
1546
1740
  */
1547
1741
  addHooks() {
1548
1742
  if (this._map._rotate && this.__deviceOrientationEvent) {
1549
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
+ }
1550
1749
  } else {
1551
1750
  this.disable();
1552
1751
  }
@@ -1677,6 +1876,7 @@ var leafletRotate = (() => {
1677
1876
  this._map = map;
1678
1877
  this.rotate = !!this._map.options.touchRotate;
1679
1878
  this.zoom = !!this._map.options.touchZoom;
1879
+ this.pitch = !!this._map.options.touchPitch;
1680
1880
  },
1681
1881
  /**
1682
1882
  * Listen for the start of two-finger gestures on the map container.
@@ -1703,10 +1903,18 @@ var leafletRotate = (() => {
1703
1903
  */
1704
1904
  _onTouchStart(e) {
1705
1905
  const map = this._map;
1706
- 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) {
1707
1907
  return;
1708
1908
  }
1709
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();
1710
1918
  this._centerPoint = map.getSize()._divideBy(2);
1711
1919
  this._startLatLng = map.containerPointToLatLng(this._centerPoint);
1712
1920
  if (this.zoom) {
@@ -1742,11 +1950,36 @@ var leafletRotate = (() => {
1742
1950
  * @since leaflet-rotate (v0.1.0)
1743
1951
  */
1744
1952
  _onTouchMove(e) {
1745
- if (!e.touches || e.touches.length !== 2 || !(this._zooming || this._rotating)) {
1953
+ if (!e.touches || e.touches.length !== 2 || !this._gesture) {
1746
1954
  return;
1747
1955
  }
1748
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;
1749
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
+ }
1750
1983
  if (this._rotating) {
1751
1984
  const theta = Math.atan(vector.x / vector.y);
1752
1985
  let bearingDelta = (theta - this._startTheta) * (180 / Math.PI);
@@ -1797,8 +2030,12 @@ var leafletRotate = (() => {
1797
2030
  * @since leaflet-rotate (v0.1.0)
1798
2031
  */
1799
2032
  _onTouchEnd() {
1800
- 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)) {
1801
2036
  this._zooming = false;
2037
+ this._rotating = false;
2038
+ L.DomEvent.off(document, "touchmove", this._onTouchMove, this).off(document, "touchend touchcancel", this._onTouchEnd, this);
1802
2039
  return;
1803
2040
  }
1804
2041
  this._zooming = false;
@@ -1847,6 +2084,38 @@ var leafletRotate = (() => {
1847
2084
  });
1848
2085
  L.Map.addInitHook("addHandler", "touchRotate", L.Map.TouchRotate);
1849
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
+
1850
2119
  // src/map/handler/ShiftKeyRotate.js
1851
2120
  L.Map.mergeOptions({
1852
2121
  /**
@@ -1858,7 +2127,7 @@ var leafletRotate = (() => {
1858
2127
  */
1859
2128
  shiftKeyRotate: true,
1860
2129
  /**
1861
- * Whether Ctrl+drag changes bearing and pitch.
2130
+ * Whether Ctrl+drag (or right-button drag) changes bearing and pitch.
1862
2131
  *
1863
2132
  * @type {Boolean}
1864
2133
  *
@@ -1877,6 +2146,8 @@ var leafletRotate = (() => {
1877
2146
  this._onMouseDownBound = this._onMouseDown.bind(this);
1878
2147
  this._onMouseMoveBound = this._onMouseMove.bind(this);
1879
2148
  this._onMouseUpBound = this._onMouseUp.bind(this);
2149
+ this._onContextMenuBound = this._onContextMenu.bind(this);
2150
+ this._applyBound = this._apply.bind(this);
1880
2151
  },
1881
2152
  /**
1882
2153
  * Attach enabled wheel and pointer/mouse gesture listeners.
@@ -1911,6 +2182,7 @@ var leafletRotate = (() => {
1911
2182
  }
1912
2183
  this._pitchEvents = L.version.startsWith("2.") ? ["pointerdown", "pointermove", "pointerup pointercancel"] : ["mousedown", "mousemove", "mouseup"];
1913
2184
  this._map._container.addEventListener(this._pitchEvents[0], this._onMouseDownBound, true);
2185
+ this._map._container.addEventListener("contextmenu", this._onContextMenuBound, true);
1914
2186
  this._pitchHooksAdded = true;
1915
2187
  },
1916
2188
  /**
@@ -1923,6 +2195,7 @@ var leafletRotate = (() => {
1923
2195
  return;
1924
2196
  }
1925
2197
  this._map._container.removeEventListener(this._pitchEvents[0], this._onMouseDownBound, true);
2198
+ this._map._container.removeEventListener("contextmenu", this._onContextMenuBound, true);
1926
2199
  this._onMouseUp();
1927
2200
  delete this._pitchEvents;
1928
2201
  this._pitchHooksAdded = false;
@@ -1944,14 +2217,18 @@ var leafletRotate = (() => {
1944
2217
  }
1945
2218
  },
1946
2219
  /**
1947
- * Start Ctrl+drag and capture subsequent movement outside the map.
2220
+ * Start Ctrl+drag (or right-button drag) and capture subsequent movement outside the map.
1948
2221
  *
1949
2222
  * @param {MouseEvent|PointerEvent} e Gesture start event.
1950
2223
  *
1951
2224
  * @since leaflet-rotate (0.5.0)
1952
2225
  */
1953
2226
  _onMouseDown(e) {
1954
- if (!this._map._rotate || !this._map.options.pitchDrag || !e.ctrlKey || e.button !== 0) {
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)) {
1955
2232
  return;
1956
2233
  }
1957
2234
  const scrollWheelZoom = this._map.scrollWheelZoom;
@@ -1964,42 +2241,82 @@ var leafletRotate = (() => {
1964
2241
  e.preventDefault();
1965
2242
  e.stopPropagation();
1966
2243
  e.stopImmediatePropagation();
2244
+ this._map._stopPitchBearingAnimation();
2245
+ this._dragging = true;
1967
2246
  this._lastX = e.clientX;
1968
2247
  this._lastY = e.clientY;
2248
+ this._pitchDelta = this._bearingDelta = 0;
1969
2249
  document.addEventListener(this._pitchEvents[1], this._onMouseMoveBound);
1970
2250
  for (const event of this._pitchEvents[2].split(" ")) {
1971
2251
  document.addEventListener(event, this._onMouseUpBound);
1972
2252
  }
1973
2253
  },
1974
2254
  /**
1975
- * Apply pointer movement to pitch (vertical) and bearing (horizontal).
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.
1976
2258
  *
1977
2259
  * @param {MouseEvent|PointerEvent} e Gesture movement event.
1978
2260
  *
1979
2261
  * @since leaflet-rotate (0.5.0)
1980
2262
  */
1981
2263
  _onMouseMove(e) {
1982
- this._map._setPitchAndBearing(
1983
- this._map.getPitch() + (this._lastY - e.clientY) * 0.5,
1984
- this._map.getBearing() + (e.clientX - this._lastX) * 0.8
1985
- );
1986
- this._lastX = e.clientX;
1987
- this._lastY = e.clientY;
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
+ }
1988
2273
  e.preventDefault();
1989
2274
  },
1990
2275
  /**
1991
- * End Ctrl+drag and clear document-level listeners.
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.
1992
2303
  *
1993
2304
  * @since leaflet-rotate (0.5.0)
1994
2305
  */
1995
2306
  _onMouseUp() {
1996
- if (!this._pitchEvents) {
2307
+ if (!this._pitchEvents || !this._dragging) {
1997
2308
  return;
1998
2309
  }
1999
2310
  document.removeEventListener(this._pitchEvents[1], this._onMouseMoveBound);
2000
2311
  for (const event of this._pitchEvents[2].split(" ")) {
2001
2312
  document.removeEventListener(event, this._onMouseUpBound);
2002
2313
  }
2314
+ cancelAnimationFrame(this._frame);
2315
+ if (this._map._mapPane) {
2316
+ this._apply();
2317
+ }
2318
+ this._frame = null;
2319
+ this._dragging = false;
2003
2320
  delete this._lastX;
2004
2321
  delete this._lastY;
2005
2322
  }
@@ -2073,10 +2390,11 @@ var leafletRotate = (() => {
2073
2390
  L.Control.Rotate = L.Control.extend({
2074
2391
  options: {
2075
2392
  position: "topleft",
2076
- closeOnZeroBearing: true
2393
+ closeOnZeroBearing: true,
2394
+ visualizePitch: true
2077
2395
  },
2078
2396
  /**
2079
- * Create the control element and subscribe to map rotation.
2397
+ * Create the control element and subscribe to map rotation and pitch.
2080
2398
  *
2081
2399
  * @param {L.Map} map
2082
2400
  *
@@ -2088,24 +2406,27 @@ var leafletRotate = (() => {
2088
2406
  const container = this._container = L.DomUtil.create("div", "leaflet-control-rotate leaflet-bar");
2089
2407
  const arrow = this._arrow = L.DomUtil.create("span", "leaflet-control-rotate-arrow");
2090
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")`;
2091
- arrow.style.cursor = "grab";
2092
2409
  arrow.style.display = "block";
2093
2410
  arrow.style.width = "100%";
2094
2411
  arrow.style.height = "100%";
2095
2412
  arrow.style.backgroundRepeat = "no-repeat";
2096
2413
  arrow.style.backgroundPosition = "50%";
2414
+ arrow.style.pointerEvents = "none";
2097
2415
  const link = this._link = L.DomUtil.create("a", "leaflet-control-rotate-toggle", container);
2098
2416
  link.appendChild(arrow);
2099
2417
  link.href = "#";
2100
- link.title = "Rotate map";
2101
- 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;
2102
2429
  this._restyle();
2103
- map.on("rotate", this._restyle, this);
2104
- this._follow = false;
2105
- this._canFollow = false;
2106
- if (this.options.closeOnZeroBearing && map.getBearing() === 0) {
2107
- container.style.display = "none";
2108
- }
2109
2430
  return container;
2110
2431
  },
2111
2432
  /**
@@ -2116,102 +2437,119 @@ var leafletRotate = (() => {
2116
2437
  * @since leaflet-rotate (v0.1.0)
2117
2438
  */
2118
2439
  onRemove(map) {
2119
- map.off("rotate", this._restyle, this);
2440
+ this._onPointerUp();
2441
+ map.off("rotate pitch", this._restyle, this);
2120
2442
  },
2121
2443
  /**
2122
- * Begin dragging the control's bearing indicator.
2123
- *
2124
- * @param {MouseEvent} e Mouse-down event.
2444
+ * Reset bearing (and pitch) unless the click ends a drag.
2125
2445
  *
2126
- * @since leaflet-rotate (v0.1.0)
2446
+ * @since leaflet-rotate (0.5.1)
2127
2447
  */
2128
- _handleMouseDown(e) {
2129
- L.DomEvent.stop(e);
2130
- this.dragging = true;
2131
- this.dragstartX = e.pageX;
2132
- this.dragstartY = e.pageY;
2133
- L.DomEvent.on(document, "mousemove", this._handleMouseDrag, this).on(document, "mouseup", this._handleMouseUp, this);
2448
+ _onClick() {
2449
+ if (this._moved) {
2450
+ this._moved = false;
2451
+ return;
2452
+ }
2453
+ if (!this._map || !this._map._rotate) {
2454
+ return;
2455
+ }
2456
+ if (this.options.visualizePitch) {
2457
+ this._map.resetNorthPitch();
2458
+ } else {
2459
+ this._map.resetNorth();
2460
+ }
2134
2461
  },
2135
2462
  /**
2136
- * End bearing-indicator dragging and remove document listeners.
2463
+ * Start dragging the control (mouse, pen or touch).
2137
2464
  *
2138
- * @param {MouseEvent} e Mouse-up event.
2465
+ * @param {PointerEvent} e
2139
2466
  *
2140
2467
  * @since leaflet-rotate (v0.1.0)
2141
2468
  */
2142
- _handleMouseUp(e) {
2143
- L.DomEvent.stop(e);
2144
- this.dragging = false;
2145
- L.DomEvent.off(document, "mousemove", this._handleMouseDrag, this).off(document, "mouseup", this._handleMouseUp, this);
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);
2146
2483
  },
2147
2484
  /**
2148
- * Set bearing from the horizontal drag distance.
2485
+ * Horizontal movement changes the bearing, vertical movement the pitch.
2149
2486
  *
2150
- * @param {MouseEvent} e Mouse-move event.
2487
+ * @param {PointerEvent} e
2151
2488
  *
2152
2489
  * @since leaflet-rotate (v0.1.0)
2153
2490
  */
2154
- _handleMouseDrag(e) {
2155
- if (!this.dragging) {
2491
+ _onPointerMove(e) {
2492
+ if (e.pointerId !== this._pointerId) {
2156
2493
  return;
2157
2494
  }
2158
- const deltaX = e.clientX - this.dragstartX;
2159
- this._map.setBearing(deltaX);
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;
2160
2507
  },
2161
2508
  /**
2162
- * Cycle through touch, compass, and locked rotation modes.
2163
- * Compass mode may require permission from the browser.
2509
+ * Stop dragging and remove document listeners.
2164
2510
  *
2165
- * @param {Event} ev Control click event.
2511
+ * @param {PointerEvent} [e]
2166
2512
  *
2167
2513
  * @since leaflet-rotate (v0.1.0)
2168
2514
  */
2169
- _cycleState(ev) {
2170
- if (!this._map) {
2515
+ _onPointerUp(e) {
2516
+ if (e && e.pointerId !== this._pointerId) {
2171
2517
  return;
2172
2518
  }
2173
- const map = this._map;
2174
- if (!map.touchRotate.enabled() && !map.compassBearing.enabled()) {
2175
- map.touchRotate.enable();
2176
- } else if (!map.compassBearing.enabled()) {
2177
- map.touchRotate.disable();
2178
- (DeviceOrientationEvent && DeviceOrientationEvent.requestPermission ? DeviceOrientationEvent.requestPermission() : Promise.resolve("granted")).then((state) => "granted" === state && map.compassBearing.enable());
2179
- } else {
2180
- map.compassBearing.disable();
2181
- map.setBearing(0);
2182
- if (this.options.closeOnZeroBearing) {
2183
- map.touchRotate.enable();
2184
- }
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();
2185
2529
  }
2186
- this._restyle();
2187
2530
  },
2188
2531
  /**
2189
- * Update the control icon and visibility to match the current mode.
2532
+ * Update the icon (bearing and pitch) and the control visibility.
2190
2533
  *
2191
2534
  * @listens L.Map#rotate
2535
+ * @listens L.Map#pitch
2192
2536
  *
2193
2537
  * @since leaflet-rotate (v0.1.0)
2194
2538
  */
2195
2539
  _restyle() {
2196
- if (!this._map.options.rotate) {
2540
+ const map = this._map;
2541
+ if (!map) {
2542
+ return;
2543
+ }
2544
+ if (!map.options.rotate) {
2197
2545
  this._link.classList.add("leaflet-disabled");
2198
- } else {
2199
- const map = this._map;
2200
- const bearing = map.getBearing();
2201
- this._arrow.style.transform = "rotate(" + bearing + "deg)";
2202
- if (bearing && this.options.closeOnZeroBearing) {
2203
- this._container.style.display = "block";
2204
- }
2205
- if (map.compassBearing.enabled()) {
2206
- this._link.style.backgroundColor = "orange";
2207
- } else if (map.touchRotate.enabled()) {
2208
- this._link.style.backgroundColor = null;
2209
- } else {
2210
- this._link.style.backgroundColor = "grey";
2211
- if (0 === bearing && this.options.closeOnZeroBearing) {
2212
- this._container.style.display = "none";
2213
- }
2214
- }
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";
2215
2553
  }
2216
2554
  }
2217
2555
  });