leaflet-rotate 0.5.0 → 0.5.2

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,10 @@ 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("movestart dragstart", this._startPitchPan, this);
835
+ this.on("move", this._updatePitchPan, this);
836
+ this.on("moveend", this._endPitchPan, this);
837
+ this.on("move", this._updatePitchSky, this);
798
838
  this.setBearing(this.options.bearing);
799
839
  this.setPitch(this.options.pitch);
800
840
  }
@@ -914,7 +954,8 @@ var leafletRotate = (() => {
914
954
  /**
915
955
  * Return the geographic bounds of the current viewport, accounting for rotation and pitch.
916
956
  *
917
- * The result is the smallest geographic bounds containing all four viewport corners.
957
+ * The result encloses the four corners of the visible ground rectangle. With pitch,
958
+ * the rectangle stops at the configured far plane, excluding the sky.
918
959
  *
919
960
  * @returns {L.LatLngBounds} Geographic bounds visible in the map viewport.
920
961
  *
@@ -924,18 +965,60 @@ var leafletRotate = (() => {
924
965
  if (!this._rotate) {
925
966
  return mapProto.getBounds.apply(this, arguments);
926
967
  }
927
- const size = this.getSize();
968
+ const [min, max] = this._getPitchedContainerBounds(0);
928
969
  return new L.LatLngBounds([
929
- this.containerPointToLatLng([0, 0]),
970
+ this.containerPointToLatLng([min.x, min.y]),
930
971
  // topleft
931
- this.containerPointToLatLng([size.x, 0]),
972
+ this.containerPointToLatLng([max.x, min.y]),
932
973
  // topright
933
- this.containerPointToLatLng([size.x, size.y]),
974
+ this.containerPointToLatLng([max.x, max.y]),
934
975
  // bottomright
935
- this.containerPointToLatLng([0, size.y])
976
+ this.containerPointToLatLng([min.x, max.y])
936
977
  // bottomleft
937
978
  ]);
938
979
  },
980
+ /**
981
+ * Return the container rectangle covering the visible map plane, optionally padded by a
982
+ * fraction of the map size. When pitched, its top is cut at the far plane (see `pitchMaxDistance`)
983
+ * so that it never reaches the horizon, where the plane would extend to infinity.
984
+ *
985
+ * @param {number} padding Padding as a fraction of the map size.
986
+ *
987
+ * @returns {L.Point[]} Top-left and bottom-right container points.
988
+ *
989
+ * @since leaflet-rotate (0.5.1)
990
+ */
991
+ _getPitchedContainerBounds(padding) {
992
+ const size = this.getSize(), min = size.multiplyBy(-padding), max = size.multiplyBy(1 + padding);
993
+ if (this._pitch) {
994
+ min.y = Math.min(Math.max(min.y, this._getPitchFarY()), max.y);
995
+ }
996
+ return [min, max];
997
+ },
998
+ /**
999
+ * Return the container y coordinate of the far plane: points above it are farther than
1000
+ * `pitchMaxDistance` from the map center, or beyond the horizon.
1001
+ *
1002
+ * @returns {number} Container y coordinate (`-Infinity` when the whole map is close enough).
1003
+ *
1004
+ * @since leaflet-rotate (0.5.1)
1005
+ */
1006
+ _getPitchFarY() {
1007
+ 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;
1008
+ let far = -size.y - panePos.y, near = centerY;
1009
+ if (isNear(far)) {
1010
+ return -Infinity;
1011
+ }
1012
+ for (let i = 0; i < 20; i++) {
1013
+ const mid = (far + near) / 2;
1014
+ if (isNear(mid)) {
1015
+ near = mid;
1016
+ } else {
1017
+ far = mid;
1018
+ }
1019
+ }
1020
+ return near + panePos.y;
1021
+ },
939
1022
  /**
940
1023
  * Returns the bounds of the current map view in projected pixel
941
1024
  * coordinates (sometimes useful in layer and overlay implementations).
@@ -972,37 +1055,92 @@ var leafletRotate = (() => {
972
1055
  return;
973
1056
  }
974
1057
  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));
1058
+ this._animatePitchBearing(this.getPitch(), theta, options);
998
1059
  return;
999
1060
  }
1061
+ this._stopPitchBearingAnimation();
1062
+ this._setBearing(theta);
1063
+ },
1064
+ /**
1065
+ * Animate pitch and bearing together (bearing follows the shortest path).
1066
+ *
1067
+ * @param {number} pitch Target pitch in degrees.
1068
+ * @param {number} bearing Target bearing in degrees.
1069
+ * @param {Object} [options] Animation options.
1070
+ * @param {number} [options.duration=0.25] Animation duration in seconds.
1071
+ * @param {number} [options.easeLinearity=0.5] Easing curve linearity.
1072
+ *
1073
+ * @since leaflet-rotate (0.5.1)
1074
+ */
1075
+ _animatePitchBearing(pitch, bearing, options) {
1076
+ this._stopPitchBearingAnimation();
1077
+ const animationId = this._bearingAnimationId;
1078
+ 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();
1079
+ const animate = function() {
1080
+ if (!this._mapPane || this._bearingAnimationId !== animationId) {
1081
+ return;
1082
+ }
1083
+ const progress = duration ? Math.min((performance.now() - startTime) / duration, 1) : 1, eased = 1 - Math.pow(1 - progress, 1 / easeLinearity);
1084
+ if (progress < 1) {
1085
+ this._bearingAnimationFrame = requestAnimationFrame(animate.bind(this));
1086
+ } else {
1087
+ delete this._bearingAnimationFrame;
1088
+ }
1089
+ this._setPitchAndBearing(
1090
+ progress === 1 ? endPitch : startPitch + (endPitch - startPitch) * eased,
1091
+ progress === 1 ? endBearing : startBearing + delta * eased
1092
+ );
1093
+ };
1094
+ this._bearingAnimationFrame = requestAnimationFrame(animate.bind(this));
1095
+ },
1096
+ /**
1097
+ * Cancel a running pitch/bearing animation.
1098
+ *
1099
+ * @since leaflet-rotate (0.5.1)
1100
+ */
1101
+ _stopPitchBearingAnimation() {
1000
1102
  this._bearingAnimationId = (this._bearingAnimationId || 0) + 1;
1001
1103
  if (this._bearingAnimationFrame) {
1002
1104
  cancelAnimationFrame(this._bearingAnimationFrame);
1003
1105
  delete this._bearingAnimationFrame;
1004
1106
  }
1005
- this._setBearing(theta);
1107
+ },
1108
+ /**
1109
+ * Rotate the map back to north (bearing 0), animated by default.
1110
+ *
1111
+ * @param {Object} [options] Animation options (`animate`, `duration`, `easeLinearity`).
1112
+ *
1113
+ * @returns {L.Map} This map instance.
1114
+ *
1115
+ * @since leaflet-rotate (0.5.1)
1116
+ */
1117
+ resetNorth(options) {
1118
+ options = Object.assign({ animate: true, duration: 0.5 }, options);
1119
+ if (options.animate) {
1120
+ this._animatePitchBearing(this.getPitch(), 0, options);
1121
+ } else {
1122
+ this.setBearing(0);
1123
+ }
1124
+ return this;
1125
+ },
1126
+ /**
1127
+ * Rotate the map back to north and remove the pitch, animated by default.
1128
+ *
1129
+ * @param {Object} [options] Animation options (`animate`, `duration`, `easeLinearity`).
1130
+ *
1131
+ * @returns {L.Map} This map instance.
1132
+ *
1133
+ * @since leaflet-rotate (0.5.1)
1134
+ */
1135
+ resetNorthPitch(options) {
1136
+ options = Object.assign({ animate: true, duration: 0.5 }, options);
1137
+ if (options.animate) {
1138
+ this._animatePitchBearing(0, 0, options);
1139
+ } else {
1140
+ this._stopPitchBearingAnimation();
1141
+ this._setPitchAndBearing(0, 0);
1142
+ }
1143
+ return this;
1006
1144
  },
1007
1145
  /**
1008
1146
  * Apply a bearing immediately and update the rotated pane and map coordinates.
@@ -1036,10 +1174,12 @@ var leafletRotate = (() => {
1036
1174
  this.fire("rotate");
1037
1175
  },
1038
1176
  /**
1039
- * Transform a point with a projective matrix, accepting DOMMatrix or its 2D inverse.
1177
+ * Transform a ground-plane point with a DOMMatrix, or reverse its screen projection.
1178
+ * Both branches divide by the homogeneous coordinate after applying the matrix.
1040
1179
  *
1041
1180
  * @param {L.Point|number[]} point Input pixel coordinate.
1042
- * @param {DOMMatrix|Object} matrix Forward or inverse transformation matrix.
1181
+ * @param {DOMMatrix|Object} matrix Forward CSS matrix or inverse homography with
1182
+ * row-major coefficients `a` through `i`.
1043
1183
  *
1044
1184
  * @returns {L.Point} Transformed pixel coordinate.
1045
1185
  *
@@ -1067,9 +1207,23 @@ var leafletRotate = (() => {
1067
1207
  return;
1068
1208
  }
1069
1209
  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)`;
1210
+ this._setPitchTransform(transform);
1211
+ },
1212
+ /**
1213
+ * Apply the CSS transform and cache matching forward/inverse point conversions.
1214
+ *
1215
+ * Ground points have z = 0, so the 4x4 CSS matrix reduces to the homography
1216
+ * [a b c; d e f; g h i]. Its inverse maps screen points back onto that plane;
1217
+ * inverting the full 4x4 matrix at screen z = 0 would not undo perspective.
1218
+ *
1219
+ * @param {string} transform CSS transform list, including a serialized DOMMatrix.
1220
+ *
1221
+ * @since leaflet-rotate (0.5.2)
1222
+ */
1223
+ _setPitchTransform(transform) {
1070
1224
  this._rotatePane.style.transformOrigin = "0 0";
1071
1225
  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);
1226
+ 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
1227
  this._pitchInverseTransform = {
1074
1228
  a: (e * i - f * h) / determinant,
1075
1229
  b: (c * h - b * i) / determinant,
@@ -1081,11 +1235,109 @@ var leafletRotate = (() => {
1081
1235
  h: (b * g - a * h) / determinant,
1082
1236
  i: (a * e - b * d) / determinant
1083
1237
  };
1238
+ this._updatePitchSky();
1239
+ },
1240
+ /**
1241
+ * Capture a fixed projection baseline for a pitched drag or programmatic pan.
1242
+ *
1243
+ * `movestart` uses the viewport center; `dragstart` replaces it with the original
1244
+ * pointer position. Store both the screen anchor and its ground-plane coordinate
1245
+ * so subsequent frames can keep the grabbed geographic point under the pointer.
1246
+ * Pane positions are in screen pixels and the baseline matrices stay unchanged.
1247
+ *
1248
+ * @param {L.Event} e Leaflet `movestart` or `dragstart` event.
1249
+ *
1250
+ * @since leaflet-rotate (0.5.2)
1251
+ */
1252
+ _startPitchPan(e) {
1253
+ if (!this._pitch || !this._loaded) {
1254
+ return;
1255
+ }
1256
+ const draggable = this.dragging && this.dragging._draggable, anchor = e.type === "dragstart" && draggable ? (this.pointerEventToContainerPoint || this.mouseEventToContainerPoint).call(this, {
1257
+ clientX: draggable._startPoint.x,
1258
+ clientY: draggable._startPoint.y
1259
+ }) : this.getSize().divideBy(2), panePos = this._getMapPanePos().clone();
1260
+ this._pitchPan = {
1261
+ anchor,
1262
+ panePos,
1263
+ lastPanePos: panePos,
1264
+ matrix: this._pitchTransform,
1265
+ inverse: this._pitchInverseTransform,
1266
+ layerAnchor: this.containerPointToLayerPoint(anchor),
1267
+ zoom: this._zoom
1268
+ };
1269
+ },
1270
+ /**
1271
+ * Translate the ground plane without moving the camera's vanishing point.
1272
+ *
1273
+ * For pane displacement delta, invert the displaced anchor with the baseline
1274
+ * projection to find the required layer-space shift. Compose
1275
+ * T(-delta) * baseline * T(shift): the left translation cancels Leaflet's outer
1276
+ * pane movement, while the right translation moves geography before perspective.
1277
+ * Always derive this from the baseline, not the previous frame, to avoid drift.
1278
+ * Zoom frames are handled by Leaflet's zoom/origin updates instead.
1279
+ *
1280
+ * @since leaflet-rotate (0.5.2)
1281
+ */
1282
+ _updatePitchPan() {
1283
+ const pan = this._pitchPan;
1284
+ if (!this._pitch || !pan || this._animatingZoom || this._zoom !== pan.zoom) {
1285
+ return;
1286
+ }
1287
+ const panePos = this._getMapPanePos();
1288
+ if (panePos.equals(pan.lastPanePos)) {
1289
+ return;
1290
+ }
1291
+ const delta = panePos.subtract(pan.panePos), target = this._transformPitchPoint(pan.anchor.add(delta).subtract(pan.panePos), pan.inverse), shift = target.subtract(pan.layerAnchor);
1292
+ const matrix = new DOMMatrix().translate(-delta.x, -delta.y).multiply(pan.matrix).translate(shift.x, shift.y);
1293
+ this._setPitchTransform(matrix.toString());
1294
+ pan.lastPanePos = panePos.clone();
1295
+ delete this._lastCenter;
1296
+ },
1297
+ /**
1298
+ * Discard the baseline only at `moveend`, after any drag inertia has completed.
1299
+ * Keep the final transform and pixel origin: resetting the view here would
1300
+ * reproject layers abruptly and cause a visible jump on release.
1301
+ *
1302
+ * @since leaflet-rotate (0.5.2)
1303
+ */
1304
+ _endPitchPan() {
1305
+ delete this._pitchPan;
1306
+ },
1307
+ /**
1308
+ * Fill the area above the far plane (where no tiles are loaded) with a sky gradient.
1309
+ * Style it through the `.leaflet-pitch-sky` class.
1310
+ *
1311
+ * @since leaflet-rotate (0.5.1)
1312
+ */
1313
+ _updatePitchSky() {
1314
+ const farY = this._pitch && this._pitchInverseTransform ? this._getPitchFarY() : -Infinity;
1315
+ if (farY <= 0) {
1316
+ if (this._pitchSky) {
1317
+ this._pitchSky.style.display = "none";
1318
+ }
1319
+ return;
1320
+ }
1321
+ if (!this._pitchSky) {
1322
+ this._pitchSky = L.DomUtil.create("div", "leaflet-pitch-sky", this._container);
1323
+ Object.assign(this._pitchSky.style, {
1324
+ position: "absolute",
1325
+ left: 0,
1326
+ top: 0,
1327
+ width: "100%",
1328
+ zIndex: 0,
1329
+ pointerEvents: "none",
1330
+ background: "linear-gradient(#9ec5e8, #dfe9f0)"
1331
+ });
1332
+ }
1333
+ this._pitchSky.style.display = "";
1334
+ this._pitchSky.style.height = `${Math.ceil(farY) + 1}px`;
1084
1335
  },
1085
1336
  /**
1086
1337
  * Set the map pitch in degrees. Values are clamped to the range 0–85.
1087
1338
  *
1088
1339
  * @param {number} pitch Pitch angle in degrees; clamped to [0, 85].
1340
+ * @param {Object} [options] Animation options (`animate`, `duration`, `easeLinearity`).
1089
1341
  *
1090
1342
  * @returns {L.Map} This map instance.
1091
1343
  *
@@ -1093,7 +1345,12 @@ var leafletRotate = (() => {
1093
1345
  *
1094
1346
  * @since leaflet-rotate (0.5.0)
1095
1347
  */
1096
- setPitch(pitch) {
1348
+ setPitch(pitch, options) {
1349
+ if (options && options.animate) {
1350
+ this._animatePitchBearing(pitch, this.getBearing(), options);
1351
+ return this;
1352
+ }
1353
+ this._stopPitchBearingAnimation();
1097
1354
  this._setPitchAndBearing(pitch, this.getBearing());
1098
1355
  return this;
1099
1356
  },
@@ -1124,6 +1381,9 @@ var leafletRotate = (() => {
1124
1381
  }
1125
1382
  this._setBearing(bearing, mapCenter);
1126
1383
  if (pitchChanged) {
1384
+ if (!this._pitch) {
1385
+ this._updatePitchSky();
1386
+ }
1127
1387
  this.fire("pitch");
1128
1388
  }
1129
1389
  },
@@ -1137,6 +1397,31 @@ var leafletRotate = (() => {
1137
1397
  getPitch() {
1138
1398
  return (this._pitch || 0) * (180 / Math.PI);
1139
1399
  },
1400
+ /**
1401
+ * Zoom while keeping the given point fixed on screen, also when the map is pitched.
1402
+ *
1403
+ * Leaflet interpolates in container space, which is not linear under perspective;
1404
+ * interpolating in layer space keeps the point under the cursor.
1405
+ *
1406
+ * @param {L.LatLng|L.Point} latlng Geographic point or container point to keep fixed.
1407
+ * @param {number} zoom Target zoom level.
1408
+ * @param {Object} [options] Zoom options.
1409
+ *
1410
+ * @returns {L.Map} This map instance.
1411
+ *
1412
+ * @since leaflet-rotate (0.5.1)
1413
+ */
1414
+ setZoomAround(latlng, zoom, options) {
1415
+ if (!this._pitch) {
1416
+ return mapProto.setZoomAround.apply(this, arguments);
1417
+ }
1418
+ 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)));
1419
+ options = Object.assign({}, options);
1420
+ if (options.animate === void 0 && new L.Bounds(new L.Point(0, 0), size).contains(containerPoint)) {
1421
+ options.animate = true;
1422
+ }
1423
+ return this.setView(newCenter, zoom, { zoom: options });
1424
+ },
1140
1425
  /**
1141
1426
  * Get the current map bearing. Enable the `rotate` map option before calling.
1142
1427
  *
@@ -1367,23 +1652,26 @@ var leafletRotate = (() => {
1367
1652
  // return this.containerPointToLayerPoint(this.getSize()._divideBy(2));
1368
1653
  // },
1369
1654
  /**
1370
- * Return the pixel offset from the current center to the given geographic point.
1655
+ * Return the screen-space pan offset needed to center the given geographic point.
1656
+ * Under perspective, projecting a negated layer offset is not equivalent to
1657
+ * negating its screen projection. Reflect the target about the layer center
1658
+ * to match the opposite anchor displacement performed by Leaflet's `panBy`.
1371
1659
  *
1372
1660
  * @param {L.LatLng} latlng Geographic point.
1373
1661
  *
1374
- * @returns {L.Point} Offset in layer pixels, adjusted for bearing and pitch.
1662
+ * @returns {L.Point} Offset in container pixels (the map pane is panned in screen space), adjusted for bearing and pitch.
1375
1663
  *
1376
1664
  * @since leaflet-rotate (v0.1.0)
1377
1665
  */
1378
1666
  _getCenterOffset(latlng) {
1379
- let centerOffset = mapProto._getCenterOffset.apply(this, arguments);
1667
+ if (!this._rotate) {
1668
+ return mapProto._getCenterOffset.apply(this, arguments);
1669
+ }
1380
1670
  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);
1671
+ const center = this.getSize().divideBy(2), layerCenter = this.containerPointToLayerPoint(center), opposite = layerCenter.multiplyBy(2).subtract(this.project(latlng).subtract(this._pixelOrigin));
1672
+ return center.subtract(this.layerPointToContainerPoint(opposite));
1385
1673
  }
1386
- return centerOffset;
1674
+ return this.latLngToContainerPoint(latlng)._subtract(this.getSize()._divideBy(2));
1387
1675
  },
1388
1676
  /**
1389
1677
  * Return the current position of the rotated pane.
@@ -1410,11 +1698,10 @@ var leafletRotate = (() => {
1410
1698
  * @since leaflet-rotate (v0.1.0)
1411
1699
  */
1412
1700
  _getNewPixelOrigin(center, zoom) {
1413
- if (!this._rotate || L.version.startsWith("2.")) {
1701
+ if (!this._rotate) {
1414
1702
  return mapProto._getNewPixelOrigin.apply(this, arguments);
1415
1703
  }
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();
1704
+ return this.project(center, zoom)._subtract(this.containerPointToLayerPoint(this.getSize()._divideBy(2)))._round();
1418
1705
  },
1419
1706
  /**
1420
1707
  * Calculate the pixel bounds needed to cover the transformed viewport.
@@ -1434,18 +1721,21 @@ var leafletRotate = (() => {
1434
1721
  if (!this._rotate && mapProto._getNewPixelBounds) {
1435
1722
  return mapProto._getNewPixelBounds.apply(this, arguments);
1436
1723
  }
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);
1724
+ 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));
1725
+ if (this._pitch) {
1726
+ const viewCenter = this.containerPointToLayerPoint(this.getSize()._divideBy(2));
1727
+ return new L.Bounds(corners.map((point) => pixelCenter.add(point.subtract(viewCenter).divideBy(scale))));
1728
+ }
1729
+ const halfSize = new L.Bounds(corners.map((point) => point.floor())).getSize().divideBy(scale * 2);
1443
1730
  return new L.Bounds(pixelCenter.subtract(halfSize), pixelCenter.add(halfSize));
1444
1731
  },
1445
1732
  /**
1733
+ * Return the viewport center before the outer map-pane translation.
1734
+ * Used as the bearing/pitch pivot in map-pane coordinates, not layer coordinates.
1735
+ *
1446
1736
  * @since leaflet-rotate (v0.2.0)
1447
1737
  *
1448
- * @returns {L.Point} Map pivot point in pixel coordinates.
1738
+ * @returns {L.Point} Map pivot in map-pane pixels.
1449
1739
  */
1450
1740
  _getPixelCenter() {
1451
1741
  if (!this._rotate && mapProto._getPixelCenter) {
@@ -1454,6 +1744,9 @@ var leafletRotate = (() => {
1454
1744
  return this.getSize()._divideBy(2)._subtract(this._getMapPanePos());
1455
1745
  },
1456
1746
  /**
1747
+ * Convert the padded visible ground rectangle to layer bounds for vector renderers.
1748
+ * Clip its far edge before reversing perspective to avoid unbounded render areas.
1749
+ *
1457
1750
  * @see src\layer\vector\Renderer::_update()
1458
1751
  *
1459
1752
  * @param {number} padding Bounds padding as a fraction of map size.
@@ -1466,7 +1759,7 @@ var leafletRotate = (() => {
1466
1759
  if (!this._rotate && mapProto._getPaddedPixelBounds) {
1467
1760
  return mapProto._getPaddedPixelBounds.apply(this, arguments);
1468
1761
  }
1469
- const p = padding, size = this.getSize(), padMin = size.multiplyBy(-p), padMax = size.multiplyBy(1 + p);
1762
+ const [padMin, padMax] = this._getPitchedContainerBounds(padding);
1470
1763
  return new L.Bounds([
1471
1764
  this.containerPointToLayerPoint([padMin.x, padMin.y]).floor(),
1472
1765
  this.containerPointToLayerPoint([padMin.x, padMax.y]).floor(),
@@ -1541,12 +1834,18 @@ var leafletRotate = (() => {
1541
1834
  },
1542
1835
  /**
1543
1836
  * Subscribe to the supported device-orientation event, if available.
1837
+ * On iOS, permission is requested first (call `enable()` from a user gesture).
1544
1838
  *
1545
1839
  * @since leaflet-rotate (v0.1.0)
1546
1840
  */
1547
1841
  addHooks() {
1548
1842
  if (this._map._rotate && this.__deviceOrientationEvent) {
1549
1843
  L.DomEvent.on(window, this.__deviceOrientationEvent, this._throttled, this);
1844
+ const DOE = window.DeviceOrientationEvent;
1845
+ if (DOE && DOE.requestPermission) {
1846
+ DOE.requestPermission().then((state) => "denied" === state && this.enabled() && this.disable()).catch(() => {
1847
+ });
1848
+ }
1550
1849
  } else {
1551
1850
  this.disable();
1552
1851
  }
@@ -1677,6 +1976,7 @@ var leafletRotate = (() => {
1677
1976
  this._map = map;
1678
1977
  this.rotate = !!this._map.options.touchRotate;
1679
1978
  this.zoom = !!this._map.options.touchZoom;
1979
+ this.pitch = !!this._map.options.touchPitch;
1680
1980
  },
1681
1981
  /**
1682
1982
  * Listen for the start of two-finger gestures on the map container.
@@ -1703,10 +2003,18 @@ var leafletRotate = (() => {
1703
2003
  */
1704
2004
  _onTouchStart(e) {
1705
2005
  const map = this._map;
1706
- if (!e.touches || e.touches.length !== 2 || map._animatingZoom || this._zooming || this._rotating) {
2006
+ if (!e.touches || e.touches.length !== 2 || map._animatingZoom || this._zooming || this._rotating || this._gesture) {
1707
2007
  return;
1708
2008
  }
1709
2009
  const p1 = touchToContainerPoint(map, e.touches[0]), p2 = touchToContainerPoint(map, e.touches[1]), vector = p1.subtract(p2);
2010
+ const canPitch = this.pitch && map._rotate && Math.abs(vector.x) > Math.abs(vector.y);
2011
+ if (!canPitch && !this.zoom && !this.rotate) {
2012
+ return;
2013
+ }
2014
+ this._gesture = canPitch ? "pending" : "pinch";
2015
+ this._startP1 = p1;
2016
+ this._startP2 = p2;
2017
+ map._stopPitchBearingAnimation();
1710
2018
  this._centerPoint = map.getSize()._divideBy(2);
1711
2019
  this._startLatLng = map.containerPointToLatLng(this._centerPoint);
1712
2020
  if (this.zoom) {
@@ -1742,11 +2050,36 @@ var leafletRotate = (() => {
1742
2050
  * @since leaflet-rotate (v0.1.0)
1743
2051
  */
1744
2052
  _onTouchMove(e) {
1745
- if (!e.touches || e.touches.length !== 2 || !(this._zooming || this._rotating)) {
2053
+ if (!e.touches || e.touches.length !== 2 || !this._gesture) {
1746
2054
  return;
1747
2055
  }
1748
2056
  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
2057
  let delta;
2058
+ if (this._gesture === "pending") {
2059
+ const d1 = p1.subtract(this._startP1), d2 = p2.subtract(this._startP2);
2060
+ if (Math.max(d1.x * d1.x + d1.y * d1.y, d2.x * d2.x + d2.y * d2.y) < 25) {
2061
+ L.DomEvent.preventDefault(e);
2062
+ return;
2063
+ }
2064
+ const vertical = (d) => Math.abs(d.y) > Math.abs(d.x);
2065
+ if (vertical(d1) && vertical(d2) && Math.sign(d1.y) === Math.sign(d2.y)) {
2066
+ this._gesture = "pitch";
2067
+ this._lastPitchY = (this._startP1.y + this._startP2.y) / 2;
2068
+ this._zooming = this._rotating = false;
2069
+ } else {
2070
+ this._gesture = "pinch";
2071
+ }
2072
+ }
2073
+ if (this._gesture === "pitch") {
2074
+ const y = (p1.y + p2.y) / 2;
2075
+ map._setPitchAndBearing(map.getPitch() - (y - this._lastPitchY) * 0.5, map.getBearing());
2076
+ this._lastPitchY = y;
2077
+ L.DomEvent.preventDefault(e);
2078
+ return;
2079
+ }
2080
+ if (!(this._zooming || this._rotating)) {
2081
+ return;
2082
+ }
1750
2083
  if (this._rotating) {
1751
2084
  const theta = Math.atan(vector.x / vector.y);
1752
2085
  let bearingDelta = (theta - this._startTheta) * (180 / Math.PI);
@@ -1797,8 +2130,12 @@ var leafletRotate = (() => {
1797
2130
  * @since leaflet-rotate (v0.1.0)
1798
2131
  */
1799
2132
  _onTouchEnd() {
1800
- if (!this._moved || !(this._zooming || this._rotating)) {
2133
+ const gesture = this._gesture;
2134
+ delete this._gesture;
2135
+ if (gesture === "pitch" || gesture === "pending" || !this._moved || !(this._zooming || this._rotating)) {
1801
2136
  this._zooming = false;
2137
+ this._rotating = false;
2138
+ L.DomEvent.off(document, "touchmove", this._onTouchMove, this).off(document, "touchend touchcancel", this._onTouchEnd, this);
1802
2139
  return;
1803
2140
  }
1804
2141
  this._zooming = false;
@@ -1847,6 +2184,38 @@ var leafletRotate = (() => {
1847
2184
  });
1848
2185
  L.Map.addInitHook("addHandler", "touchRotate", L.Map.TouchRotate);
1849
2186
 
2187
+ // src/map/handler/TouchPitch.js
2188
+ L.Map.mergeOptions({
2189
+ /**
2190
+ * Whether the map can be pitched by dragging two fingers vertically
2191
+ *
2192
+ * @type {Boolean}
2193
+ *
2194
+ * @since leaflet-rotate (0.5.1)
2195
+ */
2196
+ touchPitch: false
2197
+ });
2198
+ L.Map.TouchPitch = L.Handler.extend({
2199
+ /**
2200
+ * Enable pitch handling in the map's shared touch gesture handler.
2201
+ *
2202
+ * @since leaflet-rotate (0.5.1)
2203
+ */
2204
+ addHooks() {
2205
+ this._map.touchGestures.enable();
2206
+ this._map.touchGestures.pitch = true;
2207
+ },
2208
+ /**
2209
+ * Disable pitch handling without affecting pinch zoom and rotation.
2210
+ *
2211
+ * @since leaflet-rotate (0.5.1)
2212
+ */
2213
+ removeHooks() {
2214
+ this._map.touchGestures.pitch = false;
2215
+ }
2216
+ });
2217
+ L.Map.addInitHook("addHandler", "touchPitch", L.Map.TouchPitch);
2218
+
1850
2219
  // src/map/handler/ShiftKeyRotate.js
1851
2220
  L.Map.mergeOptions({
1852
2221
  /**
@@ -1858,7 +2227,7 @@ var leafletRotate = (() => {
1858
2227
  */
1859
2228
  shiftKeyRotate: true,
1860
2229
  /**
1861
- * Whether Ctrl+drag changes bearing and pitch.
2230
+ * Whether Ctrl+drag (or right-button drag) changes bearing and pitch.
1862
2231
  *
1863
2232
  * @type {Boolean}
1864
2233
  *
@@ -1877,6 +2246,8 @@ var leafletRotate = (() => {
1877
2246
  this._onMouseDownBound = this._onMouseDown.bind(this);
1878
2247
  this._onMouseMoveBound = this._onMouseMove.bind(this);
1879
2248
  this._onMouseUpBound = this._onMouseUp.bind(this);
2249
+ this._onContextMenuBound = this._onContextMenu.bind(this);
2250
+ this._applyBound = this._apply.bind(this);
1880
2251
  },
1881
2252
  /**
1882
2253
  * Attach enabled wheel and pointer/mouse gesture listeners.
@@ -1911,6 +2282,7 @@ var leafletRotate = (() => {
1911
2282
  }
1912
2283
  this._pitchEvents = L.version.startsWith("2.") ? ["pointerdown", "pointermove", "pointerup pointercancel"] : ["mousedown", "mousemove", "mouseup"];
1913
2284
  this._map._container.addEventListener(this._pitchEvents[0], this._onMouseDownBound, true);
2285
+ this._map._container.addEventListener("contextmenu", this._onContextMenuBound, true);
1914
2286
  this._pitchHooksAdded = true;
1915
2287
  },
1916
2288
  /**
@@ -1923,6 +2295,7 @@ var leafletRotate = (() => {
1923
2295
  return;
1924
2296
  }
1925
2297
  this._map._container.removeEventListener(this._pitchEvents[0], this._onMouseDownBound, true);
2298
+ this._map._container.removeEventListener("contextmenu", this._onContextMenuBound, true);
1926
2299
  this._onMouseUp();
1927
2300
  delete this._pitchEvents;
1928
2301
  this._pitchHooksAdded = false;
@@ -1944,14 +2317,18 @@ var leafletRotate = (() => {
1944
2317
  }
1945
2318
  },
1946
2319
  /**
1947
- * Start Ctrl+drag and capture subsequent movement outside the map.
2320
+ * Start Ctrl+drag (or right-button drag) and capture subsequent movement outside the map.
1948
2321
  *
1949
2322
  * @param {MouseEvent|PointerEvent} e Gesture start event.
1950
2323
  *
1951
2324
  * @since leaflet-rotate (0.5.0)
1952
2325
  */
1953
2326
  _onMouseDown(e) {
1954
- if (!this._map._rotate || !this._map.options.pitchDrag || !e.ctrlKey || e.button !== 0) {
2327
+ if (this._dragging) {
2328
+ return;
2329
+ }
2330
+ this._moved = false;
2331
+ if (!this._map._rotate || !this._map.options.pitchDrag || !(e.button === 2 || e.ctrlKey && e.button === 0)) {
1955
2332
  return;
1956
2333
  }
1957
2334
  const scrollWheelZoom = this._map.scrollWheelZoom;
@@ -1964,42 +2341,82 @@ var leafletRotate = (() => {
1964
2341
  e.preventDefault();
1965
2342
  e.stopPropagation();
1966
2343
  e.stopImmediatePropagation();
2344
+ this._map._stopPitchBearingAnimation();
2345
+ this._dragging = true;
1967
2346
  this._lastX = e.clientX;
1968
2347
  this._lastY = e.clientY;
2348
+ this._pitchDelta = this._bearingDelta = 0;
1969
2349
  document.addEventListener(this._pitchEvents[1], this._onMouseMoveBound);
1970
2350
  for (const event of this._pitchEvents[2].split(" ")) {
1971
2351
  document.addEventListener(event, this._onMouseUpBound);
1972
2352
  }
1973
2353
  },
1974
2354
  /**
1975
- * Apply pointer movement to pitch (vertical) and bearing (horizontal).
2355
+ * Accumulate pointer movement as pitch (vertical) and bearing (horizontal).
2356
+ * Far from the horizontal center line the bearing follows the pointer angle
2357
+ * around the map center.
1976
2358
  *
1977
2359
  * @param {MouseEvent|PointerEvent} e Gesture movement event.
1978
2360
  *
1979
2361
  * @since leaflet-rotate (0.5.0)
1980
2362
  */
1981
2363
  _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;
2364
+ const rect = this._map._container.getBoundingClientRect(), cx = rect.left + rect.width / 2, cy = rect.top + rect.height / 2, x = e.clientX, y = e.clientY;
2365
+ this._pitchDelta += (this._lastY - y) * 0.5;
2366
+ 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);
2367
+ this._lastX = x;
2368
+ this._lastY = y;
2369
+ this._moved = true;
2370
+ if (!this._frame) {
2371
+ this._frame = requestAnimationFrame(this._applyBound);
2372
+ }
1988
2373
  e.preventDefault();
1989
2374
  },
1990
2375
  /**
1991
- * End Ctrl+drag and clear document-level listeners.
2376
+ * Apply accumulated movement once per animation frame.
2377
+ *
2378
+ * @since leaflet-rotate (0.5.1)
2379
+ */
2380
+ _apply() {
2381
+ this._frame = null;
2382
+ if (!this._pitchDelta && !this._bearingDelta) {
2383
+ return;
2384
+ }
2385
+ this._map._setPitchAndBearing(this._map.getPitch() + this._pitchDelta, this._map.getBearing() + this._bearingDelta);
2386
+ this._pitchDelta = this._bearingDelta = 0;
2387
+ },
2388
+ /**
2389
+ * Suppress the context menu that follows a right-button drag.
2390
+ *
2391
+ * @param {MouseEvent} e Context menu event.
2392
+ *
2393
+ * @since leaflet-rotate (0.5.1)
2394
+ */
2395
+ _onContextMenu(e) {
2396
+ if (this._dragging || this._moved) {
2397
+ e.preventDefault();
2398
+ e.stopPropagation();
2399
+ }
2400
+ },
2401
+ /**
2402
+ * End Ctrl+drag, flush pending movement and clear document-level listeners.
1992
2403
  *
1993
2404
  * @since leaflet-rotate (0.5.0)
1994
2405
  */
1995
2406
  _onMouseUp() {
1996
- if (!this._pitchEvents) {
2407
+ if (!this._pitchEvents || !this._dragging) {
1997
2408
  return;
1998
2409
  }
1999
2410
  document.removeEventListener(this._pitchEvents[1], this._onMouseMoveBound);
2000
2411
  for (const event of this._pitchEvents[2].split(" ")) {
2001
2412
  document.removeEventListener(event, this._onMouseUpBound);
2002
2413
  }
2414
+ cancelAnimationFrame(this._frame);
2415
+ if (this._map._mapPane) {
2416
+ this._apply();
2417
+ }
2418
+ this._frame = null;
2419
+ this._dragging = false;
2003
2420
  delete this._lastX;
2004
2421
  delete this._lastY;
2005
2422
  }
@@ -2073,10 +2490,11 @@ var leafletRotate = (() => {
2073
2490
  L.Control.Rotate = L.Control.extend({
2074
2491
  options: {
2075
2492
  position: "topleft",
2076
- closeOnZeroBearing: true
2493
+ closeOnZeroBearing: true,
2494
+ visualizePitch: true
2077
2495
  },
2078
2496
  /**
2079
- * Create the control element and subscribe to map rotation.
2497
+ * Create the control element and subscribe to map rotation and pitch.
2080
2498
  *
2081
2499
  * @param {L.Map} map
2082
2500
  *
@@ -2088,24 +2506,27 @@ var leafletRotate = (() => {
2088
2506
  const container = this._container = L.DomUtil.create("div", "leaflet-control-rotate leaflet-bar");
2089
2507
  const arrow = this._arrow = L.DomUtil.create("span", "leaflet-control-rotate-arrow");
2090
2508
  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
2509
  arrow.style.display = "block";
2093
2510
  arrow.style.width = "100%";
2094
2511
  arrow.style.height = "100%";
2095
2512
  arrow.style.backgroundRepeat = "no-repeat";
2096
2513
  arrow.style.backgroundPosition = "50%";
2514
+ arrow.style.pointerEvents = "none";
2097
2515
  const link = this._link = L.DomUtil.create("a", "leaflet-control-rotate-toggle", container);
2098
2516
  link.appendChild(arrow);
2099
2517
  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);
2518
+ link.title = this.options.visualizePitch ? "Reset bearing and pitch" : "Reset bearing to north";
2519
+ link.setAttribute("role", "button");
2520
+ link.setAttribute("aria-label", link.title);
2521
+ link.style.cursor = "grab";
2522
+ link.style.touchAction = "none";
2523
+ this._onPointerMoveBound = this._onPointerMove.bind(this);
2524
+ this._onPointerUpBound = this._onPointerUp.bind(this);
2525
+ 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);
2526
+ link.addEventListener("pointerdown", this._onPointerDown.bind(this));
2527
+ map.on("rotate pitch", this._restyle, this);
2528
+ this._map = map;
2102
2529
  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
2530
  return container;
2110
2531
  },
2111
2532
  /**
@@ -2116,102 +2537,119 @@ var leafletRotate = (() => {
2116
2537
  * @since leaflet-rotate (v0.1.0)
2117
2538
  */
2118
2539
  onRemove(map) {
2119
- map.off("rotate", this._restyle, this);
2540
+ this._onPointerUp();
2541
+ map.off("rotate pitch", this._restyle, this);
2120
2542
  },
2121
2543
  /**
2122
- * Begin dragging the control's bearing indicator.
2544
+ * Reset bearing (and pitch) unless the click ends a drag.
2123
2545
  *
2124
- * @param {MouseEvent} e Mouse-down event.
2125
- *
2126
- * @since leaflet-rotate (v0.1.0)
2546
+ * @since leaflet-rotate (0.5.1)
2127
2547
  */
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);
2548
+ _onClick() {
2549
+ if (this._moved) {
2550
+ this._moved = false;
2551
+ return;
2552
+ }
2553
+ if (!this._map || !this._map._rotate) {
2554
+ return;
2555
+ }
2556
+ if (this.options.visualizePitch) {
2557
+ this._map.resetNorthPitch();
2558
+ } else {
2559
+ this._map.resetNorth();
2560
+ }
2134
2561
  },
2135
2562
  /**
2136
- * End bearing-indicator dragging and remove document listeners.
2563
+ * Start dragging the control (mouse, pen or touch).
2137
2564
  *
2138
- * @param {MouseEvent} e Mouse-up event.
2565
+ * @param {PointerEvent} e
2139
2566
  *
2140
2567
  * @since leaflet-rotate (v0.1.0)
2141
2568
  */
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);
2569
+ _onPointerDown(e) {
2570
+ if (!this._map || !this._map._rotate || e.button > 0 || this._pointerId != null) {
2571
+ return;
2572
+ }
2573
+ e.preventDefault();
2574
+ e.stopPropagation();
2575
+ this._pointerId = e.pointerId;
2576
+ this._startX = this._lastX = e.clientX;
2577
+ this._startY = this._lastY = e.clientY;
2578
+ this._moved = false;
2579
+ this._link.style.cursor = "grabbing";
2580
+ document.addEventListener("pointermove", this._onPointerMoveBound);
2581
+ document.addEventListener("pointerup", this._onPointerUpBound);
2582
+ document.addEventListener("pointercancel", this._onPointerUpBound);
2146
2583
  },
2147
2584
  /**
2148
- * Set bearing from the horizontal drag distance.
2585
+ * Horizontal movement changes the bearing, vertical movement the pitch.
2149
2586
  *
2150
- * @param {MouseEvent} e Mouse-move event.
2587
+ * @param {PointerEvent} e
2151
2588
  *
2152
2589
  * @since leaflet-rotate (v0.1.0)
2153
2590
  */
2154
- _handleMouseDrag(e) {
2155
- if (!this.dragging) {
2591
+ _onPointerMove(e) {
2592
+ if (e.pointerId !== this._pointerId) {
2156
2593
  return;
2157
2594
  }
2158
- const deltaX = e.clientX - this.dragstartX;
2159
- this._map.setBearing(deltaX);
2595
+ e.preventDefault();
2596
+ if (!this._moved && Math.hypot(e.clientX - this._startX, e.clientY - this._startY) < 3) {
2597
+ return;
2598
+ }
2599
+ if (!this._moved) {
2600
+ this._moved = true;
2601
+ this._map._stopPitchBearingAnimation();
2602
+ }
2603
+ const map = this._map, pitch = this.options.visualizePitch ? map.getPitch() + (this._lastY - e.clientY) * 0.5 : map.getPitch();
2604
+ map._setPitchAndBearing(pitch, map.getBearing() + (e.clientX - this._lastX) * 0.8);
2605
+ this._lastX = e.clientX;
2606
+ this._lastY = e.clientY;
2160
2607
  },
2161
2608
  /**
2162
- * Cycle through touch, compass, and locked rotation modes.
2163
- * Compass mode may require permission from the browser.
2609
+ * Stop dragging and remove document listeners.
2164
2610
  *
2165
- * @param {Event} ev Control click event.
2611
+ * @param {PointerEvent} [e]
2166
2612
  *
2167
2613
  * @since leaflet-rotate (v0.1.0)
2168
2614
  */
2169
- _cycleState(ev) {
2170
- if (!this._map) {
2615
+ _onPointerUp(e) {
2616
+ if (e && e.pointerId !== this._pointerId) {
2171
2617
  return;
2172
2618
  }
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
- }
2619
+ document.removeEventListener("pointermove", this._onPointerMoveBound);
2620
+ document.removeEventListener("pointerup", this._onPointerUpBound);
2621
+ document.removeEventListener("pointercancel", this._onPointerUpBound);
2622
+ if (e && this._moved && !this._link.contains(e.target)) {
2623
+ this._moved = false;
2624
+ }
2625
+ delete this._pointerId;
2626
+ if (this._link) {
2627
+ this._link.style.cursor = "grab";
2628
+ this._restyle();
2185
2629
  }
2186
- this._restyle();
2187
2630
  },
2188
2631
  /**
2189
- * Update the control icon and visibility to match the current mode.
2632
+ * Update the icon (bearing and pitch) and the control visibility.
2190
2633
  *
2191
2634
  * @listens L.Map#rotate
2635
+ * @listens L.Map#pitch
2192
2636
  *
2193
2637
  * @since leaflet-rotate (v0.1.0)
2194
2638
  */
2195
2639
  _restyle() {
2196
- if (!this._map.options.rotate) {
2640
+ const map = this._map;
2641
+ if (!map) {
2642
+ return;
2643
+ }
2644
+ if (!map.options.rotate) {
2197
2645
  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
- }
2646
+ return;
2647
+ }
2648
+ const bearing = map.getBearing(), pitch = map.getPitch();
2649
+ 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)`;
2650
+ if (this.options.closeOnZeroBearing) {
2651
+ const hidden = !bearing && !(this.options.visualizePitch && pitch) && this._pointerId == null;
2652
+ this._container.style.display = hidden ? "none" : "block";
2215
2653
  }
2216
2654
  }
2217
2655
  });