leaflet-rotate 0.5.1 → 0.5.3

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leaflet-rotate",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "A Leaflet plugin that allows to add rotation functionality to map tiles",
5
5
  "type": "module",
6
6
  "main": "dist/leaflet-rotate.js",
package/src/index.js CHANGED
@@ -53,6 +53,7 @@ export * from './layer/vector/Renderer';
53
53
  export * from './map/Map';
54
54
  export * from './map/handler/CompassBearing';
55
55
  export * from './map/handler/ContainerMutation';
56
+ export * from './map/handler/Drag';
56
57
  export * from './map/handler/TouchGestures';
57
58
  export * from './map/handler/TouchRotate';
58
59
  export * from './map/handler/TouchPitch';
@@ -201,17 +201,7 @@ L.Marker.include({
201
201
  }
202
202
 
203
203
  /** @TODO use markerProto._setPos */
204
- let bearing = this.options.rotation || 0;
205
- if (this.options.rotateWithView) {
206
- bearing += this._map._bearing;
207
- }
208
- if (this.options.keepUpright) {
209
- bearing = ((bearing % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);
210
- if (bearing > Math.PI / 2 && bearing < 3 * Math.PI / 2) {
211
- bearing += Math.PI;
212
- }
213
- bearing %= 2 * Math.PI;
214
- }
204
+ const bearing = this._getIconBearing();
215
205
 
216
206
  /** @TODO use markerProto._setPos */
217
207
  if (this._icon) {
@@ -228,6 +218,29 @@ L.Marker.include({
228
218
  this._resetZIndex();
229
219
  },
230
220
 
221
+ /**
222
+ * Compute the on-screen icon rotation from `rotation`, `rotateWithView` and `keepUpright`.
223
+ *
224
+ * @returns {number} Angle in radians.
225
+ *
226
+ * @since leaflet-rotate (0.5.3)
227
+ */
228
+ _getIconBearing() {
229
+ let bearing = this.options.rotation || 0;
230
+ if (this.options.rotateWithView) {
231
+ bearing += this._map._bearing;
232
+ }
233
+ if (this.options.keepUpright) {
234
+ bearing = ((bearing % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);
235
+ if (bearing > Math.PI / 2 && bearing < 3 * Math.PI / 2) {
236
+ bearing += Math.PI;
237
+ }
238
+ bearing %= 2 * Math.PI;
239
+ }
240
+
241
+ return bearing;
242
+ },
243
+
231
244
  // _updateZIndex(offset) {
232
245
  // if (!this._map._rotate) {
233
246
  // return markerProto._updateZIndex.apply(this, arguments);
@@ -32,9 +32,24 @@ L.GridLayer.include({
32
32
  return events;
33
33
  },
34
34
 
35
+ /**
36
+ * Preserve the 3D context on tile containers without making individual tile levels 3D.
37
+ *
38
+ * @since leaflet-rotate (0.5.3)
39
+ */
40
+ _initContainer() {
41
+ gridLayerProto._initContainer.apply(this, arguments);
42
+ if (!this._map._rotate || !this._container) { return; }
43
+ // Tile levels stay flat: preserving 3D on them also clips images in Firefox.
44
+ for (let pane = this._container; pane && pane !== this._map._rotatePane; pane = pane.parentElement) {
45
+ pane.style.transformStyle = 'preserve-3d';
46
+ }
47
+ },
48
+
35
49
  /**
36
50
  * Keep tile levels aligned when a bearing/pitch change shifts the pixel origin
37
51
  * (eg. when the rotation pivot is not the view center), then refresh tiles (throttled).
52
+ * The first event also synchronizes levels created before any accumulated pans.
38
53
  *
39
54
  * @listens L.Map~rotate
40
55
  *
@@ -44,7 +59,7 @@ L.GridLayer.include({
44
59
  const map = this._map;
45
60
  if (!map) { return; }
46
61
  const origin = map.getPixelOrigin();
47
- if (this._rotateOrigin && !this._rotateOrigin.equals(origin) && !map._animatingZoom && this._levels) {
62
+ if ((!this._rotateOrigin || !this._rotateOrigin.equals(origin)) && !map._animatingZoom && this._levels) {
48
63
  this._setZoomTransforms(map.getCenter(), map.getZoom());
49
64
  }
50
65
  this._rotateOrigin = origin;
package/src/map/Map.js CHANGED
@@ -1,6 +1,17 @@
1
1
  /**
2
2
  * Extends Leaflet maps with bearing rotation and 3D pitch.
3
3
  *
4
+ * Coordinate spaces used by the overrides below:
5
+ * - projected pixels: CRS coordinates at a given zoom;
6
+ * - layer points: projected pixels minus `_pixelOrigin`, on the flat ground plane;
7
+ * - map-pane points: layer points after bearing/pitch, before the outer pane translation;
8
+ * - container points: map-pane points plus `_getMapPanePos()`, in viewport pixels.
9
+ *
10
+ * Tiles and vectors share `rotatePane`; upright markers and popups use `norotatePane`
11
+ * and the same point conversions. Public angles are degrees; `_bearing` and `_pitch`
12
+ * are radians. With pitch enabled, CSS and coordinate conversions must use the same
13
+ * projective transform, including while the outer map pane is being dragged.
14
+ *
4
15
  * @external L.Map
5
16
  *
6
17
  * @since leaflet-rotate (v0.1.0)
@@ -60,6 +71,20 @@ L.Map.mergeOptions({
60
71
 
61
72
  L.Map.include({
62
73
 
74
+ /**
75
+ * Rebuild perspective before Leaflet calculates the origin of a reset view.
76
+ * A reset clears the outer pane translation, unlike a completed pan.
77
+ */
78
+ _resetView() {
79
+ if (this._pitch) {
80
+ delete this._pitchPan;
81
+ L.DomUtil.setPosition(this._mapPane, new L.Point(0, 0));
82
+ this._updatePitchTransform();
83
+ arguments[0] = this._limitCenter(arguments[0], arguments[1], this.options.maxBounds);
84
+ }
85
+ return mapProto._resetView.apply(this, arguments);
86
+ },
87
+
63
88
  /**
64
89
  * Apply an immediate animated-zoom frame without waiting for a transition delay.
65
90
  *
@@ -95,7 +120,11 @@ L.Map.include({
95
120
  mapProto.initialize.apply(this, arguments);
96
121
  if (this.options.rotate) {
97
122
  this.on('resize', this._updatePitchTransform, this);
123
+ this.on('movestart dragstart', this._startPitchPan, this);
124
+ this.on('move', this._updatePitchPan, this);
125
+ this.on('moveend', this._endPitchPan, this);
98
126
  this.on('move', this._updatePitchSky, this);
127
+ this.on('rotate', this._panInsideMaxBounds, this);
99
128
  this.setBearing(this.options.bearing);
100
129
  this.setPitch(this.options.pitch);
101
130
  }
@@ -242,7 +271,8 @@ L.Map.include({
242
271
  /**
243
272
  * Return the geographic bounds of the current viewport, accounting for rotation and pitch.
244
273
  *
245
- * The result is the smallest geographic bounds containing all four viewport corners.
274
+ * The result encloses the four corners of the visible ground rectangle. With pitch,
275
+ * the rectangle stops at the configured far plane, excluding the sky.
246
276
  *
247
277
  * @returns {L.LatLngBounds} Geographic bounds visible in the map viewport.
248
278
  *
@@ -507,26 +537,40 @@ L.Map.include({
507
537
  },
508
538
 
509
539
  /**
510
- * Transform a point with a projective matrix, accepting DOMMatrix or its 2D inverse.
540
+ * Transform a ground-plane point with a DOMMatrix, or reverse its screen projection.
541
+ * Both branches divide by the homogeneous coordinate after applying the matrix.
511
542
  *
512
543
  * @param {L.Point|number[]} point Input pixel coordinate.
513
- * @param {DOMMatrix|Object} matrix Forward or inverse transformation matrix.
544
+ * @param {DOMMatrix|Object} matrix Forward CSS matrix or inverse homography with
545
+ * row-major coefficients `a` through `i`.
546
+ * @param {L.Point} [anchor] Keep inverse pan coordinates on this anchor's side of the horizon.
514
547
  *
515
548
  * @returns {L.Point} Transformed pixel coordinate.
516
549
  *
517
550
  * @since leaflet-rotate (0.5.0)
518
551
  */
519
- _transformPitchPoint(point, matrix) {
552
+ _transformPitchPoint(point, matrix, anchor) {
520
553
  point = toPoint(point);
521
554
  if (!(matrix instanceof DOMMatrix)) {
555
+ if (anchor) {
556
+ const anchorW = matrix.g * anchor.x + matrix.h * anchor.y + matrix.i,
557
+ pointW = matrix.g * point.x + matrix.h * point.y + matrix.i;
558
+ // Keep a constrained pan on the near side of the horizon.
559
+ if (pointW * anchorW <= 0) {
560
+ point = anchor.add(point.subtract(anchor).multiplyBy(anchorW / (anchorW - pointW) * (1 - 1e-6)));
561
+ }
562
+ }
522
563
  const denominator = matrix.g * point.x + matrix.h * point.y + matrix.i;
523
564
  return new L.Point(
524
565
  (matrix.a * point.x + matrix.b * point.y + matrix.c) / denominator,
525
566
  (matrix.d * point.x + matrix.e * point.y + matrix.f) / denominator
526
567
  );
527
568
  }
528
- const transformed = new DOMPoint(point.x, point.y).matrixTransform(matrix);
529
- return new L.Point(transformed.x / transformed.w, transformed.y / transformed.w);
569
+ const denominator = matrix.m14 * point.x + matrix.m24 * point.y + matrix.m44;
570
+ return new L.Point(
571
+ (matrix.m11 * point.x + matrix.m21 * point.y + matrix.m41) / denominator,
572
+ (matrix.m12 * point.x + matrix.m22 * point.y + matrix.m42) / denominator
573
+ );
530
574
  },
531
575
 
532
576
  /**
@@ -543,8 +587,22 @@ L.Map.include({
543
587
  position = this._rotatePane._leaflet_pos || new L.Point(0, 0),
544
588
  transform = `translate3d(${pivot.x}px,${pivot.y}px,0) perspective(${perspective}px) rotateX(${this.getPitch()}deg) rotateZ(${this.getBearing()}deg) translate3d(${position.x - pivot.x}px,${position.y - pivot.y}px,0)`;
545
589
 
590
+ this._setPitchTransform(transform);
591
+ },
592
+
593
+ /**
594
+ * Apply the CSS transform and cache matching forward/inverse point conversions.
595
+ *
596
+ * Ground points have z = 0, so the 4x4 CSS matrix reduces to the homography
597
+ * [a b c; d e f; g h i]. Its inverse maps screen points back onto that plane;
598
+ * inverting the full 4x4 matrix at screen z = 0 would not undo perspective.
599
+ *
600
+ * @param {string} transform CSS transform list, including a serialized DOMMatrix.
601
+ *
602
+ * @since leaflet-rotate (0.5.2)
603
+ */
604
+ _setPitchTransform(transform) {
546
605
  this._rotatePane.style.transformOrigin = '0 0';
547
- this._rotatePane.style.transform = transform;
548
606
  // parse the transform directly: getComputedStyle() would force a style recalculation on every frame
549
607
  const matrix = this._pitchTransform = new DOMMatrix(transform),
550
608
  a = matrix.m11, b = matrix.m21, c = matrix.m41,
@@ -552,6 +610,11 @@ L.Map.include({
552
610
  g = matrix.m14, h = matrix.m24, i = matrix.m44,
553
611
  determinant = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g);
554
612
 
613
+ // Preserve invertibility for Firefox and scale homogeneous coefficients to avoid CSS rounding.
614
+ const ground = DOMMatrix.fromMatrix(matrix);
615
+ ground.m13 = ground.m23 = ground.m43 = 0;
616
+ this._rotatePane.style.transform = `matrix3d(${Array.from(ground.toFloat64Array(), value => value * 1000).join(',')})`;
617
+
555
618
  this._pitchInverseTransform = {
556
619
  a: (e * i - f * h) / determinant,
557
620
  b: (c * h - b * i) / determinant,
@@ -566,9 +629,79 @@ L.Map.include({
566
629
  this._updatePitchSky();
567
630
  },
568
631
 
632
+ /**
633
+ * Capture a fixed projection baseline for a pitched drag or programmatic pan.
634
+ *
635
+ * `movestart` uses the viewport center; `dragstart` replaces it with the original
636
+ * pointer position. Store both the screen anchor and its ground-plane coordinate
637
+ * so subsequent frames can keep the grabbed geographic point under the pointer.
638
+ * Pane positions are in screen pixels and the baseline matrices stay unchanged.
639
+ *
640
+ * @param {L.Event} e Leaflet `movestart` or `dragstart` event.
641
+ *
642
+ * @since leaflet-rotate (0.5.2)
643
+ */
644
+ _startPitchPan(e) {
645
+ if (!this._pitch || !this._loaded) { return; }
646
+ const draggable = this.dragging && this.dragging._draggable,
647
+ anchor = e.type === 'dragstart' && draggable
648
+ ? (this.pointerEventToContainerPoint || this.mouseEventToContainerPoint).call(this, {
649
+ clientX: draggable._startPoint.x, clientY: draggable._startPoint.y,
650
+ })
651
+ : this.getSize().divideBy(2),
652
+ panePos = this._getMapPanePos().clone();
653
+ this._pitchPan = {
654
+ anchor,
655
+ panePos,
656
+ lastPanePos: panePos,
657
+ matrix: this._pitchTransform,
658
+ inverse: this._pitchInverseTransform,
659
+ layerAnchor: this.containerPointToLayerPoint(anchor),
660
+ zoom: this._zoom,
661
+ };
662
+ },
663
+
664
+ /**
665
+ * Translate the ground plane without moving the camera's vanishing point.
666
+ *
667
+ * For pane displacement delta, invert the displaced anchor with the baseline
668
+ * projection to find the required layer-space shift. Compose
669
+ * T(-delta) * baseline * T(shift): the left translation cancels Leaflet's outer
670
+ * pane movement, while the right translation moves geography before perspective.
671
+ * Always derive this from the baseline, not the previous frame, to avoid drift.
672
+ * Zoom frames are handled by Leaflet's zoom/origin updates instead.
673
+ *
674
+ * @since leaflet-rotate (0.5.2)
675
+ */
676
+ _updatePitchPan() {
677
+ const pan = this._pitchPan;
678
+ if (!this._pitch || !pan || this._animatingZoom || this._zoom !== pan.zoom) { return; }
679
+ const panePos = this._getMapPanePos();
680
+ if (panePos.equals(pan.lastPanePos)) { return; }
681
+ const delta = panePos.subtract(pan.panePos),
682
+ target = this._transformPitchPoint(pan.anchor.add(delta).subtract(pan.panePos), pan.inverse),
683
+ shift = target.subtract(pan.layerAnchor);
684
+ const matrix = new DOMMatrix().translate(-delta.x, -delta.y)
685
+ .multiply(pan.matrix).translate(shift.x, shift.y);
686
+ this._setPitchTransform(matrix.toString());
687
+ pan.lastPanePos = panePos.clone();
688
+ delete this._lastCenter;
689
+ },
690
+
691
+ /**
692
+ * Discard the baseline only at `moveend`, after any drag inertia has completed.
693
+ * Keep the final transform and pixel origin: resetting the view here would
694
+ * reproject layers abruptly and cause a visible jump on release.
695
+ *
696
+ * @since leaflet-rotate (0.5.2)
697
+ */
698
+ _endPitchPan() {
699
+ delete this._pitchPan;
700
+ },
701
+
569
702
  /**
570
703
  * Fill the area above the far plane (where no tiles are loaded) with a sky gradient.
571
- * Style it through the `.leaflet-pitch-sky` class.
704
+ * Style it through `.leaflet-pitch-sky` or the `--leaflet-sky-background` CSS property.
572
705
  *
573
706
  * @since leaflet-rotate (0.5.1)
574
707
  */
@@ -582,7 +715,7 @@ L.Map.include({
582
715
  this._pitchSky = L.DomUtil.create('div', 'leaflet-pitch-sky', this._container);
583
716
  Object.assign(this._pitchSky.style, {
584
717
  position: 'absolute', left: 0, top: 0, width: '100%', zIndex: 0, pointerEvents: 'none',
585
- background: 'linear-gradient(#9ec5e8, #dfe9f0)',
718
+ background: 'var(--leaflet-sky-background, linear-gradient(#9ec5e8, #dfe9f0))',
586
719
  });
587
720
  }
588
721
  this._pitchSky.style.display = '';
@@ -754,6 +887,8 @@ L.Map.include({
754
887
 
755
888
  if (this._rotate) {
756
889
  this._rotatePane = this.createPane('rotatePane', this._mapPane);
890
+ // Keep the context stable at pitch zero too: Firefox clips tiles when it is removed.
891
+ this._mapPane.style.transformStyle = this._rotatePane.style.transformStyle = 'preserve-3d';
757
892
  this._norotatePane = this.createPane('norotatePane', this._mapPane);
758
893
  if (L.version.startsWith('2.')) {
759
894
  this._rotatePane.style.transformOrigin = '0 0';
@@ -832,54 +967,111 @@ L.Map.include({
832
967
  },
833
968
 
834
969
  /**
835
- * Pans the map to the closest view that would lie inside the given bounds
836
- * (if it's not already), controlling the animation using the options specific,
837
- * if any.
838
- *
839
- * @TODO check if map bounds calculated by `L.Map::panInsideBounds()`
840
- * function should match the `rotatePane` or `norotatePane` bounds
970
+ * Constrain the visible ground footprint, including its asymmetric pitched far side.
971
+ * When it is larger than maxBounds, Leaflet's rebound centers the excess on each axis.
972
+ */
973
+ _limitCenter(center, zoom, bounds) {
974
+ if (!this._rotate || !bounds) {
975
+ return mapProto._limitCenter.apply(this, arguments);
976
+ }
977
+ const centerPoint = this.project(center, zoom),
978
+ layerCenter = this.containerPointToLayerPoint(this.getSize().divideBy(2)),
979
+ footprint = this._getPaddedPixelBounds(0, false),
980
+ viewBounds = new L.Bounds(
981
+ centerPoint.add(footprint.min.subtract(layerCenter)),
982
+ centerPoint.add(footprint.max.subtract(layerCenter))
983
+ ),
984
+ offset = this._getBoundsOffset(viewBounds, bounds, zoom);
985
+ if (Math.abs(offset.x) <= 1 && Math.abs(offset.y) <= 1) { return center; }
986
+ return this.unproject(centerPoint.add(offset), zoom);
987
+ },
988
+
989
+ /**
990
+ * Reapply configured maximum bounds after a map transform.
841
991
  *
842
- * @see https://github.com/fnicollet/Leaflet/issues/7
843
- *
844
- * @param {L.LatLngBounds} bounds Geographic bounds to fit inside the viewport.
845
- * @param {Object} [options] Pan animation options.
846
- *
992
+ * @since leaflet-rotate (0.5.3)
993
+ */
994
+ _panInsideMaxBounds() {
995
+ if (this.options.maxBounds) {
996
+ return mapProto._panInsideMaxBounds.apply(this, arguments);
997
+ }
998
+ },
999
+
1000
+ /**
1001
+ * Keep the visible ground footprint inside the given bounds.
1002
+ *
1003
+ * @param {L.LatLngBounds|Array} bounds Geographic bounds.
1004
+ * @param {Object} [options] Pan options passed to Leaflet.
847
1005
  * @returns {L.Map} This map instance.
848
- *
849
- * @since leaflet-rotate (v0.1.0)
1006
+ *
1007
+ * @since leaflet-rotate (0.5.3)
850
1008
  */
851
- // panInsideBounds(bounds, options) {
852
- // this._enforcingBounds = true;
853
- // const center = this.getCenter(),
854
- // newCenter = this._limitCenter(center, this._zoom, L.latLngBounds(bounds));
855
- //
856
- // if (!center.equals(newCenter)) {
857
- // this.panTo(newCenter, options);
858
- // }
859
- //
860
- // this._enforcingBounds = false;
861
- // return this;
862
- // },
1009
+ panInsideBounds(bounds, options) {
1010
+ if (this._rotate) {
1011
+ bounds = bounds instanceof L.LatLngBounds ? bounds : new L.LatLngBounds(bounds);
1012
+ const center = this.getCenter(),
1013
+ limited = this._limitCenter(center, this._zoom, bounds);
1014
+ const offset = this._getCenterOffset(limited);
1015
+ // Screen-space rounding cannot reliably apply subpixel pans at high pitch.
1016
+ if (!center.equals(limited) && Math.abs(offset.x) <= 1 && Math.abs(offset.y) <= 1) {
1017
+ this._enforcingBounds = true;
1018
+ this.setView(limited, this._zoom, { reset: true });
1019
+ this._enforcingBounds = false;
1020
+ return this;
1021
+ }
1022
+ }
1023
+ return mapProto.panInsideBounds.apply(this, arguments);
1024
+ },
863
1025
 
864
- // adjust center for view to get inside bounds
865
- // _limitCenter(center, zoom, bounds) {
866
- //
867
- // if (!bounds) { return center; }
868
- //
869
- // const centerPoint = this.project(center, zoom),
870
- // viewHalf = this.getSize().divideBy(2),
871
- // viewBounds = new Bounds(centerPoint.subtract(viewHalf), centerPoint.add(viewHalf)),
872
- // offset = this._getBoundsOffset(viewBounds, bounds, zoom);
873
- //
874
- // // If offset is less than a pixel, ignore.
875
- // // This prevents unstable projections from getting into
876
- // // an infinite loop of tiny offsets.
877
- // if (Math.abs(offset.x) <= 1 && Math.abs(offset.y) <= 1) {
878
- // return center;
879
- // }
880
- //
881
- // return this.unproject(centerPoint.add(offset), zoom);
882
- // },
1026
+ /**
1027
+ * Leaflet supplies inertia offsets in screen pixels, not projected CRS pixels.
1028
+ * Keep the drag's moving anchor when inertia continues a pitched pan.
1029
+ */
1030
+ _limitOffset(offset, bounds) {
1031
+ if (!this._rotate || !bounds) {
1032
+ return mapProto._limitOffset.apply(this, arguments);
1033
+ }
1034
+ const pan = this._pitchPan,
1035
+ anchor = pan ? pan.anchor.add(this._getMapPanePos().subtract(pan.panePos)) : this.getSize().divideBy(2),
1036
+ layerAnchor = this.containerPointToLayerPoint(anchor),
1037
+ panePos = this._getMapPanePos(),
1038
+ target = anchor.subtract(offset).subtract(panePos),
1039
+ shift = (this._pitch
1040
+ ? this._transformPitchPoint(target, this._pitchInverseTransform, anchor.subtract(panePos))
1041
+ : this.containerPointToLayerPoint(anchor.subtract(offset))).subtract(layerAnchor),
1042
+ footprint = this._getPaddedPixelBounds(0, false),
1043
+ origin = this.getPixelOrigin().subtract(shift),
1044
+ correction = this._getBoundsOffset(new L.Bounds(
1045
+ footprint.min.add(origin), footprint.max.add(origin)
1046
+ ), bounds);
1047
+ return anchor.subtract(this.layerPointToContainerPoint(layerAnchor.add(shift).subtract(correction)));
1048
+ },
1049
+
1050
+ /**
1051
+ * Pan by a screen-pixel offset, constraining large immediate pans to maxBounds.
1052
+ *
1053
+ * @param {L.Point|number[]} offset Pan offset in pixels.
1054
+ * @param {Object} [options] Pan options passed to Leaflet.
1055
+ * @returns {L.Map} This map instance.
1056
+ *
1057
+ * @since leaflet-rotate (0.5.3)
1058
+ */
1059
+ panBy(offset, options) {
1060
+ offset = toPoint(offset);
1061
+ if (this._rotate && this._loaded && !this.getSize().contains(offset) && !(options && options.animate)) {
1062
+ const anchor = this.getSize().divideBy(2),
1063
+ panePos = this._getMapPanePos(),
1064
+ layerAnchor = this.containerPointToLayerPoint(anchor),
1065
+ target = anchor.subtract(offset).subtract(panePos),
1066
+ shift = (this._pitch
1067
+ ? this._transformPitchPoint(target, this._pitchInverseTransform, anchor.subtract(panePos))
1068
+ : this.containerPointToLayerPoint(anchor.subtract(offset))).subtract(layerAnchor),
1069
+ center = this.unproject(this.project(this.getCenter()).subtract(shift));
1070
+ this._resetView(this._limitCenter(center, this._zoom, this.options.maxBounds), this._zoom);
1071
+ return this;
1072
+ }
1073
+ return mapProto.panBy.call(this, offset, options);
1074
+ },
883
1075
 
884
1076
  // @method flyToBounds(bounds: LatLngBounds, options?: fitBounds options): this
885
1077
  // Sets the view of the map with a smooth animation like [`flyTo`](#map-flyto),
@@ -966,7 +1158,10 @@ L.Map.include({
966
1158
  // },
967
1159
 
968
1160
  /**
969
- * Return the pixel offset from the current center to the given geographic point.
1161
+ * Return the screen-space pan offset needed to center the given geographic point.
1162
+ * Under perspective, projecting a negated layer offset is not equivalent to
1163
+ * negating its screen projection. Reflect the target about the layer center
1164
+ * to match the opposite anchor displacement performed by Leaflet's `panBy`.
970
1165
  *
971
1166
  * @param {L.LatLng} latlng Geographic point.
972
1167
  *
@@ -978,6 +1173,12 @@ L.Map.include({
978
1173
  if (!this._rotate) {
979
1174
  return mapProto._getCenterOffset.apply(this, arguments);
980
1175
  }
1176
+ if (this._pitch) {
1177
+ const center = this.getSize().divideBy(2),
1178
+ layerCenter = this.containerPointToLayerPoint(center),
1179
+ opposite = layerCenter.multiplyBy(2).subtract(this.project(latlng).subtract(this._pixelOrigin));
1180
+ return center.subtract(this.layerPointToContainerPoint(opposite));
1181
+ }
981
1182
  return this.latLngToContainerPoint(latlng)._subtract(this.getSize()._divideBy(2));
982
1183
  },
983
1184
 
@@ -1056,9 +1257,12 @@ L.Map.include({
1056
1257
  },
1057
1258
 
1058
1259
  /**
1260
+ * Return the viewport center before the outer map-pane translation.
1261
+ * Used as the bearing/pitch pivot in map-pane coordinates, not layer coordinates.
1262
+ *
1059
1263
  * @since leaflet-rotate (v0.2.0)
1060
1264
  *
1061
- * @returns {L.Point} Map pivot point in pixel coordinates.
1265
+ * @returns {L.Point} Map pivot in map-pane pixels.
1062
1266
  */
1063
1267
  _getPixelCenter() {
1064
1268
  if (!this._rotate && mapProto._getPixelCenter) {
@@ -1068,26 +1272,31 @@ L.Map.include({
1068
1272
  },
1069
1273
 
1070
1274
  /**
1275
+ * Convert the padded visible ground rectangle to layer bounds for vector renderers.
1276
+ * Clip its far edge before reversing perspective to avoid unbounded render areas.
1277
+ *
1071
1278
  * @see src\layer\vector\Renderer::_update()
1072
1279
  *
1073
1280
  * @param {number} padding Bounds padding as a fraction of map size.
1281
+ * @param {boolean} [round=true] Round renderer bounds; navigation needs exact ground coordinates.
1074
1282
  *
1075
1283
  * @returns {L.Bounds} Padded bounds in layer-point coordinates.
1076
1284
  *
1077
1285
  * @since leaflet-rotate (v0.2.0)
1078
1286
  */
1079
- _getPaddedPixelBounds(padding) {
1287
+ _getPaddedPixelBounds(padding, round = true) {
1080
1288
  if (!this._rotate && mapProto._getPaddedPixelBounds) {
1081
1289
  return mapProto._getPaddedPixelBounds.apply(this, arguments);
1082
1290
  }
1083
1291
  const [padMin, padMax] = this._getPitchedContainerBounds(padding);
1084
1292
 
1085
1293
  return new L.Bounds([
1086
- this.containerPointToLayerPoint([padMin.x, padMin.y]).floor(),
1087
- this.containerPointToLayerPoint([padMin.x, padMax.y]).floor(),
1088
- this.containerPointToLayerPoint([padMax.x, padMin.y]).floor(),
1089
- this.containerPointToLayerPoint([padMax.x, padMax.y]).floor()
1090
- ]);
1294
+ [padMin.x, padMin.y], [padMin.x, padMax.y],
1295
+ [padMax.x, padMin.y], [padMax.x, padMax.y]
1296
+ ].map(point => {
1297
+ const projected = this.containerPointToLayerPoint(point);
1298
+ return round ? projected.floor() : projected;
1299
+ }));
1091
1300
  },
1092
1301
 
1093
1302
  /**
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Apply maxBoundsViscosity in projected ground coordinates before moving the map pane.
3
+ */
4
+ const dragProto = Object.defineProperties({}, Object.getOwnPropertyDescriptors(L.Map.Drag.prototype));
5
+
6
+ L.Map.Drag.include({
7
+
8
+ /**
9
+ * Capture the ground footprint and pointer anchor before a constrained drag.
10
+ */
11
+ _onDragStart() {
12
+ dragProto._onDragStart.apply(this, arguments);
13
+ const map = this._map;
14
+ this._rotateLimit = null;
15
+ if (!map._rotate || !map.options.maxBounds || !map.options.maxBoundsViscosity) { return; }
16
+ const anchor = (map.pointerEventToContainerPoint || map.mouseEventToContainerPoint).call(map, {
17
+ clientX: this._draggable._startPoint.x, clientY: this._draggable._startPoint.y,
18
+ }),
19
+ footprint = map._getPaddedPixelBounds(0, false),
20
+ origin = map.getPixelOrigin();
21
+ this._rotateLimit = {
22
+ anchor,
23
+ layerAnchor: map.containerPointToLayerPoint(anchor),
24
+ panePos: map._getMapPanePos().clone(),
25
+ footprint: new L.Bounds(footprint.min.add(origin), footprint.max.add(origin)),
26
+ matrix: map._pitch ? map._pitchTransform : null,
27
+ inverse: map._pitchInverseTransform,
28
+ bearing: map._bearing,
29
+ };
30
+ },
31
+
32
+ /**
33
+ * Apply max-bounds viscosity in ground coordinates during a rotated drag.
34
+ */
35
+ _onPreDragLimit() {
36
+ const limit = this._rotateLimit;
37
+ if (!this._map._rotate || !limit) {
38
+ return dragProto._onPreDragLimit.apply(this, arguments);
39
+ }
40
+ const map = this._map,
41
+ draggable = this._draggable,
42
+ offset = draggable._newPos.subtract(draggable._startPos),
43
+ shift = limit.matrix
44
+ ? map._transformPitchPoint(limit.anchor.add(offset).subtract(limit.panePos), limit.inverse,
45
+ limit.anchor.subtract(limit.panePos))
46
+ .subtract(limit.layerAnchor)
47
+ : offset.rotate(-limit.bearing),
48
+ correction = map._getBoundsOffset(new L.Bounds(
49
+ limit.footprint.min.subtract(shift), limit.footprint.max.subtract(shift)
50
+ ), map.options.maxBounds);
51
+ shift._subtract(correction.multiplyBy(this._viscosity));
52
+ const limitedOffset = limit.matrix
53
+ ? map._transformPitchPoint(limit.layerAnchor.add(shift), limit.matrix)
54
+ .add(limit.panePos).subtract(limit.anchor)
55
+ : shift.rotate(limit.bearing);
56
+ draggable._newPos = draggable._startPos.add(limitedOffset);
57
+ },
58
+
59
+ });
@@ -0,0 +1,33 @@
1
+ @navigation-bounds
2
+ Feature: Navigation limits on rotated and pitched maps
3
+ Background:
4
+ Given a standalone map with navigation limits is open
5
+
6
+ @issue-75
7
+ Scenario Outline: Zoom and programmatic navigation respect the transformed viewport
8
+ When navigation rotation is <enabled> with bearing <bearing> and pitch <pitch>
9
+ Then minimum zoom 14 applies to view changes, wheel zoom and pinch zoom
10
+ And maximum bounds constrain programmatic pans and camera changes
11
+
12
+ Examples:
13
+ | enabled | bearing | pitch |
14
+ | false | 0 | 0 |
15
+ | true | 0 | 0 |
16
+ | true | 45 | 0 |
17
+ | true | 135 | 45 |
18
+ | true | 270 | 85 |
19
+
20
+ @issue-75
21
+ Scenario Outline: Viscosity limits the geographic footprint during dragging
22
+ When navigation rotation is <enabled> with bearing <bearing> and pitch <pitch>
23
+ Then viscosity 1 prevents crossing maximum bounds during a mouse drag
24
+ And viscosities 0 and 0.5 allow progressively less overshoot
25
+ And maximum bounds are restored after drag inertia
26
+
27
+ Examples:
28
+ | enabled | bearing | pitch |
29
+ | false | 0 | 0 |
30
+ | true | 0 | 0 |
31
+ | true | 45 | 0 |
32
+ | true | 135 | 45 |
33
+ | true | 270 | 85 |