leaflet-rotate 0.5.2 → 0.5.4

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/lib/debug.js CHANGED
@@ -34,6 +34,9 @@
34
34
  .leaflet-rotate-info tr[title] {
35
35
  cursor: help;
36
36
  }
37
+ div.crosshair, div.pivot, div.pixelorigin, div.panebounds {
38
+ pointer-events: none;
39
+ }
37
40
  div.crosshair {
38
41
  position:absolute;
39
42
  width:1px;
package/lib/debug.min.js CHANGED
@@ -1,4 +1,4 @@
1
- (function(){const e=Object.isExtensible(globalThis.L)?globalThis.L:Object.assign({},globalThis.L);globalThis.L=e,e.Rotate={debug(t,o={}){this._map=t,document.head.insertAdjacentHTML("beforeend",'<style>.leaflet-rotate-tools{display:flex;flex-wrap:wrap;align-items:center;gap:4px 16px;margin-bottom:6px}.leaflet-rotate-tools input,.leaflet-rotate-tools button{vertical-align:middle}.leaflet-rotate-info{border-collapse:collapse}.leaflet-rotate-info td,.leaflet-rotate-info th{padding:1px 12px 1px 0;text-align:left;white-space:nowrap;vertical-align:top}.leaflet-rotate-info td:nth-of-type(1),.leaflet-rotate-info td:nth-of-type(2){font-family:monospace;min-width:12ch}.leaflet-rotate-info tr[title]{cursor:help}div.crosshair{position:absolute;width:1px;height:1px;border:1px solid green;z-index:100;left:50%;right:50%}div.pivot,div.pixelorigin{position:absolute;width:1px;height:1px;border:5px solid yellow;border-radius:100%;z-index:1000}div.pivot:before{left:-1.5em;bottom:-2em;position:absolute;content:"PIVOT";color:#ff0}div.pixelorigin:before{left:-1.75em;top:-2em;position:absolute;content:"ORIGIN";color:#ff0}div.panebounds.main{position:absolute;width:800px;height:600px;box-sizing:border-box;border:1px solid red;border-bottom:1px red dashed;border-right:1px red dashed;z-index:100}div.panebounds.rotate{position:absolute;width:800px;height:600px;box-sizing:border-box;border:1px solid purple;border-bottom:1px purple dashed;border-right:1px purple dashed;z-index:100}div.crosshair.c1{left:calc(50% - 5px);top:50%;height:0;width:10px}div.crosshair.c2{left:50%;top:calc(50% - 5px);width:0;height:10px}#rho_input{vertical-align:middle}.panebounds:before{padding:1ch}.panebounds.rotate:before{float:right;content:"ROTATED"}.panebounds.main:before{float:left;content:"MAIN"}</style>'),t._container.insertAdjacentHTML("afterbegin",'<div class="crosshair c1"></div><div class="crosshair c2"></div>');const i=`
1
+ (function(){const e=Object.isExtensible(globalThis.L)?globalThis.L:Object.assign({},globalThis.L);globalThis.L=e,e.Rotate={debug(t,o={}){this._map=t,document.head.insertAdjacentHTML("beforeend",'<style>.leaflet-rotate-tools{display:flex;flex-wrap:wrap;align-items:center;gap:4px 16px;margin-bottom:6px}.leaflet-rotate-tools input,.leaflet-rotate-tools button{vertical-align:middle}.leaflet-rotate-info{border-collapse:collapse}.leaflet-rotate-info td,.leaflet-rotate-info th{padding:1px 12px 1px 0;text-align:left;white-space:nowrap;vertical-align:top}.leaflet-rotate-info td:nth-of-type(1),.leaflet-rotate-info td:nth-of-type(2){font-family:monospace;min-width:12ch}.leaflet-rotate-info tr[title]{cursor:help}div.crosshair,div.pivot,div.pixelorigin,div.panebounds{pointer-events:none}div.crosshair{position:absolute;width:1px;height:1px;border:1px solid green;z-index:100;left:50%;right:50%}div.pivot,div.pixelorigin{position:absolute;width:1px;height:1px;border:5px solid yellow;border-radius:100%;z-index:1000}div.pivot:before{left:-1.5em;bottom:-2em;position:absolute;content:"PIVOT";color:#ff0}div.pixelorigin:before{left:-1.75em;top:-2em;position:absolute;content:"ORIGIN";color:#ff0}div.panebounds.main{position:absolute;width:800px;height:600px;box-sizing:border-box;border:1px solid red;border-bottom:1px red dashed;border-right:1px red dashed;z-index:100}div.panebounds.rotate{position:absolute;width:800px;height:600px;box-sizing:border-box;border:1px solid purple;border-bottom:1px purple dashed;border-right:1px purple dashed;z-index:100}div.crosshair.c1{left:calc(50% - 5px);top:50%;height:0;width:10px}div.crosshair.c2{left:50%;top:calc(50% - 5px);width:0;height:10px}#rho_input{vertical-align:middle}.panebounds:before{padding:1ch}.panebounds.rotate:before{float:right;content:"ROTATED"}.panebounds.main:before{float:left;content:"MAIN"}</style>'),t._container.insertAdjacentHTML("afterbegin",'<div class="crosshair c1"></div><div class="crosshair c2"></div>');const i=`
2
2
  <div class="leaflet-rotate-tools">
3
3
  <span>Bearing
4
4
  ${[0,15,30,45,60,75,90,180].map(n=>`<button onclick="L.Rotate._map.setBearing(${n});" title="map.setBearing(${n});">${n}</button>`).join("")}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leaflet-rotate",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
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",
@@ -1,6 +1,6 @@
1
1
  /**
2
- * This plugin does not modify the `L.Draggable` API; it adapts marker drag
3
- * coordinates in `L.Marker` when rotation or pitch is enabled.
2
+ * Preserve the final position when an active drag returns within its starting
3
+ * click tolerance. Marker geographic coordinates are adapted in `L.Marker`.
4
4
  *
5
5
  * @external L.Draggable
6
6
  *
@@ -15,11 +15,23 @@
15
15
  * @since leaflet-rotate (v0.1.0)
16
16
  */
17
17
 
18
- L.Draggable.include({
19
-
20
- /** @TODO */
21
- // updateMapBearing(mapBearing) {
22
- // this._mapBearing = mapBearing;
23
- // },
18
+ const draggableProto = Object.defineProperties({}, Object.getOwnPropertyDescriptors(L.Draggable.prototype));
24
19
 
20
+ L.Draggable.include({
21
+ _onMove(e) {
22
+ const pointer = e.touches ? e.touches[0] : e;
23
+ if (this._enabled && this._moving && (!e.touches || e.touches.length === 1)) {
24
+ const offset = new L.Point(pointer.clientX, pointer.clientY).subtract(this._startPoint);
25
+ if ((!offset.x && !offset.y) || Math.abs(offset.x) + Math.abs(offset.y) < this.options.clickTolerance) {
26
+ L.DomEvent.preventDefault(e);
27
+ this._newPos = this._startPos.add(new L.Point(
28
+ offset.x / this._parentScale.x, offset.y / this._parentScale.y
29
+ ));
30
+ this._lastEvent = e;
31
+ this._updatePosition();
32
+ return;
33
+ }
34
+ }
35
+ return draggableProto._onMove.apply(this, arguments);
36
+ },
25
37
  });
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
@@ -71,6 +71,20 @@ L.Map.mergeOptions({
71
71
 
72
72
  L.Map.include({
73
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
+
74
88
  /**
75
89
  * Apply an immediate animated-zoom frame without waiting for a transition delay.
76
90
  *
@@ -110,6 +124,7 @@ L.Map.include({
110
124
  this.on('move', this._updatePitchPan, this);
111
125
  this.on('moveend', this._endPitchPan, this);
112
126
  this.on('move', this._updatePitchSky, this);
127
+ this.on('rotate', this._panInsideMaxBounds, this);
113
128
  this.setBearing(this.options.bearing);
114
129
  this.setPitch(this.options.pitch);
115
130
  }
@@ -528,22 +543,34 @@ L.Map.include({
528
543
  * @param {L.Point|number[]} point Input pixel coordinate.
529
544
  * @param {DOMMatrix|Object} matrix Forward CSS matrix or inverse homography with
530
545
  * row-major coefficients `a` through `i`.
546
+ * @param {L.Point} [anchor] Keep inverse pan coordinates on this anchor's side of the horizon.
531
547
  *
532
548
  * @returns {L.Point} Transformed pixel coordinate.
533
549
  *
534
550
  * @since leaflet-rotate (0.5.0)
535
551
  */
536
- _transformPitchPoint(point, matrix) {
552
+ _transformPitchPoint(point, matrix, anchor) {
537
553
  point = toPoint(point);
538
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
+ }
539
563
  const denominator = matrix.g * point.x + matrix.h * point.y + matrix.i;
540
564
  return new L.Point(
541
565
  (matrix.a * point.x + matrix.b * point.y + matrix.c) / denominator,
542
566
  (matrix.d * point.x + matrix.e * point.y + matrix.f) / denominator
543
567
  );
544
568
  }
545
- const transformed = new DOMPoint(point.x, point.y).matrixTransform(matrix);
546
- 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
+ );
547
574
  },
548
575
 
549
576
  /**
@@ -576,7 +603,6 @@ L.Map.include({
576
603
  */
577
604
  _setPitchTransform(transform) {
578
605
  this._rotatePane.style.transformOrigin = '0 0';
579
- this._rotatePane.style.transform = transform;
580
606
  // parse the transform directly: getComputedStyle() would force a style recalculation on every frame
581
607
  const matrix = this._pitchTransform = new DOMMatrix(transform),
582
608
  a = matrix.m11, b = matrix.m21, c = matrix.m41,
@@ -584,6 +610,11 @@ L.Map.include({
584
610
  g = matrix.m14, h = matrix.m24, i = matrix.m44,
585
611
  determinant = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g);
586
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
+
587
618
  this._pitchInverseTransform = {
588
619
  a: (e * i - f * h) / determinant,
589
620
  b: (c * h - b * i) / determinant,
@@ -670,7 +701,7 @@ L.Map.include({
670
701
 
671
702
  /**
672
703
  * Fill the area above the far plane (where no tiles are loaded) with a sky gradient.
673
- * Style it through the `.leaflet-pitch-sky` class.
704
+ * Style it through `.leaflet-pitch-sky` or the `--leaflet-sky-background` CSS property.
674
705
  *
675
706
  * @since leaflet-rotate (0.5.1)
676
707
  */
@@ -684,7 +715,7 @@ L.Map.include({
684
715
  this._pitchSky = L.DomUtil.create('div', 'leaflet-pitch-sky', this._container);
685
716
  Object.assign(this._pitchSky.style, {
686
717
  position: 'absolute', left: 0, top: 0, width: '100%', zIndex: 0, pointerEvents: 'none',
687
- background: 'linear-gradient(#9ec5e8, #dfe9f0)',
718
+ background: 'var(--leaflet-sky-background, linear-gradient(#9ec5e8, #dfe9f0))',
688
719
  });
689
720
  }
690
721
  this._pitchSky.style.display = '';
@@ -856,6 +887,8 @@ L.Map.include({
856
887
 
857
888
  if (this._rotate) {
858
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';
859
892
  this._norotatePane = this.createPane('norotatePane', this._mapPane);
860
893
  if (L.version.startsWith('2.')) {
861
894
  this._rotatePane.style.transformOrigin = '0 0';
@@ -899,10 +932,11 @@ L.Map.include({
899
932
  * @since leaflet-rotate (v0.1.4)
900
933
  */
901
934
  panInside(latlng, options) {
902
- if (!this._rotate || (!this._pitch && Math.abs(this._bearing).toFixed(1) < 0.1)) {
935
+ if (!this._rotate) {
903
936
  return mapProto.panInside.apply(this, arguments);
904
937
  }
905
938
 
939
+ // The rotate pane can retain an offset after returning to bearing zero.
906
940
  options = options || {};
907
941
 
908
942
  const paddingTL = toPoint(options.paddingTopLeft || options.padding || [0, 0]),
@@ -934,54 +968,111 @@ L.Map.include({
934
968
  },
935
969
 
936
970
  /**
937
- * Pans the map to the closest view that would lie inside the given bounds
938
- * (if it's not already), controlling the animation using the options specific,
939
- * if any.
940
- *
941
- * @TODO check if map bounds calculated by `L.Map::panInsideBounds()`
942
- * function should match the `rotatePane` or `norotatePane` bounds
971
+ * Constrain the visible ground footprint, including its asymmetric pitched far side.
972
+ * When it is larger than maxBounds, Leaflet's rebound centers the excess on each axis.
973
+ */
974
+ _limitCenter(center, zoom, bounds) {
975
+ if (!this._rotate || !bounds) {
976
+ return mapProto._limitCenter.apply(this, arguments);
977
+ }
978
+ const centerPoint = this.project(center, zoom),
979
+ layerCenter = this.containerPointToLayerPoint(this.getSize().divideBy(2)),
980
+ footprint = this._getPaddedPixelBounds(0, false),
981
+ viewBounds = new L.Bounds(
982
+ centerPoint.add(footprint.min.subtract(layerCenter)),
983
+ centerPoint.add(footprint.max.subtract(layerCenter))
984
+ ),
985
+ offset = this._getBoundsOffset(viewBounds, bounds, zoom);
986
+ if (Math.abs(offset.x) <= 1 && Math.abs(offset.y) <= 1) { return center; }
987
+ return this.unproject(centerPoint.add(offset), zoom);
988
+ },
989
+
990
+ /**
991
+ * Reapply configured maximum bounds after a map transform.
943
992
  *
944
- * @see https://github.com/fnicollet/Leaflet/issues/7
945
- *
946
- * @param {L.LatLngBounds} bounds Geographic bounds to fit inside the viewport.
947
- * @param {Object} [options] Pan animation options.
948
- *
993
+ * @since leaflet-rotate (0.5.3)
994
+ */
995
+ _panInsideMaxBounds() {
996
+ if (this.options.maxBounds) {
997
+ return mapProto._panInsideMaxBounds.apply(this, arguments);
998
+ }
999
+ },
1000
+
1001
+ /**
1002
+ * Keep the visible ground footprint inside the given bounds.
1003
+ *
1004
+ * @param {L.LatLngBounds|Array} bounds Geographic bounds.
1005
+ * @param {Object} [options] Pan options passed to Leaflet.
949
1006
  * @returns {L.Map} This map instance.
950
- *
951
- * @since leaflet-rotate (v0.1.0)
1007
+ *
1008
+ * @since leaflet-rotate (0.5.3)
952
1009
  */
953
- // panInsideBounds(bounds, options) {
954
- // this._enforcingBounds = true;
955
- // const center = this.getCenter(),
956
- // newCenter = this._limitCenter(center, this._zoom, L.latLngBounds(bounds));
957
- //
958
- // if (!center.equals(newCenter)) {
959
- // this.panTo(newCenter, options);
960
- // }
961
- //
962
- // this._enforcingBounds = false;
963
- // return this;
964
- // },
1010
+ panInsideBounds(bounds, options) {
1011
+ if (this._rotate) {
1012
+ bounds = bounds instanceof L.LatLngBounds ? bounds : new L.LatLngBounds(bounds);
1013
+ const center = this.getCenter(),
1014
+ limited = this._limitCenter(center, this._zoom, bounds);
1015
+ const offset = this._getCenterOffset(limited);
1016
+ // Screen-space rounding cannot reliably apply subpixel pans at high pitch.
1017
+ if (!center.equals(limited) && Math.abs(offset.x) <= 1 && Math.abs(offset.y) <= 1) {
1018
+ this._enforcingBounds = true;
1019
+ this.setView(limited, this._zoom, { reset: true });
1020
+ this._enforcingBounds = false;
1021
+ return this;
1022
+ }
1023
+ }
1024
+ return mapProto.panInsideBounds.apply(this, arguments);
1025
+ },
965
1026
 
966
- // adjust center for view to get inside bounds
967
- // _limitCenter(center, zoom, bounds) {
968
- //
969
- // if (!bounds) { return center; }
970
- //
971
- // const centerPoint = this.project(center, zoom),
972
- // viewHalf = this.getSize().divideBy(2),
973
- // viewBounds = new Bounds(centerPoint.subtract(viewHalf), centerPoint.add(viewHalf)),
974
- // offset = this._getBoundsOffset(viewBounds, bounds, zoom);
975
- //
976
- // // If offset is less than a pixel, ignore.
977
- // // This prevents unstable projections from getting into
978
- // // an infinite loop of tiny offsets.
979
- // if (Math.abs(offset.x) <= 1 && Math.abs(offset.y) <= 1) {
980
- // return center;
981
- // }
982
- //
983
- // return this.unproject(centerPoint.add(offset), zoom);
984
- // },
1027
+ /**
1028
+ * Leaflet supplies inertia offsets in screen pixels, not projected CRS pixels.
1029
+ * Keep the drag's moving anchor when inertia continues a pitched pan.
1030
+ */
1031
+ _limitOffset(offset, bounds) {
1032
+ if (!this._rotate || !bounds) {
1033
+ return mapProto._limitOffset.apply(this, arguments);
1034
+ }
1035
+ const pan = this._pitchPan,
1036
+ anchor = pan ? pan.anchor.add(this._getMapPanePos().subtract(pan.panePos)) : this.getSize().divideBy(2),
1037
+ layerAnchor = this.containerPointToLayerPoint(anchor),
1038
+ panePos = this._getMapPanePos(),
1039
+ target = anchor.subtract(offset).subtract(panePos),
1040
+ shift = (this._pitch
1041
+ ? this._transformPitchPoint(target, this._pitchInverseTransform, anchor.subtract(panePos))
1042
+ : this.containerPointToLayerPoint(anchor.subtract(offset))).subtract(layerAnchor),
1043
+ footprint = this._getPaddedPixelBounds(0, false),
1044
+ origin = this.getPixelOrigin().subtract(shift),
1045
+ correction = this._getBoundsOffset(new L.Bounds(
1046
+ footprint.min.add(origin), footprint.max.add(origin)
1047
+ ), bounds);
1048
+ return anchor.subtract(this.layerPointToContainerPoint(layerAnchor.add(shift).subtract(correction)));
1049
+ },
1050
+
1051
+ /**
1052
+ * Pan by a screen-pixel offset, constraining large immediate pans to maxBounds.
1053
+ *
1054
+ * @param {L.Point|number[]} offset Pan offset in pixels.
1055
+ * @param {Object} [options] Pan options passed to Leaflet.
1056
+ * @returns {L.Map} This map instance.
1057
+ *
1058
+ * @since leaflet-rotate (0.5.3)
1059
+ */
1060
+ panBy(offset, options) {
1061
+ offset = toPoint(offset);
1062
+ if (this._rotate && this._loaded && !this.getSize().contains(offset) && !(options && options.animate)) {
1063
+ const anchor = this.getSize().divideBy(2),
1064
+ panePos = this._getMapPanePos(),
1065
+ layerAnchor = this.containerPointToLayerPoint(anchor),
1066
+ target = anchor.subtract(offset).subtract(panePos),
1067
+ shift = (this._pitch
1068
+ ? this._transformPitchPoint(target, this._pitchInverseTransform, anchor.subtract(panePos))
1069
+ : this.containerPointToLayerPoint(anchor.subtract(offset))).subtract(layerAnchor),
1070
+ center = this.unproject(this.project(this.getCenter()).subtract(shift));
1071
+ this._resetView(this._limitCenter(center, this._zoom, this.options.maxBounds), this._zoom);
1072
+ return this;
1073
+ }
1074
+ return mapProto.panBy.call(this, offset, options);
1075
+ },
985
1076
 
986
1077
  // @method flyToBounds(bounds: LatLngBounds, options?: fitBounds options): this
987
1078
  // Sets the view of the map with a smooth animation like [`flyTo`](#map-flyto),
@@ -1089,7 +1180,8 @@ L.Map.include({
1089
1180
  opposite = layerCenter.multiplyBy(2).subtract(this.project(latlng).subtract(this._pixelOrigin));
1090
1181
  return center.subtract(this.layerPointToContainerPoint(opposite));
1091
1182
  }
1092
- return this.latLngToContainerPoint(latlng)._subtract(this.getSize()._divideBy(2));
1183
+ return this.layerPointToContainerPoint(this.project(latlng).subtract(this._pixelOrigin))
1184
+ ._subtract(this.getSize()._divideBy(2));
1093
1185
  },
1094
1186
 
1095
1187
  /**
@@ -1125,9 +1217,9 @@ L.Map.include({
1125
1217
  }
1126
1218
  // the layer point under the view center doesn't depend on zoom (pane transforms only rotate/pitch around the pivot),
1127
1219
  // so this matches `_setBearing` also when the pivot is no longer the view center (eg. after panning)
1220
+ // Keep subpixel centers exact: wheel bursts magnify rounding from earlier zooms.
1128
1221
  return this.project(center, zoom)
1129
- ._subtract(this.containerPointToLayerPoint(this.getSize()._divideBy(2)))
1130
- ._round();
1222
+ ._subtract(this.containerPointToLayerPoint(this.getSize()._divideBy(2)));
1131
1223
  },
1132
1224
 
1133
1225
  /**
@@ -1188,23 +1280,25 @@ L.Map.include({
1188
1280
  * @see src\layer\vector\Renderer::_update()
1189
1281
  *
1190
1282
  * @param {number} padding Bounds padding as a fraction of map size.
1283
+ * @param {boolean} [round=true] Round renderer bounds; navigation needs exact ground coordinates.
1191
1284
  *
1192
1285
  * @returns {L.Bounds} Padded bounds in layer-point coordinates.
1193
1286
  *
1194
1287
  * @since leaflet-rotate (v0.2.0)
1195
1288
  */
1196
- _getPaddedPixelBounds(padding) {
1289
+ _getPaddedPixelBounds(padding, round = true) {
1197
1290
  if (!this._rotate && mapProto._getPaddedPixelBounds) {
1198
1291
  return mapProto._getPaddedPixelBounds.apply(this, arguments);
1199
1292
  }
1200
1293
  const [padMin, padMax] = this._getPitchedContainerBounds(padding);
1201
1294
 
1202
1295
  return new L.Bounds([
1203
- this.containerPointToLayerPoint([padMin.x, padMin.y]).floor(),
1204
- this.containerPointToLayerPoint([padMin.x, padMax.y]).floor(),
1205
- this.containerPointToLayerPoint([padMax.x, padMin.y]).floor(),
1206
- this.containerPointToLayerPoint([padMax.x, padMax.y]).floor()
1207
- ]);
1296
+ [padMin.x, padMin.y], [padMin.x, padMax.y],
1297
+ [padMax.x, padMin.y], [padMax.x, padMax.y]
1298
+ ].map(point => {
1299
+ const projected = this.containerPointToLayerPoint(point);
1300
+ return round ? projected.floor() : projected;
1301
+ }));
1208
1302
  },
1209
1303
 
1210
1304
  /**
@@ -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
+ });
@@ -51,6 +51,11 @@ Feature: Rotating a Leaflet map
51
51
  When I inspect debugger geometry after resizing and moving the map
52
52
  Then the debugger reports the current map geometry
53
53
 
54
+ @debugger @issue-76
55
+ Scenario: Debugger overlays do not intercept marker clicks
56
+ When I place the debugger overlays over a marker
57
+ Then the marker receives the pointer events
58
+
54
59
  @example-ui
55
60
  Scenario: Example base-layer radios select one layer
56
61
  When I switch between the example base layers
@@ -0,0 +1,84 @@
1
+ Feature: Interactive regression scenarios have executable BDD coverage
2
+ Background:
3
+ Given the interactive regression page is open
4
+
5
+ Scenario: Every interactive scenario has a BDD example
6
+ Then every interactive scenario is listed in the BDD examples
7
+
8
+ Scenario: An active drag can finish within its starting click tolerance
9
+ Then active drags return to their starting position without turning clicks into drags
10
+
11
+ Scenario Outline: Camera gestures retain their intended behavior
12
+ When I run the interactive scenario "<scenario>"
13
+ Then the interactive camera result is "<result>"
14
+
15
+ Examples:
16
+ | scenario | result |
17
+ | Ctrl+drag up: pitch from center | pitch up |
18
+ | Ctrl+drag down: pitch clamps at 0 | pitch zero |
19
+ | Ctrl+drag far up: pitch clamps at max | pitch max |
20
+ | Ctrl+drag right, just above center | clockwise |
21
+ | Ctrl+drag right, just below center | counterclockwise |
22
+ | Ctrl+drag right, far above center (angular) | clockwise |
23
+ | Ctrl+drag right, far below center (angular) | counterclockwise |
24
+ | Ctrl+drag diagonal, pitched + rotated | pitch and bearing |
25
+ | Ctrl+drag fast (2 big steps) | pitch and bearing |
26
+ | Ctrl+drag full circle around center | pitch and bearing |
27
+ | Ctrl+drag leaving the map | pitch and bearing |
28
+ | Ctrl+drag starting on a marker | pitch and bearing |
29
+ | Ctrl released mid-drag | pitch and bearing |
30
+ | Ctrl pressed mid-pan (should keep panning) | pan |
31
+ | Right-drag up + right | pitch and bearing |
32
+ | Ctrl+drag then plain pan | rotate then pan |
33
+ | Shift+wheel rotate | shift rotate |
34
+ | Wheel then Ctrl+drag while zoom pending | cancel zoom |
35
+ | Wheel during Ctrl+drag | zoom and rotate |
36
+
37
+ Scenario Outline: Wheel input zooms around its geographic anchor
38
+ When I run the interactive scenario "<scenario>"
39
+ Then the interactive camera result is "<result>"
40
+
41
+ Examples:
42
+ | scenario | result |
43
+ | Wheel in at top-right, flat (control case) | zoom in |
44
+ | Wheel in at top-right, rotated | zoom in |
45
+ | Wheel in at top-right, pitch 60 | zoom in |
46
+ | Wheel out at bottom-left, pitch 45 | zoom out |
47
+ | Wheel near horizon, pitch 75 | zoom in |
48
+ | Ctrl+wheel pinch-in burst (trackpad), pitch 50 | zoom in |
49
+ | Ctrl+wheel pinch-out burst (trackpad), rotated | zoom out |
50
+ | Ctrl+wheel mouse notches at center | zoom in |
51
+ | Low zoom rotated, wheel in (layer jump check) | zoom in |
52
+
53
+ Scenario Outline: Pitched drags retain their geographic anchor
54
+ When I run the interactive scenario "<scenario>"
55
+ Then the interactive camera result is "pan"
56
+
57
+ Examples:
58
+ | scenario |
59
+ | Pan pitched 70 |
60
+ | Pan pitched 70: vertical up |
61
+ | Pan pitched 70: vertical down |
62
+ | Pan pitched 70: horizontal left |
63
+ | Pan pitched 70: horizontal right |
64
+ | Pan pitched 70: diagonal up-right |
65
+ | Pan pitched 70: diagonal down-left |
66
+ | Pan pitched 70: reverse vertical drag |
67
+ | Pan pitched 70: multi-direction loop |
68
+ | Pan pitched 70, bearing 225: diagonal |
69
+
70
+ Scenario Outline: Navigation reaches its destination while pitched and rotated
71
+ When I run the interactive scenario "<scenario>"
72
+ Then the interactive camera result is "navigate"
73
+
74
+ Examples:
75
+ | scenario |
76
+ | Go to: Circle |
77
+ | Go to: Polygon |
78
+ | Go to: Path |
79
+ | Go to: Kyiv |
80
+ | Go to: London |
81
+ | Go to: San Francisco |
82
+ | Go to: Washington |
83
+ | Go to: Trondheim |
84
+ | Go to: Rectangle (fit from z 0) |