leaflet-rotate 0.4.0 → 0.5.1

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 (39) hide show
  1. package/README.md +85 -5
  2. package/dist/leaflet-rotate-src.js +1325 -254
  3. package/dist/leaflet-rotate-src.js.map +4 -4
  4. package/dist/leaflet-rotate.js +1 -1
  5. package/dist/leaflet-rotate.js.map +4 -4
  6. package/lib/debug.js +62 -88
  7. package/lib/debug.min.js +16 -1
  8. package/misc/leaflet-heat-rotate.js +7 -0
  9. package/package.json +1 -1
  10. package/src/control/Rotate.js +160 -103
  11. package/src/dom/DomUtil.js +23 -12
  12. package/src/dom/Draggable.js +5 -0
  13. package/src/geo/LatLngBounds.js +5 -0
  14. package/src/geometry/Bounds.js +5 -0
  15. package/src/geometry/Point.js +17 -3
  16. package/src/index.js +4 -1
  17. package/src/layer/DivOverlay.js +34 -5
  18. package/src/layer/Popup.js +14 -3
  19. package/src/layer/Tooltip.js +16 -0
  20. package/src/layer/marker/Icon.js +12 -0
  21. package/src/layer/marker/Marker.js +86 -7
  22. package/src/layer/tile/GridLayer.js +38 -3
  23. package/src/layer/vector/Renderer.js +88 -34
  24. package/src/map/Map.js +578 -116
  25. package/src/map/handler/CompassBearing.js +34 -1
  26. package/src/map/handler/ContainerMutation.js +18 -1
  27. package/src/map/handler/ShiftKeyRotate.js +204 -13
  28. package/src/map/handler/TouchGestures.js +108 -10
  29. package/src/map/handler/TouchPitch.js +53 -0
  30. package/src/map/handler/TouchRotate.js +18 -1
  31. package/src/map/handler/TouchZoom.js +21 -1
  32. package/test/features/example.feature +8 -4
  33. package/test/features/gestures.feature +4 -0
  34. package/test/features/rotation.feature +60 -0
  35. package/test/index.html +342 -186
  36. package/test/steps/bearing.steps.js +535 -0
  37. package/test/steps/example.steps.js +56 -29
  38. package/test/steps/gestures.steps.js +55 -0
  39. package/test/support/hooks.js +4 -16
@@ -71,6 +71,348 @@ 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]];
@@ -233,3 +575,196 @@ Then('its drag callbacks match the marker and pointer coordinates', function ()
233
575
  assert.ok(Math.abs(this.markerPosition.marker.lat - lastCallback[0]) < 1e-7, JSON.stringify(this.markerPosition));
234
576
  assert.ok(Math.abs(this.markerPosition.marker.lng - lastCallback[1]) < 1e-7, JSON.stringify(this.markerPosition));
235
577
  });
578
+
579
+ When('I animate a zoom from a low level on a rotated map', async function () {
580
+ this.lowZoomJump = await this.page.evaluate(async () => {
581
+ const style = document.createElement('style');
582
+ style.textContent = '*{transition:none!important}';
583
+ document.head.appendChild(style);
584
+ map.setView([50, 5], 1, { animate: false });
585
+ map.setBearing(30);
586
+ await new Promise(resolve => setTimeout(resolve, 300));
587
+ const element = circle.getElement(),
588
+ center = () => {
589
+ const bounds = element.getBoundingClientRect();
590
+ return [bounds.left + bounds.width / 2, bounds.top + bounds.height / 2];
591
+ };
592
+ map.setZoom(3);
593
+ await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
594
+ const animated = center();
595
+ await new Promise(resolve => setTimeout(resolve, 500));
596
+ const settled = center();
597
+ style.remove();
598
+ return Math.hypot(animated[0] - settled[0], animated[1] - settled[1]);
599
+ });
600
+ });
601
+
602
+ Then('vector layers do not jump at the end of the zoom animation', function () {
603
+ assert.ok(this.lowZoomJump < 3, `Vector layer jumped ${this.lowZoomJump}px`);
604
+ });
605
+
606
+ When('I zoom around an off-center point on a pitched map', async function () {
607
+ this.zoomAroundDrift = await this.page.evaluate(async () => {
608
+ map.setView([50, 5], 6, { animate: false });
609
+ map.setBearing(30);
610
+ map.setPitch(60);
611
+ const size = map.getSize(),
612
+ point = new L.Point(size.x * 0.1, size.y * 0.9),
613
+ latlng = map.containerPointToLatLng(point);
614
+ map.setZoomAround(point, 7, { animate: false });
615
+ const drift = map.latLngToContainerPoint(latlng).distanceTo(point);
616
+ let animated = false;
617
+ map.once('zoomanim', () => animated = true);
618
+ map.setZoomAround(new L.Point(size.x * 0.85, size.y * 0.15), 8);
619
+ await new Promise(resolve => setTimeout(resolve, 400));
620
+ return { drift, animated };
621
+ });
622
+ });
623
+
624
+ Then('the point under the cursor stays in place', function () {
625
+ assert.ok(this.zoomAroundDrift.drift < 2, `Point under the cursor moved ${this.zoomAroundDrift.drift}px`);
626
+ assert.equal(this.zoomAroundDrift.animated, true, 'Zoom toward the horizon was not animated');
627
+ });
628
+
629
+ When('I right-drag horizontally below the map center', async function () {
630
+ await this.page.evaluate(() => {
631
+ map.setPitch(0);
632
+ map.setBearing(0);
633
+ window.__contextMenus = 0;
634
+ map.on('contextmenu', () => window.__contextMenus++);
635
+ });
636
+ const bounds = await this.page.locator('#map').boundingBox(),
637
+ start = { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 + 50 };
638
+ await this.page.mouse.move(start.x, start.y);
639
+ await this.page.mouse.down({ button: 'right' });
640
+ await this.page.mouse.move(start.x + 50, start.y, { steps: 5 });
641
+ await this.page.mouse.up({ button: 'right' });
642
+ await this.page.locator('#map').dispatchEvent('contextmenu', { button: 2, clientX: start.x + 50, clientY: start.y });
643
+ this.rightDrag = await this.page.evaluate(() => ({
644
+ bearing: map.getBearing(),
645
+ contextMenus: window.__contextMenus,
646
+ }));
647
+ });
648
+
649
+ Then('the map rotates counterclockwise without opening the context menu', function () {
650
+ assert.ok(Math.abs(this.rightDrag.bearing - 320) < 1e-6, JSON.stringify(this.rightDrag));
651
+ assert.equal(this.rightDrag.contextMenus, 0);
652
+ });
653
+
654
+ When('a context menu event fires immediately on right-button press', async function () {
655
+ this.earlyContextMenu = await this.page.evaluate(() => {
656
+ let contextMenus = 0;
657
+ map.on('contextmenu', () => contextMenus++);
658
+ const pointer = L.version.startsWith('2.');
659
+ const Event = pointer ? PointerEvent : MouseEvent;
660
+ map.getContainer().dispatchEvent(new Event(pointer ? 'pointerdown' : 'mousedown', {
661
+ button: 2, buttons: 2, bubbles: true, cancelable: true,
662
+ }));
663
+ const menu = new MouseEvent('contextmenu', { button: 2, bubbles: true, cancelable: true });
664
+ map.getContainer().dispatchEvent(menu);
665
+ document.dispatchEvent(new Event(pointer ? 'pointerup' : 'mouseup', { button: 2 }));
666
+ return { prevented: menu.defaultPrevented, contextMenus };
667
+ });
668
+ });
669
+
670
+ Then('the early context menu is suppressed', function () {
671
+ assert.deepEqual(this.earlyContextMenu, { prevented: true, contextMenus: 0 });
672
+ });
673
+
674
+ When('I look at a high zoom map with a high pitch', async function () {
675
+ this.highPitch = await this.page.evaluate(async () => {
676
+ const results = [];
677
+ for (const pitch of [70, 85]) {
678
+ map.setView([51.45, -0.26], 18, { animate: false });
679
+ map.setBearing(30);
680
+ map.setPitch(pitch);
681
+ await new Promise(resolve => setTimeout(resolve, 300));
682
+ const layer = Object.values(map._layers).find(l => l._tiles && l._map && l._tileZoom !== undefined),
683
+ tileSize = layer.getTileSize(),
684
+ size = map.getSize(),
685
+ farY = map._getPitchFarY();
686
+ let missing = 0;
687
+ for (let x = 0; x <= size.x; x += size.x / 8) {
688
+ for (let y = Math.max(0, farY + 2); y <= size.y; y += size.y / 8) {
689
+ const tile = map.project(map.containerPointToLatLng([x, y]), layer._tileZoom).unscaleBy(tileSize).floor();
690
+ if (!layer._tiles[`${tile.x}:${tile.y}:${layer._tileZoom}`]) missing++;
691
+ }
692
+ }
693
+ const bounds = map.getBounds(), center = map.getCenter(), canvas = path._renderer._container;
694
+ results.push({
695
+ pitch,
696
+ farY,
697
+ missing,
698
+ tiles: Object.keys(layer._tiles).length,
699
+ canvas: [canvas.width, canvas.height],
700
+ boundsContainCenter: bounds.contains(center),
701
+ sky: map._pitchSky && map._pitchSky.style.display !== 'none',
702
+ });
703
+ }
704
+ return results;
705
+ });
706
+ });
707
+
708
+ Then('tile loading stays bounded and covers the visible map', function () {
709
+ for (const result of this.highPitch) {
710
+ const details = JSON.stringify(result);
711
+ assert.equal(result.missing, 0, details);
712
+ assert.ok(result.tiles < 300, details);
713
+ assert.ok(Math.max(...result.canvas) < 5000, details);
714
+ assert.equal(result.boundsContainCenter, true, details);
715
+ assert.ok(result.farY > 0, details);
716
+ assert.equal(result.sky, true, details);
717
+ }
718
+ });
719
+
720
+ When('I pan, zoom and change the pitch of a rotated map', async function () {
721
+ const page = this.page;
722
+ const setup = ([bearing, pitch]) => {
723
+ window.__tileErr = () => {
724
+ const layer = Object.values(map._layers).find(l => l._tiles && l._map && l._level),
725
+ level = layer._level,
726
+ point = new L.Point(150, 200),
727
+ ref = document.createElement('div'),
728
+ origin = map.project(map.containerPointToLatLng(point), level.zoom).round().subtract(level.origin);
729
+ ref.style.cssText = `position:absolute;width:0;height:0;left:${origin.x}px;top:${origin.y}px`;
730
+ level.el.appendChild(ref);
731
+ const rect = ref.getBoundingClientRect(), container = map.getContainer().getBoundingClientRect();
732
+ ref.remove();
733
+ return Math.hypot(rect.left - container.left - point.x, rect.top - container.top - point.y);
734
+ };
735
+ map.setView([51.45, -0.26], 10, { animate: false });
736
+ map.setBearing(bearing);
737
+ map.setPitch(pitch);
738
+ map.panBy([60, 60], { animate: false });
739
+ };
740
+ const box = await page.locator('#map').boundingBox();
741
+ this.tileAlignment = [];
742
+ for (const view of [[30, 30], [30, 0]]) {
743
+ await page.evaluate(setup, view);
744
+ await page.mouse.move(box.x + box.width * 0.7, box.y + box.height * 0.6);
745
+ for (const delta of [-200, 200]) {
746
+ const zoomEnd = page.evaluate(() => new Promise(resolve => { map.once('zoomend', () => resolve()); setTimeout(resolve, 2000); }));
747
+ await page.mouse.wheel(0, delta);
748
+ await zoomEnd;
749
+ await page.waitForTimeout(500);
750
+ this.tileAlignment.push({ view, delta, error: await page.evaluate(() => window.__tileErr()) });
751
+ }
752
+ }
753
+ this.tileAlignment.push(await page.evaluate(async () => {
754
+ map.setView([51.45, -0.26], 10, { animate: false });
755
+ map.setPitch(85);
756
+ map.setView([51.45, -0.26], 10, { animate: false });
757
+ const centerError = map.project(map.getCenter()).distanceTo(map.project([51.45, -0.26]));
758
+ map.setPitch(0);
759
+ map.setBearing(30);
760
+ await new Promise(resolve => setTimeout(resolve, 300));
761
+ return { view: 'pitch 85 to 0', centerError, error: window.__tileErr() };
762
+ }));
763
+ });
764
+
765
+ Then('the map is centered and the tiles stay aligned with the map coordinates', function () {
766
+ for (const result of this.tileAlignment) {
767
+ assert.ok(result.error < 3, JSON.stringify(this.tileAlignment));
768
+ assert.ok(!(result.centerError > 2), JSON.stringify(this.tileAlignment));
769
+ }
770
+ });
@@ -76,32 +76,62 @@ When('I rotate a map with the default rotate control', async function () {
76
76
  const displayWhenRotated = getComputedStyle(control).display;
77
77
  testMap.setBearing(0);
78
78
  const displayAfterReset = getComputedStyle(control).display;
79
+ testMap.setPitch(30);
80
+ const displayWhenPitched = getComputedStyle(control).display;
81
+ testMap.setPitch(0);
82
+ const displayAfterPitchReset = getComputedStyle(control).display;
79
83
  testMap.remove();
80
84
  container.remove();
81
- return [displayAtZero, displayWhenRotated, displayAfterReset];
85
+ return [displayAtZero, displayWhenRotated, displayAfterReset, displayWhenPitched, displayAfterPitchReset];
82
86
  });
83
87
  });
84
88
 
85
89
  Then('the default rotate control visibility follows the bearing', function () {
86
- assert.deepEqual(this.rotateControlVisibility, ['none', 'block', 'none']);
90
+ assert.deepEqual(this.rotateControlVisibility, ['none', 'block', 'none', 'block', 'none']);
87
91
  });
88
92
 
89
93
  When('I drag the rotate control', async function () {
90
- this.controlBearing = await this.page.evaluate(() => {
91
- map.setBearing(0);
92
- const link = map.rotateControl._link;
93
- link.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: 100, clientY: 100 }));
94
- document.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: 135, clientY: 100 }));
95
- document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: 135, clientY: 100 }));
96
- return map.getBearing();
97
- });
94
+ await this.page.evaluate(() => { map.setPitch(0); map.setBearing(0); });
95
+ const box = await this.page.locator('.leaflet-control-rotate-toggle').boundingBox(),
96
+ x = box.x + box.width / 2,
97
+ y = box.y + box.height / 2;
98
+ await this.page.mouse.move(x, y);
99
+ await this.page.mouse.down();
100
+ await this.page.mouse.move(x + 50, y, { steps: 5 });
101
+ await this.page.mouse.move(x + 50, y + 200, { steps: 5 });
102
+ await this.page.mouse.move(x + 50, y + 160, { steps: 5 });
103
+ await this.page.mouse.up();
104
+ await this.page.waitForTimeout(700);
105
+ this.controlDrag = await this.page.evaluate(() => ({
106
+ bearing: map.getBearing(),
107
+ pitch: map.getPitch(),
108
+ arrow: map.rotateControl._arrow.style.transform,
109
+ }));
110
+ });
111
+
112
+ Then('the map bearing and pitch follow the control drag', function () {
113
+ // a drag must not be followed by the click reset
114
+ assert.ok(Math.abs(this.controlDrag.bearing - 40) < 1e-6, JSON.stringify(this.controlDrag));
115
+ assert.ok(Math.abs(this.controlDrag.pitch - 20) < 1e-6, JSON.stringify(this.controlDrag));
116
+ assert.match(this.controlDrag.arrow, /rotateX\((20|19\.9)[\d.]*deg\) rotate\((40|39\.9)[\d.]*deg\)/);
98
117
  });
99
118
 
100
- Then('the map bearing follows the control drag', function () {
101
- assert.equal(this.controlBearing, 35);
119
+ When('I click the rotate control on a rotated and pitched map', async function () {
120
+ await this.page.evaluate(() => { map.setBearing(120); map.setPitch(45); });
121
+ await this.page.locator('.leaflet-control-rotate-toggle').click();
122
+ this.controlReset = [await this.page.evaluate(() => ({ bearing: map.getBearing(), pitch: map.getPitch() }))];
123
+ await this.page.waitForTimeout(800);
124
+ this.controlReset.push(await this.page.evaluate(() => ({ bearing: map.getBearing(), pitch: map.getPitch() })));
102
125
  });
103
126
 
104
- When('I activate compass mode on a permission-based device', async function () {
127
+ Then('the map animates back to north and zero pitch', function () {
128
+ const [start, end] = this.controlReset;
129
+ assert.ok(start.bearing > 0 || start.pitch > 0, JSON.stringify(this.controlReset));
130
+ assert.equal(end.bearing, 0);
131
+ assert.equal(end.pitch, 0);
132
+ });
133
+
134
+ When('I enable compass bearing on a permission-based device', async function () {
105
135
  this.permissionRequests = await this.page.evaluate(async () => {
106
136
  const descriptor = Object.getOwnPropertyDescriptor(window, 'DeviceOrientationEvent');
107
137
  window.permissionRequests = 0;
@@ -114,24 +144,21 @@ When('I activate compass mode on a permission-based device', async function () {
114
144
  }
115
145
  },
116
146
  });
117
- const click = () => map.rotateControl._link.dispatchEvent(new MouseEvent('click', {
118
- bubbles: true,
119
- cancelable: true,
120
- }));
121
- click();
122
- click();
123
- await Promise.resolve();
147
+ map.compassBearing.disable();
148
+ map.compassBearing.enable();
149
+ await new Promise(resolve => setTimeout(resolve));
150
+ const enabled = map.compassBearing.enabled();
124
151
  if (descriptor) {
125
152
  Object.defineProperty(window, 'DeviceOrientationEvent', descriptor);
126
153
  } else {
127
154
  delete window.DeviceOrientationEvent;
128
155
  }
129
- return window.permissionRequests;
156
+ return { requests: window.permissionRequests, enabled };
130
157
  });
131
158
  });
132
159
 
133
160
  Then('device-orientation permission is requested', function () {
134
- assert.equal(this.permissionRequests, 1);
161
+ assert.deepEqual(this.permissionRequests, { requests: 1, enabled: false });
135
162
  });
136
163
 
137
164
  When('I apply compass heading readings', async function () {
@@ -327,8 +354,8 @@ When('I inspect debugger geometry after resizing and moving the map', async func
327
354
  return [point.x - targets[index][0], point.y - targets[index][1]];
328
355
  });
329
356
  const pixelBounds = map.getPixelBounds();
330
- const debugMin = JSON.parse(document.getElementById('ogbx').textContent);
331
- const debugMax = JSON.parse(document.getElementById('ogby').textContent);
357
+ const debugMin = document.getElementById('ogbx').textContent.split(',').map(Number);
358
+ const debugMax = document.getElementById('ogby').textContent.split(',').map(Number);
332
359
  return {
333
360
  size: [size.x, size.y],
334
361
  paneSizes: ['.panebounds.rotate', '.panebounds.main'].map(selector => {
@@ -343,10 +370,10 @@ When('I inspect debugger geometry after resizing and moving the map', async func
343
370
  ],
344
371
  pixelOrigin: [read('ogx') - map._pixelOrigin.x, read('ogy') - map._pixelOrigin.y],
345
372
  pixelBounds: [
346
- debugMin.x - pixelBounds.min.x,
347
- debugMin.y - pixelBounds.min.y,
348
- debugMax.x - pixelBounds.max.x,
349
- debugMax.y - pixelBounds.max.y,
373
+ debugMin[0] - pixelBounds.min.x,
374
+ debugMin[1] - pixelBounds.min.y,
375
+ debugMax[0] - pixelBounds.max.x,
376
+ debugMax[1] - pixelBounds.max.y,
350
377
  ],
351
378
  pointer: [
352
379
  read('llx') - window.debugMouseEvent.latlng[0],
@@ -392,7 +419,7 @@ When('I use the example zoom controls', async function () {
392
419
 
393
420
  When('I rotate the example markers from the debugger', async function () {
394
421
  this.markerRotationsBefore = await this.page.evaluate(() => markers.map(marker => marker.options.rotation));
395
- await this.page.getByRole('button', { name: 'Rotate Markers' }).click();
422
+ await this.page.getByRole('button', { name: 'Rotate', exact: true }).click();
396
423
  await this.page.waitForTimeout(600);
397
424
  this.markerRotationsAfter = await this.page.evaluate(() => markers.map(marker => marker.options.rotation));
398
425
  });