leaflet-rotate 0.3.0 → 0.5.0

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.
Files changed (42) hide show
  1. package/README.md +74 -5
  2. package/build/test.js +18 -0
  3. package/dist/leaflet-rotate-src.js +1081 -303
  4. package/dist/leaflet-rotate-src.js.map +3 -3
  5. package/dist/leaflet-rotate.js +1 -1
  6. package/dist/leaflet-rotate.js.map +3 -3
  7. package/lib/debug.js +84 -66
  8. package/lib/debug.min.js +1 -1
  9. package/misc/leaflet-heat-rotate.js +14 -7
  10. package/misc/loremIpsum.js +1 -1
  11. package/misc/places.js +1 -1
  12. package/misc/route.js +1 -1
  13. package/package.json +5 -5
  14. package/src/control/Rotate.js +98 -29
  15. package/src/dom/DomUtil.js +42 -47
  16. package/src/dom/Draggable.js +6 -1
  17. package/src/geo/LatLngBounds.js +5 -0
  18. package/src/geometry/Bounds.js +5 -0
  19. package/src/geometry/Point.js +33 -10
  20. package/src/index.js +3 -1
  21. package/src/layer/DivOverlay.js +43 -12
  22. package/src/layer/Popup.js +29 -16
  23. package/src/layer/Tooltip.js +21 -5
  24. package/src/layer/marker/Icon.js +20 -8
  25. package/src/layer/marker/Marker.js +103 -24
  26. package/src/layer/tile/GridLayer.js +22 -6
  27. package/src/layer/vector/Renderer.js +102 -11
  28. package/src/map/Map.js +408 -121
  29. package/src/map/handler/CompassBearing.js +33 -7
  30. package/src/map/handler/ContainerMutation.js +20 -3
  31. package/src/map/handler/ShiftKeyRotate.js +156 -16
  32. package/src/map/handler/TouchGestures.js +91 -29
  33. package/src/map/handler/TouchRotate.js +20 -3
  34. package/src/map/handler/TouchZoom.js +25 -5
  35. package/test/features/example.feature +5 -0
  36. package/test/features/rotation.feature +30 -0
  37. package/test/index.html +60 -22
  38. package/test/steps/bearing.steps.js +345 -3
  39. package/test/steps/example.steps.js +72 -11
  40. package/test/steps/gestures.steps.js +4 -4
  41. package/test/steps/overlays.steps.js +32 -32
  42. package/test/support/hooks.js +38 -2
@@ -21,6 +21,36 @@ Feature: Rotating a Leaflet map
21
21
  When I animate the bearing to 90 degrees
22
22
  Then the bearing reaches 90 degrees gradually
23
23
 
24
+ @issue-72
25
+ Scenario: Pitch projects and reverses map coordinates
26
+ When I set the map pitch to 45 degrees
27
+ Then map coordinates stay consistent with the pitched view
28
+
29
+ @issue-72
30
+ Scenario: Ctrl-drag changes pitch and bearing without panning
31
+ When I change pitch and bearing with Ctrl-drag
32
+ Then pitch and bearing change without moving the map pane
33
+
34
+ @issue-72
35
+ Scenario: Pitched vector layers stay aligned while panning
36
+ When I drag the pitched map
37
+ Then vector layers stay aligned with their map coordinates
38
+
39
+ @issue-72
40
+ Scenario: Vector layers stay aligned after off-center zoom and rotation
41
+ When I zoom off-center then rotate the map
42
+ Then vector layers stay aligned with their map coordinates
43
+
44
+ @issue-72
45
+ Scenario: Ctrl-drag after panning keeps the map center stable
46
+ When I pan the map then Ctrl-drag
47
+ Then the map center stays stable during Ctrl-drag
48
+
49
+ @issue-72
50
+ Scenario: Ctrl-drag cancels a pending wheel zoom
51
+ When I Ctrl-drag while a wheel zoom is pending
52
+ Then the pending wheel zoom does not move the map
53
+
24
54
  @issue-25
25
55
  Scenario: Fit bounds zoom is stable
26
56
  When I fit the same bounds twice
package/test/index.html CHANGED
@@ -6,18 +6,12 @@
6
6
  <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
7
7
  <meta name="viewport" content="initial-scale=1.0, user-scalable=no" />
8
8
 
9
- <!-- Leaflet JS / CSS -->
10
- <script src="https://unpkg.com/leaflet@1.9/dist/leaflet-src.js"></script>
11
- <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9/dist/leaflet.css">
12
-
13
- <!-- Leaflet-Rotate -->
14
- <script src="../dist/leaflet-rotate-src.js"></script>
9
+ <link id="leaflet-css" rel="stylesheet">
15
10
 
16
11
  <!-- include some miscellaneous -->
17
12
  <script src="../misc/route.js"></script>
18
13
  <script src="../misc/places.js"></script>
19
14
  <script src="../misc/loremIpsum.js"></script>
20
- <script src="../lib/debug.js"></script>
21
15
 
22
16
  <style>
23
17
  #map {
@@ -36,6 +30,12 @@
36
30
 
37
31
  <body>
38
32
 
33
+ <label for="leaflet-version">Leaflet version:</label>
34
+ <select id="leaflet-version">
35
+ <option value="1">1.9.4</option>
36
+ <option value="2">2.0.0-alpha.1</option>
37
+ </select>
38
+
39
39
  <div id="map"></div>
40
40
 
41
41
  <hr>
@@ -61,8 +61,38 @@
61
61
  </p>
62
62
  </div>
63
63
 
64
- <script>
65
- var esri = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
64
+ <script type="module">
65
+ const versionSelect = document.getElementById('leaflet-version');
66
+ const params = new URLSearchParams(location.search);
67
+ versionSelect.value = params.get('leaflet') === '2' ? '2' : '1';
68
+ versionSelect.addEventListener('change', () => {
69
+ location.search = `?leaflet=${versionSelect.value}`;
70
+ });
71
+
72
+ const loadScript = src => new Promise((resolve, reject) => {
73
+ const script = document.createElement('script');
74
+ script.src = src;
75
+ script.onload = resolve;
76
+ script.onerror = () => reject(new Error(`Could not load ${src}`));
77
+ document.head.appendChild(script);
78
+ });
79
+
80
+ const version = versionSelect.value;
81
+ document.getElementById('leaflet-css').href = version === '2'
82
+ ? 'https://unpkg.com/leaflet@2.0.0-alpha.1/dist/leaflet.css'
83
+ : 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
84
+
85
+ if (version === '2') {
86
+ await loadScript('https://unpkg.com/leaflet@2.0.0-alpha.1/dist/leaflet-global-src.js');
87
+ globalThis.L = globalThis.leaflet;
88
+ } else {
89
+ await loadScript('https://unpkg.com/leaflet@1.9.4/dist/leaflet-src.js');
90
+ }
91
+ await loadScript('../dist/leaflet-rotate-src.js');
92
+ await loadScript('../lib/debug.js');
93
+ const L = globalThis.L;
94
+
95
+ const esri = new L.TileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
66
96
  id: 'mapbox.streets',
67
97
  maxZoom: 24,
68
98
  maxNativeZoom: 18,
@@ -70,14 +100,14 @@
70
100
  // noWrap: true
71
101
  });
72
102
 
73
- var osm = L.tileLayer('http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
103
+ const osm = new L.TileLayer('http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
74
104
  maxZoom: 24,
75
105
  maxNativeZoom: 19,
76
106
  attribution: '&copy; <a href="http://openstreetmap.org/copyright">OpenStreetMap</a> contributors',
77
107
  // noWrap: true
78
108
  });
79
109
 
80
- var map = L.map('map', {
110
+ const map = new L.Map('map', {
81
111
  center: [55, 10],
82
112
  zoom: 2,
83
113
  layers: [esri],
@@ -99,6 +129,9 @@
99
129
  touchRotate: true,
100
130
  // touchZoom: true
101
131
  });
132
+ window.map = map;
133
+ window.esri = esri;
134
+ window.osm = osm;
102
135
 
103
136
  // map.setBearing(90);
104
137
  // map.touchRotate.enable();
@@ -111,17 +144,18 @@
111
144
  // map.rotateControl.setPosition('bottomleft');
112
145
  // map.setMaxBounds([[-90,-180], [90,180]]);
113
146
 
114
- var layers = L.control.layers({
115
- 'Empty': L.tileLayer(''),
147
+ const layers = new L.Control.Layers({
148
+ 'Empty': new L.TileLayer(''),
116
149
  'Streets': osm,
117
150
  'Satellite': esri,
118
151
  }, null, {
119
152
  collapsed: false
120
153
  }).addTo(map);
154
+ window.layers = layers;
121
155
 
122
- var markers = [];
123
- for (var i in places) {
124
- markers.push(L.marker(places[i], {
156
+ const markers = [];
157
+ for (const i in places) {
158
+ markers.push(new L.Marker(places[i], {
125
159
  draggable: true
126
160
  })
127
161
  .bindPopup('<b>' + i + '</b><br>' + loremIpsum)
@@ -133,30 +167,34 @@
133
167
  )
134
168
  .addTo(map));
135
169
  }
170
+ window.markers = markers;
136
171
 
137
- var path = L.polyline(route, {
138
- renderer: L.canvas()
172
+ const path = new L.Polyline(route, {
173
+ renderer: new L.Canvas()
139
174
  }).addTo(map);
175
+ window.path = path;
140
176
 
141
- var circle = L.circle([53, 4], 111111)
177
+ const circle = new L.Circle([53, 4], 111111)
142
178
  .addTo(map);
179
+ window.circle = circle;
143
180
 
144
- var polygon = L.polygon([
181
+ const polygon = new L.Polygon([
145
182
  [48, -3],
146
183
  [50, -4],
147
184
  [52, 4]
148
185
  ]).addTo(map);
186
+ window.polygon = polygon;
149
187
 
150
- const rectangle = L.rectangle([
188
+ const rectangle = new L.Rectangle([
151
189
  [60.81123, 25.71632],
152
190
  [60.81028, 25.73166],
153
191
  [60.7997, 25.72892],
154
192
  [60.80061, 25.71362]
155
193
  ]).addTo(map);
194
+ window.rectangle = rectangle;
156
195
 
157
196
  // Display some debug info
158
197
  L.Rotate.debug(map);
159
-
160
198
  </script>
161
199
 
162
200
  <a href="https://github.com/Raruto/leaflet-rotate" class="view-on-github" style="position: fixed;bottom: 10px;left: calc(50% - 60px);z-index: 9999;"> <img alt="View on Github" src="https://raruto.github.io/img/view-on-github.png" title="View on Github"
@@ -71,10 +71,352 @@ Then('the bearing reaches 90 degrees gradually', function () {
71
71
  assert.ok(Math.abs(this.animatedBearings.afterUnavailableHeading - 15) < 1e-6);
72
72
  });
73
73
 
74
+ When('I set the map pitch to 45 degrees', async function () {
75
+ this.pitchProjection = await this.page.evaluate(() => {
76
+ const location = new L.LatLng(54, 12);
77
+ map.setView(new L.LatLng(55, 10), 5, { animate: false });
78
+ const flat = map.latLngToContainerPoint(location);
79
+ map.setPitch(90);
80
+ const maxPitch = map.getPitch();
81
+ map.setPitch(45);
82
+ const layerPoint = map.latLngToLayerPoint(location),
83
+ pitched = map.layerPointToContainerPoint(layerPoint),
84
+ roundTripPoint = map.containerPointToLayerPoint(pitched),
85
+ centerPoint = map.latLngToContainerPoint(map.getCenter()),
86
+ size = map.getSize();
87
+
88
+ return {
89
+ pitch: map.getPitch(),
90
+ maxPitch,
91
+ bearing: map.getBearing(),
92
+ flat,
93
+ pitched,
94
+ layerPoint,
95
+ roundTripPoint,
96
+ centerPoint,
97
+ size,
98
+ transform: map._rotatePane.style.transform,
99
+ };
100
+ });
101
+ });
102
+
103
+ Then('map coordinates stay consistent with the pitched view', function () {
104
+ const { pitch, maxPitch, bearing, flat, pitched, layerPoint, roundTripPoint, centerPoint, size, transform } = this.pitchProjection;
105
+ assert.equal(pitch, 45);
106
+ assert.equal(maxPitch, 85);
107
+ assert.ok(Math.abs(bearing - 30) < 1e-9);
108
+ assert.match(transform, /rotateX\(45deg\)/);
109
+ assert.ok(Math.abs(Number(transform.match(/perspective\(([\d.]+)px\)/)[1]) - size.y * 1.5) < 1);
110
+ assert.ok(Math.abs(flat.y - pitched.y) > 0.1);
111
+ const details = JSON.stringify({ flat, pitched, layerPoint, roundTripPoint, centerPoint, size });
112
+ assert.ok(Math.abs(roundTripPoint.x - layerPoint.x) < 1e-6, details);
113
+ assert.ok(Math.abs(roundTripPoint.y - layerPoint.y) < 1e-6, details);
114
+ assert.ok(Math.abs(centerPoint.x - size.x / 2) < 1, details);
115
+ assert.ok(Math.abs(centerPoint.y - size.y / 2) < 1, details);
116
+ });
117
+
118
+ When('I change pitch and bearing with Ctrl-drag', async function () {
119
+ await this.page.evaluate(() => {
120
+ map.setPitch(0);
121
+ map.setBearing(0);
122
+ });
123
+ const bounds = await this.page.locator('#map').boundingBox();
124
+ const start = { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 },
125
+ panePos = await this.page.evaluate(() => {
126
+ const pos = map._getMapPanePos();
127
+ return [pos.x, pos.y];
128
+ });
129
+ await this.page.keyboard.down('Control');
130
+ await this.page.mouse.move(start.x, start.y);
131
+ await this.page.mouse.down();
132
+ await this.page.mouse.move(start.x + 80, start.y - 80, { steps: 4 });
133
+ await this.page.mouse.up();
134
+ await this.page.keyboard.up('Control');
135
+ this.ctrlPitchDrag = await this.page.evaluate(() => {
136
+ const pane = map._getMapPanePos();
137
+ return {
138
+ pitch: map.getPitch(),
139
+ bearing: map.getBearing(),
140
+ finalPanePos: [pane.x, pane.y],
141
+ };
142
+ });
143
+ this.ctrlPitchDrag.panePos = panePos;
144
+ });
145
+
146
+ Then('pitch and bearing change without moving the map pane', function () {
147
+ assert.equal(this.ctrlPitchDrag.pitch, 40);
148
+ assert.equal(this.ctrlPitchDrag.bearing, 64);
149
+ for (let i = 0; i < 2; i++) {
150
+ assert.ok(Math.abs(this.ctrlPitchDrag.panePos[i] - this.ctrlPitchDrag.finalPanePos[i]) < 0.01);
151
+ }
152
+ });
153
+
154
+ When('I drag the pitched map', async function () {
155
+ const dragStart = await this.page.evaluate(() => {
156
+ map.setBearing(30);
157
+ map.setPitch(45);
158
+ map.setView(new L.LatLng(55, 10), 5, { animate: false });
159
+ const location = new L.LatLng(55, 10),
160
+ marker = new L.CircleMarker(location, { radius: 8 }).addTo(map),
161
+ pin = new L.Marker(location, {
162
+ icon: new L.DivIcon({ className: '', html: 'x', iconSize: [4, 4], iconAnchor: [2, 2] }),
163
+ }).addTo(map),
164
+ element = marker.getElement(),
165
+ pinElement = pin.getElement(),
166
+ container = map.getContainer(),
167
+ rect = container.getBoundingClientRect(),
168
+ start = [rect.left + rect.width / 2, rect.top + rect.height / 2];
169
+ const vectorScreenPoint = () => {
170
+ const bounds = element.getBBox();
171
+ const point = new DOMPoint(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2)
172
+ .matrixTransform(element.getScreenCTM());
173
+ return [point.x, point.y];
174
+ };
175
+ const before = vectorScreenPoint(),
176
+ pinBoundsBefore = pinElement.getBoundingClientRect(),
177
+ pinBefore = [pinBoundsBefore.left + pinBoundsBefore.width / 2, pinBoundsBefore.top + pinBoundsBefore.height / 2];
178
+ window.__pitchPanTest = { element, pinElement, vectorScreenPoint, before, pinBefore, rect };
179
+ return start;
180
+ });
181
+ await this.page.mouse.move(dragStart[0], dragStart[1]);
182
+ await this.page.mouse.down();
183
+ await this.page.mouse.move(dragStart[0] + 70, dragStart[1] + 35, { steps: 4 });
184
+ await this.page.mouse.up();
185
+ this.pitchedDrag = await this.page.evaluate(() => {
186
+ const { pinElement, vectorScreenPoint, before, pinBefore } = window.__pitchPanTest,
187
+ after = vectorScreenPoint(),
188
+ pinBoundsAfter = pinElement.getBoundingClientRect(),
189
+ pinAfter = [pinBoundsAfter.left + pinBoundsAfter.width / 2, pinBoundsAfter.top + pinBoundsAfter.height / 2];
190
+ delete window.__pitchPanTest;
191
+ return {
192
+ vectorDelta: [after[0] - before[0], after[1] - before[1]],
193
+ pinDelta: [pinAfter[0] - pinBefore[0], pinAfter[1] - pinBefore[1]],
194
+ expected: map.latLngToContainerPoint(new L.LatLng(55, 10)),
195
+ pointerDelta: [70, 35],
196
+ };
197
+ });
198
+ });
199
+
200
+ Then('vector layers stay aligned with their map coordinates', function () {
201
+ if (this.vectorAlignment) {
202
+ assert.ok(this.zoomAfterOffCenter > 5, 'Off-center wheel zoom did not run');
203
+ assert.ok(
204
+ this.pitchTransformError < 0.1,
205
+ `Pitch changed the projected map pane by ${this.pitchTransformError}px`
206
+ );
207
+ const centerDistance = Math.hypot(
208
+ this.centerBeforeCtrlDrag[0] - this.centerAfterCtrlDrag[0],
209
+ this.centerBeforeCtrlDrag[1] - this.centerAfterCtrlDrag[1]
210
+ );
211
+ assert.ok(centerDistance < 0.01, `Map center moved ${centerDistance} projected pixels`);
212
+ for (const layer of this.vectorAlignment) {
213
+ const error = Math.hypot(layer.actual[0] - layer.expected[0], layer.actual[1] - layer.expected[1]);
214
+ assert.ok(error < 2, `${layer.name} is ${error}px from its geographic position: ${JSON.stringify(layer)}`);
215
+ }
216
+ return;
217
+ }
218
+ const { vectorDelta, pinDelta, pointerDelta } = this.pitchedDrag;
219
+ for (const delta of [vectorDelta, pinDelta]) {
220
+ assert.ok(Math.abs(delta[0] - pointerDelta[0]) < 2, JSON.stringify(this.pitchedDrag));
221
+ assert.ok(Math.abs(delta[1] - pointerDelta[1]) < 2, JSON.stringify(this.pitchedDrag));
222
+ }
223
+ });
224
+
225
+ When('I zoom off-center then rotate the map', async function () {
226
+ await this.page.evaluate(() => {
227
+ map.setView(new L.LatLng(55, 10), 5, { animate: false });
228
+ map.setBearing(30);
229
+ map.setPitch(0);
230
+ });
231
+ const mapBounds = await this.page.locator('#map').boundingBox(),
232
+ zoomPoint = { x: mapBounds.x + mapBounds.width * 0.9, y: mapBounds.y + mapBounds.height * 0.1 };
233
+ await this.page.mouse.move(zoomPoint.x, zoomPoint.y);
234
+ await this.page.mouse.wheel(0, -480);
235
+ await this.page.waitForTimeout(500);
236
+
237
+ const positions = await this.page.evaluate(() => {
238
+ const location = map.containerPointToLatLng([600, 170]),
239
+ marker = new L.Marker(location, {
240
+ icon: new L.DivIcon({ className: '', html: 'x', iconSize: [8, 8], iconAnchor: [4, 4] }),
241
+ }).addTo(map),
242
+ circle = new L.CircleMarker(location, { radius: 5 }).addTo(map),
243
+ measure = () => {
244
+ const expected = map.latLngToContainerPoint(location),
245
+ container = map.getContainer().getBoundingClientRect(),
246
+ vectorBounds = circle.getElement().getBoundingClientRect(),
247
+ markerBounds = marker.getElement().getBoundingClientRect();
248
+ return [
249
+ {
250
+ name: 'vector',
251
+ expected: [expected.x, expected.y],
252
+ actual: [vectorBounds.left + vectorBounds.width / 2 - container.left, vectorBounds.top + vectorBounds.height / 2 - container.top],
253
+ },
254
+ {
255
+ name: 'marker',
256
+ expected: [expected.x, expected.y],
257
+ actual: [markerBounds.left + markerBounds.width / 2 - container.left, markerBounds.top + markerBounds.height / 2 - container.top],
258
+ },
259
+ ];
260
+ };
261
+
262
+ const positions = [measure()];
263
+ const pane = map._rotatePane,
264
+ pivot = map._getPixelCenter(),
265
+ size = map.getSize(),
266
+ beforeTransform = new DOMMatrix(getComputedStyle(pane).transform),
267
+ testPoints = [[0, 0], [size.x, 0], [0, size.y], [size.x, size.y]]
268
+ .map(([x, y]) => ({
269
+ x,
270
+ y,
271
+ point: new DOMPoint(x, y).matrixTransform(beforeTransform),
272
+ }));
273
+ map.setPitch(1);
274
+ const afterTransform = new DOMMatrix(getComputedStyle(pane).transform),
275
+ pitch = Math.PI / 180,
276
+ perspective = size.y * 1.5;
277
+ const pitchTransformError = Math.max(...testPoints.map(({ x: pointX, y: pointY, point }) => {
278
+ const x = point.x / point.w - pivot.x,
279
+ y = point.y / point.w - pivot.y,
280
+ factor = perspective / (perspective - y * Math.sin(pitch)),
281
+ expectedX = pivot.x + x * factor,
282
+ expectedY = pivot.y + y * Math.cos(pitch) * factor,
283
+ actual = new DOMPoint(pointX, pointY).matrixTransform(afterTransform);
284
+ return Math.hypot(actual.x / actual.w - expectedX, actual.y / actual.w - expectedY);
285
+ }));
286
+ positions.push(measure());
287
+ map.setBearing(50);
288
+ positions.push(measure());
289
+ map.setPitch(35);
290
+ positions.push(measure());
291
+ map.panBy([50, 25], { animate: false });
292
+ positions.push(measure());
293
+ window.__rotateAlignment = { marker, circle, measure };
294
+ const center = map.project(map.getCenter());
295
+ return {
296
+ positions: positions.flatMap((position, index) => position.map(layer => ({ ...layer, step: index }))),
297
+ centerBeforeCtrlDrag: [center.x, center.y],
298
+ zoomAfterOffCenter: map.getZoom(),
299
+ pitchTransformError,
300
+ };
301
+ });
302
+ const bounds = await this.page.locator('#map').boundingBox(),
303
+ start = { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 };
304
+ await this.page.keyboard.down('Control');
305
+ await this.page.mouse.move(start.x, start.y);
306
+ await this.page.mouse.down();
307
+ await this.page.mouse.move(start.x + 24, start.y - 18, { steps: 3 });
308
+ await this.page.mouse.up();
309
+ await this.page.keyboard.up('Control');
310
+ const afterCtrlDrag = await this.page.evaluate(() => {
311
+ const positions = window.__rotateAlignment.measure();
312
+ const center = map.project(map.getCenter());
313
+ window.__rotateAlignment.marker.remove();
314
+ window.__rotateAlignment.circle.remove();
315
+ delete window.__rotateAlignment;
316
+ return {
317
+ positions: positions.map(layer => ({ ...layer, step: 'Ctrl-drag' })),
318
+ center: [center.x, center.y],
319
+ };
320
+ });
321
+ this.vectorAlignment = [...positions.positions, ...afterCtrlDrag.positions];
322
+ this.centerBeforeCtrlDrag = positions.centerBeforeCtrlDrag;
323
+ this.centerAfterCtrlDrag = afterCtrlDrag.center;
324
+ this.zoomAfterOffCenter = positions.zoomAfterOffCenter;
325
+ this.pitchTransformError = positions.pitchTransformError;
326
+ });
327
+
328
+ When('I pan the map then Ctrl-drag', async function () {
329
+ await this.page.evaluate(() => {
330
+ map.setView(new L.LatLng(55, 10), 5, { animate: false });
331
+ map.setBearing(30);
332
+ map.setPitch(0);
333
+ });
334
+ const bounds = await this.page.locator('#map').boundingBox();
335
+ const x = bounds.x + bounds.width / 2,
336
+ y = bounds.y + bounds.height / 2;
337
+ await this.page.mouse.move(x, y);
338
+ await this.page.mouse.down();
339
+ await this.page.mouse.move(x + 70, y + 35, { steps: 4 });
340
+ await this.page.mouse.up();
341
+ await this.page.waitForTimeout(400);
342
+ this.centerBeforeCtrlDrag = await this.page.evaluate(() => {
343
+ const center = map.project(map.getCenter()),
344
+ pane = map._getMapPanePos();
345
+ return { point: [center.x, center.y], pane: [pane.x, pane.y] };
346
+ });
347
+
348
+ await this.page.keyboard.down('Control');
349
+ await this.page.mouse.move(x + 70, y + 35);
350
+ await this.page.mouse.down();
351
+ await this.page.mouse.move(x + 90, y + 20, { steps: 3 });
352
+ await this.page.mouse.up();
353
+ await this.page.keyboard.up('Control');
354
+ this.centerAfterCtrlDrag = await this.page.evaluate(() => {
355
+ const center = map.project(map.getCenter()),
356
+ pane = map._getMapPanePos();
357
+ return {
358
+ point: [center.x, center.y],
359
+ pane: [pane.x, pane.y],
360
+ pitch: map.getPitch(),
361
+ bearing: map.getBearing(),
362
+ };
363
+ });
364
+ });
365
+
366
+ Then('the map center stays stable during Ctrl-drag', function () {
367
+ const distance = Math.hypot(
368
+ this.centerBeforeCtrlDrag.point[0] - this.centerAfterCtrlDrag.point[0],
369
+ this.centerBeforeCtrlDrag.point[1] - this.centerAfterCtrlDrag.point[1]
370
+ );
371
+ assert.ok(distance < 0.01, `Map center moved ${distance} projected pixels: ${JSON.stringify({
372
+ before: this.centerBeforeCtrlDrag,
373
+ after: this.centerAfterCtrlDrag,
374
+ })}`);
375
+ });
376
+
377
+ When('I Ctrl-drag while a wheel zoom is pending', async function () {
378
+ await this.page.evaluate(() => {
379
+ map.setView(new L.LatLng(55, 10), 5, { animate: false });
380
+ map.setBearing(0);
381
+ map.setPitch(0);
382
+ map.options.wheelDebounceTime = 500;
383
+ });
384
+ const bounds = await this.page.locator('#map').boundingBox();
385
+ const zoomPoint = { x: bounds.x + bounds.width * 0.9, y: bounds.y + bounds.height * 0.1 },
386
+ dragPoint = { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 };
387
+ await this.page.mouse.move(zoomPoint.x, zoomPoint.y);
388
+ await this.page.mouse.wheel(0, -120);
389
+ this.pendingZoomBeforeDrag = await this.page.evaluate(() => ({
390
+ zoom: map.getZoom(),
391
+ center: map.project(map.getCenter()),
392
+ }));
393
+
394
+ await this.page.keyboard.down('Control');
395
+ await this.page.mouse.move(dragPoint.x, dragPoint.y);
396
+ await this.page.mouse.down();
397
+ await this.page.mouse.move(dragPoint.x + 20, dragPoint.y - 15);
398
+ await this.page.waitForTimeout(600);
399
+ this.pendingZoomAfterDrag = await this.page.evaluate(() => ({
400
+ zoom: map.getZoom(),
401
+ center: map.project(map.getCenter()),
402
+ }));
403
+ await this.page.mouse.up();
404
+ await this.page.keyboard.up('Control');
405
+ });
406
+
407
+ Then('the pending wheel zoom does not move the map', function () {
408
+ assert.equal(this.pendingZoomAfterDrag.zoom, this.pendingZoomBeforeDrag.zoom);
409
+ const distance = Math.hypot(
410
+ this.pendingZoomBeforeDrag.center.x - this.pendingZoomAfterDrag.center.x,
411
+ this.pendingZoomBeforeDrag.center.y - this.pendingZoomAfterDrag.center.y
412
+ );
413
+ assert.ok(distance < 0.01, `Map center moved ${distance} projected pixels`);
414
+ });
415
+
74
416
  When('I fit the same bounds twice', async function () {
75
417
  this.zooms = await this.page.evaluate(() => {
76
418
  const bounds = [[60.81123, 25.71632], [60.81028, 25.73166], [60.7997, 25.72892], [60.80061, 25.71362]];
77
- L.rectangle(bounds).addTo(map);
419
+ new L.Rectangle(bounds).addTo(map);
78
420
  map.setZoom(0, { animate: false });
79
421
  map.fitBounds(bounds, { animate: false });
80
422
  const first = map.getZoom();
@@ -148,8 +490,8 @@ When('I rotate and zoom two fingers over a vector path', async function () {
148
490
  this.gesturePathPositions = await this.page.evaluate(() => {
149
491
  map.options.zoomAnimation = true;
150
492
  map.setView([55, 10], 6, { animate: false });
151
- const location = L.latLng(55.1, 10.1);
152
- const path = L.circleMarker(location, { radius: 8 }).addTo(map);
493
+ const location = new L.LatLng(55.1, 10.1);
494
+ const path = new L.CircleMarker(location, { radius: 8 }).addTo(map);
153
495
  const container = map.getContainer();
154
496
  const center = map.getSize().divideBy(2);
155
497
  const initial = map.getBearing(),
@@ -64,7 +64,7 @@ When('I rotate a map with the default rotate control', async function () {
64
64
  const container = document.createElement('div');
65
65
  container.style.cssText = 'width: 400px; height: 300px';
66
66
  document.body.append(container);
67
- const testMap = L.map(container, {
67
+ const testMap = new L.Map(container, {
68
68
  center: [55, 10],
69
69
  zoom: 6,
70
70
  rotate: true,
@@ -166,7 +166,7 @@ When('I apply compass heading readings', async function () {
166
166
  const container = document.createElement('div');
167
167
  container.style.cssText = 'width: 400px; height: 300px';
168
168
  document.body.append(container);
169
- const absoluteMap = L.map(container, {
169
+ const absoluteMap = new L.Map(container, {
170
170
  center: [55, 10],
171
171
  zoom: 6,
172
172
  rotate: true,
@@ -212,6 +212,50 @@ Then('valid compass readings update bearing and invalid readings are ignored', f
212
212
  assert.ok(Math.abs(absoluteEventBearing - 60) < 1e-6);
213
213
  });
214
214
 
215
+ When('I use shift-wheel rotation with the handler disabled', async function () {
216
+ this.shiftWheelDisabled = await this.page.evaluate(async () => {
217
+ const container = document.createElement('div');
218
+ container.style.cssText = 'width: 400px; height: 300px';
219
+ document.body.append(container);
220
+ const testMap = new L.Map(container, {
221
+ center: [55, 10],
222
+ zoom: 6,
223
+ rotate: true,
224
+ rotateControl: false,
225
+ shiftKeyRotate: false,
226
+ zoomControl: false,
227
+ zoomAnimation: false,
228
+ });
229
+ const initialZoom = testMap.getZoom();
230
+ const initialBearing = testMap.getBearing();
231
+ container.dispatchEvent(new WheelEvent('wheel', {
232
+ bubbles: true,
233
+ cancelable: true,
234
+ deltaY: -60,
235
+ shiftKey: true,
236
+ }));
237
+ await new Promise(resolve => setTimeout(resolve, 150));
238
+ const result = {
239
+ handlerDisabled: !testMap.shiftKeyRotate.enabled(),
240
+ wheelZoomEnabled: testMap.scrollWheelZoom.enabled(),
241
+ zoomChanged: testMap.getZoom() > initialZoom,
242
+ bearingUnchanged: testMap.getBearing() === initialBearing,
243
+ };
244
+ testMap.remove();
245
+ container.remove();
246
+ return result;
247
+ });
248
+ });
249
+
250
+ Then('shift-wheel input zooms without changing the map bearing', function () {
251
+ assert.deepEqual(this.shiftWheelDisabled, {
252
+ handlerDisabled: true,
253
+ wheelZoomEnabled: true,
254
+ zoomChanged: true,
255
+ bearingUnchanged: true,
256
+ });
257
+ });
258
+
215
259
  When('I change the debugger bounds padding from the keyboard', async function () {
216
260
  await this.page.evaluate(() => {
217
261
  map.setView([55, 10], 6, { animate: false });
@@ -244,13 +288,30 @@ When('I inspect debugger geometry after resizing and moving the map', async func
244
288
  map.panBy([20, 15], { animate: false });
245
289
  map.setZoom(6, { animate: false });
246
290
  window.debugMouseEvent = null;
247
- map.once('mousemove', event => {
248
- window.debugMouseEvent = {
249
- latlng: [event.latlng.lng, event.latlng.lat],
250
- layerPoint: [event.layerPoint.x, event.layerPoint.y],
251
- containerPoint: [event.containerPoint.x, event.containerPoint.y],
252
- };
253
- });
291
+ if (L.version.startsWith('2.')) {
292
+ map.getContainer().addEventListener('mousemove', event => {
293
+ const rect = map.getContainer().getBoundingClientRect();
294
+ const containerPoint = new L.Point(
295
+ event.clientX - rect.left - map.getContainer().clientLeft,
296
+ event.clientY - rect.top - map.getContainer().clientTop,
297
+ );
298
+ const latlng = map.containerPointToLatLng(containerPoint);
299
+ const layerPoint = map.containerPointToLayerPoint(containerPoint);
300
+ window.debugMouseEvent = {
301
+ latlng: [latlng.lng, latlng.lat],
302
+ layerPoint: [layerPoint.x, layerPoint.y],
303
+ containerPoint: [containerPoint.x, containerPoint.y],
304
+ };
305
+ }, { once: true });
306
+ } else {
307
+ map.once('mousemove', event => {
308
+ window.debugMouseEvent = {
309
+ latlng: [event.latlng.lng, event.latlng.lat],
310
+ layerPoint: [event.layerPoint.x, event.layerPoint.y],
311
+ containerPoint: [event.containerPoint.x, event.containerPoint.y],
312
+ };
313
+ });
314
+ }
254
315
  });
255
316
 
256
317
  const bounds = await this.page.locator('#map').boundingBox();
@@ -339,9 +400,9 @@ When('I rotate the example markers from the debugger', async function () {
339
400
  When('I select {string} from the example page', async function (place) {
340
401
  await this.page.getByRole('button', { name: place, exact: true }).click();
341
402
  await this.page.waitForFunction(name =>
342
- map.getZoom() === 10 && map.getCenter().distanceTo(L.latLng(places[name])) < 1, place);
403
+ map.getZoom() === 10 && map.getCenter().distanceTo(new L.LatLng(...places[name])) < 1, place);
343
404
  this.destination = await this.page.evaluate(name => ({
344
- distance: map.getCenter().distanceTo(L.latLng(places[name])),
405
+ distance: map.getCenter().distanceTo(new L.LatLng(...places[name])),
345
406
  zoom: map.getZoom(),
346
407
  }), place);
347
408
  });