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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leaflet-rotate",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "description": "A Leaflet plugin that allows to add rotation functionality to map tiles",
5
5
  "type": "module",
6
6
  "main": "dist/leaflet-rotate.js",
package/src/index.js CHANGED
@@ -53,6 +53,7 @@ export * from './layer/vector/Renderer';
53
53
  export * from './map/Map';
54
54
  export * from './map/handler/CompassBearing';
55
55
  export * from './map/handler/ContainerMutation';
56
+ export * from './map/handler/Drag';
56
57
  export * from './map/handler/TouchGestures';
57
58
  export * from './map/handler/TouchRotate';
58
59
  export * from './map/handler/TouchPitch';
@@ -201,17 +201,7 @@ L.Marker.include({
201
201
  }
202
202
 
203
203
  /** @TODO use markerProto._setPos */
204
- let bearing = this.options.rotation || 0;
205
- if (this.options.rotateWithView) {
206
- bearing += this._map._bearing;
207
- }
208
- if (this.options.keepUpright) {
209
- bearing = ((bearing % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);
210
- if (bearing > Math.PI / 2 && bearing < 3 * Math.PI / 2) {
211
- bearing += Math.PI;
212
- }
213
- bearing %= 2 * Math.PI;
214
- }
204
+ const bearing = this._getIconBearing();
215
205
 
216
206
  /** @TODO use markerProto._setPos */
217
207
  if (this._icon) {
@@ -228,6 +218,29 @@ L.Marker.include({
228
218
  this._resetZIndex();
229
219
  },
230
220
 
221
+ /**
222
+ * Compute the on-screen icon rotation from `rotation`, `rotateWithView` and `keepUpright`.
223
+ *
224
+ * @returns {number} Angle in radians.
225
+ *
226
+ * @since leaflet-rotate (0.5.3)
227
+ */
228
+ _getIconBearing() {
229
+ let bearing = this.options.rotation || 0;
230
+ if (this.options.rotateWithView) {
231
+ bearing += this._map._bearing;
232
+ }
233
+ if (this.options.keepUpright) {
234
+ bearing = ((bearing % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);
235
+ if (bearing > Math.PI / 2 && bearing < 3 * Math.PI / 2) {
236
+ bearing += Math.PI;
237
+ }
238
+ bearing %= 2 * Math.PI;
239
+ }
240
+
241
+ return bearing;
242
+ },
243
+
231
244
  // _updateZIndex(offset) {
232
245
  // if (!this._map._rotate) {
233
246
  // return markerProto._updateZIndex.apply(this, arguments);
@@ -32,9 +32,24 @@ L.GridLayer.include({
32
32
  return events;
33
33
  },
34
34
 
35
+ /**
36
+ * Preserve the 3D context on tile containers without making individual tile levels 3D.
37
+ *
38
+ * @since leaflet-rotate (0.5.3)
39
+ */
40
+ _initContainer() {
41
+ gridLayerProto._initContainer.apply(this, arguments);
42
+ if (!this._map._rotate || !this._container) { return; }
43
+ // Tile levels stay flat: preserving 3D on them also clips images in Firefox.
44
+ for (let pane = this._container; pane && pane !== this._map._rotatePane; pane = pane.parentElement) {
45
+ pane.style.transformStyle = 'preserve-3d';
46
+ }
47
+ },
48
+
35
49
  /**
36
50
  * Keep tile levels aligned when a bearing/pitch change shifts the pixel origin
37
51
  * (eg. when the rotation pivot is not the view center), then refresh tiles (throttled).
52
+ * The first event also synchronizes levels created before any accumulated pans.
38
53
  *
39
54
  * @listens L.Map~rotate
40
55
  *
@@ -44,7 +59,7 @@ L.GridLayer.include({
44
59
  const map = this._map;
45
60
  if (!map) { return; }
46
61
  const origin = map.getPixelOrigin();
47
- if (this._rotateOrigin && !this._rotateOrigin.equals(origin) && !map._animatingZoom && this._levels) {
62
+ if ((!this._rotateOrigin || !this._rotateOrigin.equals(origin)) && !map._animatingZoom && this._levels) {
48
63
  this._setZoomTransforms(map.getCenter(), map.getZoom());
49
64
  }
50
65
  this._rotateOrigin = origin;
package/src/map/Map.js CHANGED
@@ -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';
@@ -934,54 +967,111 @@ L.Map.include({
934
967
  },
935
968
 
936
969
  /**
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
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
- * @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
- *
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 (v0.1.0)
1006
+ *
1007
+ * @since leaflet-rotate (0.5.3)
952
1008
  */
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
- // },
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
- // 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
- // },
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
- 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
- ]);
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
- polygon: { type: 'geojson', data: { type: 'Polygon', coordinates: [[...polygonLatLngs, polygonLatLngs[0]].map(([lat, lng]) => [lng, lat])] } },
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: 'polygon', type: 'fill', source: 'polygon', paint: { 'fill-color': '#3388ff', 'fill-opacity': 0.2 } },
171
- { id: 'polygon-line', type: 'line', source: 'polygon', paint: { 'line-color': '#3388ff', 'line-width': 3 } },
172
- { id: 'route', type: 'line', source: 'route', paint: { 'line-color': 'yellow', 'line-width': 3 } },
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
- for (const name in places) new maplibregl.Marker().setLngLat([places[name][1], places[name][0]]).setPopup(new maplibregl.Popup().setText(name)).addTo(ml);
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
  /**