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/README.md +2 -2
- package/build/server.js +19 -3
- package/dist/leaflet-rotate-src.js +313 -73
- package/dist/leaflet-rotate-src.js.map +4 -4
- package/dist/leaflet-rotate.js +1 -1
- package/dist/leaflet-rotate.js.map +4 -4
- package/package.json +1 -1
- package/src/index.js +1 -0
- package/src/layer/marker/Marker.js +24 -11
- package/src/layer/tile/GridLayer.js +16 -1
- package/src/map/Map.js +270 -61
- package/src/map/handler/Drag.js +59 -0
- package/test/features/navigation-bounds.feature +33 -0
- package/test/features/overlays.feature +9 -0
- package/test/features/pitch.feature +19 -0
- package/test/features/rotation.feature +5 -0
- package/test/index.html +312 -29
- package/test/steps/navigation-bounds.steps.js +195 -0
- package/test/steps/overlays.steps.js +44 -0
- package/test/steps/pitch.steps.js +264 -0
- package/test/steps/{bearing.steps.js → rotation.steps.js} +147 -19
- package/test/support/hooks.js +19 -7
package/package.json
CHANGED
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
529
|
-
return new L.Point(
|
|
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
|
|
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
|
-
*
|
|
836
|
-
*
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
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
|
-
* @
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
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 (
|
|
1006
|
+
*
|
|
1007
|
+
* @since leaflet-rotate (0.5.3)
|
|
850
1008
|
*/
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
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
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
this.containerPointToLayerPoint(
|
|
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 |
|