leaflet-rotate 0.5.2 → 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 +208 -68
- 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 +148 -56
- 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/index.html +70 -6
- 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/rotation.steps.js +28 -4
- 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
|
@@ -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
|
|
546
|
-
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
|
+
);
|
|
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
|
|
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';
|
|
@@ -934,54 +967,111 @@ L.Map.include({
|
|
|
934
967
|
},
|
|
935
968
|
|
|
936
969
|
/**
|
|
937
|
-
*
|
|
938
|
-
*
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
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.
|
|
943
991
|
*
|
|
944
|
-
* @
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
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.
|
|
949
1005
|
* @returns {L.Map} This map instance.
|
|
950
|
-
*
|
|
951
|
-
* @since leaflet-rotate (
|
|
1006
|
+
*
|
|
1007
|
+
* @since leaflet-rotate (0.5.3)
|
|
952
1008
|
*/
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
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
|
+
},
|
|
965
1025
|
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
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
|
+
},
|
|
985
1075
|
|
|
986
1076
|
// @method flyToBounds(bounds: LatLngBounds, options?: fitBounds options): this
|
|
987
1077
|
// Sets the view of the map with a smooth animation like [`flyTo`](#map-flyto),
|
|
@@ -1188,23 +1278,25 @@ L.Map.include({
|
|
|
1188
1278
|
* @see src\layer\vector\Renderer::_update()
|
|
1189
1279
|
*
|
|
1190
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.
|
|
1191
1282
|
*
|
|
1192
1283
|
* @returns {L.Bounds} Padded bounds in layer-point coordinates.
|
|
1193
1284
|
*
|
|
1194
1285
|
* @since leaflet-rotate (v0.2.0)
|
|
1195
1286
|
*/
|
|
1196
|
-
_getPaddedPixelBounds(padding) {
|
|
1287
|
+
_getPaddedPixelBounds(padding, round = true) {
|
|
1197
1288
|
if (!this._rotate && mapProto._getPaddedPixelBounds) {
|
|
1198
1289
|
return mapProto._getPaddedPixelBounds.apply(this, arguments);
|
|
1199
1290
|
}
|
|
1200
1291
|
const [padMin, padMax] = this._getPitchedContainerBounds(padding);
|
|
1201
1292
|
|
|
1202
1293
|
return new L.Bounds([
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
this.containerPointToLayerPoint(
|
|
1207
|
-
|
|
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
|
+
}));
|
|
1208
1300
|
},
|
|
1209
1301
|
|
|
1210
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 |
|
|
@@ -56,3 +56,12 @@ Feature: Rotating a Leaflet map
|
|
|
56
56
|
Scenario: Opening a popup in a second rotated map does not move it
|
|
57
57
|
When I open a popup in a second rotated map with map listeners
|
|
58
58
|
Then the second map center remains unchanged and its popup stays anchored
|
|
59
|
+
|
|
60
|
+
Scenario Outline: Markers keep Leaflet DOM rendering when the map prefers Canvas
|
|
61
|
+
When I add markers to a Canvas-preferred map at pitch <pitch>
|
|
62
|
+
Then the markers use Leaflet DOM icons while vector paths use Canvas
|
|
63
|
+
|
|
64
|
+
Examples:
|
|
65
|
+
| pitch |
|
|
66
|
+
| 0 |
|
|
67
|
+
| 45 |
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Feature: Pitched map rendering
|
|
2
|
+
Background:
|
|
3
|
+
Given a standalone pitched map is open
|
|
4
|
+
|
|
5
|
+
@issue-75
|
|
6
|
+
Scenario: Raster layers restore perspective after toggling and late attachment
|
|
7
|
+
Then raster perspective follows pitch on late and reattached layers
|
|
8
|
+
|
|
9
|
+
@issue-75
|
|
10
|
+
Scenario: Pitched raster surfaces and markers remain visible after camera changes and compass reset
|
|
11
|
+
Then pitched raster surfaces draw loaded tiles after rapid camera changes
|
|
12
|
+
|
|
13
|
+
@issue-75
|
|
14
|
+
Scenario: Raster tile levels follow the first camera change after accumulated pans
|
|
15
|
+
Then raster tiles remain aligned after panning before their first pitch change
|
|
16
|
+
|
|
17
|
+
@issue-75
|
|
18
|
+
Scenario: Resetting a pitched view after animated zoom restores the ground camera
|
|
19
|
+
Then pitched view resets preserve the ground perspective after accumulated pans
|
package/test/index.html
CHANGED
|
@@ -162,20 +162,84 @@
|
|
|
162
162
|
version: 8,
|
|
163
163
|
sources: {
|
|
164
164
|
esri: { type: 'raster', tiles: [tiles], tileSize: 256, maxzoom: 18 },
|
|
165
|
-
|
|
166
|
-
route: { type: 'geojson', data: { type: 'LineString', coordinates: route.map(([lat, lng]) => [lng, lat]) } },
|
|
165
|
+
overlays: { type: 'geojson', data: { type: 'FeatureCollection', features: [] } },
|
|
167
166
|
},
|
|
168
167
|
layers: [
|
|
169
168
|
{ id: 'esri', type: 'raster', source: 'esri' },
|
|
170
|
-
{ id: '
|
|
171
|
-
{ id: '
|
|
172
|
-
{ id: '
|
|
169
|
+
{ id: 'overlay-fills', type: 'fill', source: 'overlays', filter: ['==', ['geometry-type'], 'Polygon'], paint: { 'fill-color': ['get', 'fillColor'], 'fill-opacity': ['get', 'fillOpacity'] } },
|
|
170
|
+
{ id: 'overlay-lines', type: 'line', source: 'overlays', filter: ['!=', ['geometry-type'], 'Point'], paint: { 'line-color': ['get', 'color'], 'line-opacity': ['get', 'opacity'], 'line-width': ['get', 'weight'] } },
|
|
171
|
+
{ id: 'overlay-points', type: 'circle', source: 'overlays', filter: ['==', ['geometry-type'], 'Point'], paint: { 'circle-radius': ['get', 'radius'], 'circle-color': ['get', 'fillColor'], 'circle-opacity': ['get', 'fillOpacity'], 'circle-stroke-color': ['get', 'color'], 'circle-stroke-opacity': ['get', 'opacity'], 'circle-stroke-width': ['get', 'weight'] } },
|
|
173
172
|
],
|
|
174
173
|
},
|
|
175
174
|
}) : null;
|
|
176
175
|
if (ml) {
|
|
177
176
|
ml.addControl(new maplibregl.NavigationControl({ visualizePitch: true }));
|
|
178
|
-
|
|
177
|
+
const mirrors = new Map();
|
|
178
|
+
let mirrorFrame;
|
|
179
|
+
const syncOverlays = () => {
|
|
180
|
+
mirrorFrame = null;
|
|
181
|
+
if (!ml.getSource('overlays')) return;
|
|
182
|
+
const active = new Set(), features = [];
|
|
183
|
+
lf.eachLayer(layer => {
|
|
184
|
+
if (layer instanceof L.Marker) {
|
|
185
|
+
active.add(layer);
|
|
186
|
+
let marker = mirrors.get(layer);
|
|
187
|
+
if (!marker) {
|
|
188
|
+
const element = document.createElement('div');
|
|
189
|
+
element.style.width = element.style.height = '0px';
|
|
190
|
+
const icon = layer.options.icon.createIcon();
|
|
191
|
+
icon.style.position = 'absolute';
|
|
192
|
+
icon.style.transform = `scale(${layer.options.scale ?? 1})`;
|
|
193
|
+
element.appendChild(icon);
|
|
194
|
+
marker = new maplibregl.Marker({ element, rotationAlignment: 'viewport', pitchAlignment: 'viewport', draggable: Boolean(layer.dragging?.enabled()) });
|
|
195
|
+
marker.on('drag', () => {
|
|
196
|
+
const ll = marker.getLngLat();
|
|
197
|
+
layer.setLatLng([ll.lat, ll.lng]);
|
|
198
|
+
});
|
|
199
|
+
layer.on('move', scheduleOverlays);
|
|
200
|
+
const tooltip = layer.getTooltip();
|
|
201
|
+
if (tooltip) marker.setPopup(new maplibregl.Popup().setHTML(tooltip.getContent()));
|
|
202
|
+
mirrors.set(layer, marker);
|
|
203
|
+
marker.setLngLat([layer.getLatLng().lng, layer.getLatLng().lat]).addTo(ml);
|
|
204
|
+
}
|
|
205
|
+
const ll = layer.getLatLng();
|
|
206
|
+
marker.setLngLat([ll.lng, ll.lat]);
|
|
207
|
+
marker.setRotation(layer._getIconBearing() * 180 / Math.PI);
|
|
208
|
+
marker.getElement().style.opacity = layer.options.opacity;
|
|
209
|
+
} else if (layer instanceof L.Path && !layer._marker) {
|
|
210
|
+
let feature = layer.toGeoJSON();
|
|
211
|
+
if (layer instanceof L.Circle) {
|
|
212
|
+
const center = layer.getLatLng(), angular = layer.getRadius() / 6371000,
|
|
213
|
+
lat = center.lat * Math.PI / 180, lng = center.lng * Math.PI / 180;
|
|
214
|
+
const ring = Array.from({ length: 65 }, (_, i) => {
|
|
215
|
+
const angle = i * 2 * Math.PI / 64;
|
|
216
|
+
const y = Math.asin(Math.sin(lat) * Math.cos(angular) + Math.cos(lat) * Math.sin(angular) * Math.cos(angle));
|
|
217
|
+
const x = lng + Math.atan2(Math.sin(angle) * Math.sin(angular) * Math.cos(lat), Math.cos(angular) - Math.sin(lat) * Math.sin(y));
|
|
218
|
+
return [x * 180 / Math.PI, y * 180 / Math.PI];
|
|
219
|
+
});
|
|
220
|
+
feature = { type: 'Feature', geometry: { type: 'Polygon', coordinates: [ring] } };
|
|
221
|
+
}
|
|
222
|
+
const o = layer.options;
|
|
223
|
+
feature.properties = {
|
|
224
|
+
color: o.color, fillColor: o.fillColor || o.color,
|
|
225
|
+
opacity: o.stroke ? o.opacity : 0, fillOpacity: o.fill ? o.fillOpacity : 0,
|
|
226
|
+
weight: o.weight || 0, radius: layer instanceof L.CircleMarker ? layer.getRadius() : 0,
|
|
227
|
+
};
|
|
228
|
+
features.push(feature);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
for (const [layer, marker] of mirrors) {
|
|
232
|
+
if (!active.has(layer)) {
|
|
233
|
+
layer.off('move', scheduleOverlays);
|
|
234
|
+
marker.remove();
|
|
235
|
+
mirrors.delete(layer);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
ml.getSource('overlays').setData({ type: 'FeatureCollection', features });
|
|
239
|
+
};
|
|
240
|
+
const scheduleOverlays = () => { mirrorFrame ??= requestAnimationFrame(syncOverlays); };
|
|
241
|
+
lf.on('layeradd layerremove move zoom rotate pitch', scheduleOverlays);
|
|
242
|
+
ml.on('load', syncOverlays);
|
|
179
243
|
}
|
|
180
244
|
|
|
181
245
|
/**
|