leaflet-rotate 0.5.2 → 0.5.4

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.
@@ -178,6 +178,148 @@ When('I focus a marker near the edge of a rotated map', async function () {
178
178
  });
179
179
  });
180
180
 
181
+ When('I drag between city markers and reset north before clicking Trondheim', { timeout: 30000 }, async function () {
182
+ await this.page.evaluate(() => {
183
+ map.options.inertia = false;
184
+ map.setBearing(0);
185
+ map.setView(places.Kyiv, 6, { animate: false });
186
+ markers.forEach((marker, index) => { marker.getElement().id = `city-marker-${index}`; });
187
+ });
188
+ const rect = await this.page.locator('#map').boundingBox();
189
+ await this.page.mouse.move(rect.x + rect.width / 2, rect.y + rect.height / 2 - 20);
190
+ await this.page.keyboard.down('Control');
191
+ await this.page.mouse.down();
192
+ await this.page.mouse.move(rect.x + rect.width / 2 + 75, rect.y + rect.height / 2 - 20, { steps: 8 });
193
+ await this.page.mouse.up();
194
+ await this.page.keyboard.up('Control');
195
+ assert.ok(await this.page.evaluate(() => map.getBearing() > 1 && map.getPitch() === 0));
196
+
197
+ const waitForPan = () => this.page.waitForFunction(() => !map._panAnim || !map._panAnim._inProgress);
198
+ const dragTo = async name => {
199
+ for (let i = 0; i < 30; i++) {
200
+ const point = await this.page.evaluate(name => {
201
+ const point = map.latLngToContainerPoint(places[name]);
202
+ return { x: point.x, y: point.y };
203
+ }, name);
204
+ const dx = Math.max(-rect.width / 4, Math.min(rect.width / 4, rect.width * 0.6 - point.x));
205
+ const dy = Math.max(-rect.height / 4, Math.min(rect.height / 4, rect.height * 0.65 - point.y));
206
+ if (Math.hypot(dx, dy) < 2) return;
207
+ await this.page.mouse.move(rect.x + rect.width / 2, rect.y + rect.height / 2);
208
+ await this.page.mouse.down();
209
+ await this.page.mouse.move(rect.x + rect.width / 2 + dx, rect.y + rect.height / 2 + dy, { steps: 4 });
210
+ await this.page.mouse.up();
211
+ }
212
+ throw new Error(`Could not drag to ${name}`);
213
+ };
214
+ for (const [index, name] of [[0, 'Kyiv'], [1, 'London']]) {
215
+ await dragTo(name);
216
+ await this.page.locator(`#city-marker-${index}`).click();
217
+ await waitForPan();
218
+ await this.page.locator('.leaflet-popup-close-button').click();
219
+ }
220
+ const resetCenter = await this.page.evaluate(() => map.project(map.getCenter()));
221
+ await this.page.locator('.leaflet-control-rotate-toggle').click();
222
+ await this.page.waitForFunction(() => map.getBearing() === 0 && !map._bearingAnimationFrame);
223
+ this.cityPopup = await this.page.evaluate(before => ({
224
+ resetMovement: map.project(map.getCenter()).distanceTo(new L.Point(before.x, before.y)),
225
+ }), resetCenter);
226
+ await dragTo('Trondheim');
227
+ const before = await this.page.evaluate(() => map.project(map.getCenter()));
228
+ // Use the pointer position, so a focus-induced jump cannot make Playwright chase the marker.
229
+ const icon = await this.page.locator('#city-marker-4').boundingBox();
230
+ await this.page.locator('#city-marker-4').focus();
231
+ await waitForPan();
232
+ this.cityPopup.focusMovement = await this.page.evaluate(before =>
233
+ map.project(map.getCenter()).distanceTo(new L.Point(before.x, before.y)), before);
234
+ await this.page.mouse.click(icon.x + icon.width / 2, icon.y + icon.height / 2);
235
+ await waitForPan();
236
+ Object.assign(this.cityPopup, await this.page.evaluate(before => {
237
+ const popup = markers[4].getPopup();
238
+ const rect = map.getContainer().getBoundingClientRect();
239
+ const popupRect = popup.getElement()?.getBoundingClientRect();
240
+ const center = map.project(map.getCenter());
241
+ const viewportCenter = map.project(map.containerPointToLatLng(map.getSize().divideBy(2)));
242
+ return {
243
+ open: markers[4].isPopupOpen(),
244
+ movement: center.distanceTo(new L.Point(before.x, before.y)),
245
+ centerError: center.distanceTo(viewportCenter),
246
+ inside: Boolean(popupRect && popupRect.left >= rect.left - 1 && popupRect.right <= rect.right + 1
247
+ && popupRect.top >= rect.top - 1 && popupRect.bottom <= rect.bottom + 1),
248
+ size: Math.max(map.getSize().x, map.getSize().y),
249
+ };
250
+ }, before));
251
+ });
252
+
253
+ Then('the Trondheim popup opens without jumping back to London', function () {
254
+ assert.ok(this.cityPopup.resetMovement < 1, JSON.stringify(this.cityPopup));
255
+ assert.ok(this.cityPopup.focusMovement < 1, JSON.stringify(this.cityPopup));
256
+ assert.equal(this.cityPopup.open, true, JSON.stringify(this.cityPopup));
257
+ assert.equal(this.cityPopup.inside, true, JSON.stringify(this.cityPopup));
258
+ assert.ok(this.cityPopup.movement < this.cityPopup.size / 2, JSON.stringify(this.cityPopup));
259
+ assert.ok(this.cityPopup.centerError < 1, JSON.stringify(this.cityPopup));
260
+ });
261
+
262
+ When('I keep padded points visible with rotation {word} and bearing {float}', async function (rotate, bearing) {
263
+ this.paddedPans = await this.page.evaluate(({ rotate, bearing }) => {
264
+ const element = document.createElement('div');
265
+ element.style.cssText = 'width: 400px; height: 300px; margin-left: 100px';
266
+ document.body.append(element);
267
+ const testMap = new L.Map(element, {
268
+ center: [55, 10], zoom: 6, rotate: rotate === 'true', bearing, rotateControl: false,
269
+ });
270
+ const results = [];
271
+ for (const padding of [
272
+ {},
273
+ { padding: [20, 30] },
274
+ { paddingTopLeft: new L.Point(35, 45), paddingBottomRight: new L.Point(25, 15) },
275
+ ]) {
276
+ const tl = padding.paddingTopLeft || new L.Point(...(padding.padding || [0, 0]));
277
+ const br = padding.paddingBottomRight || new L.Point(...(padding.padding || [0, 0]));
278
+ const min = tl, max = testMap.getSize().subtract(br);
279
+ for (const point of [
280
+ min.add([1, 1]), max.subtract([1, 1]),
281
+ new L.Point(min.x - 80, 150), new L.Point(max.x + 80, 150),
282
+ new L.Point(200, min.y - 80), new L.Point(200, max.y + 80),
283
+ min.subtract([80, 80]), max.add([80, 80]),
284
+ ]) {
285
+ testMap.setView([55, 10], 6, { reset: true, animate: false });
286
+ const latlng = testMap.containerPointToLatLng(point);
287
+ // Measure the rendered center, avoiding the cached setView center before pixel rounding.
288
+ const center = testMap.getSize().divideBy(2);
289
+ const before = testMap.project(testMap.containerPointToLatLng(center));
290
+ const expected = new L.Point(
291
+ Math.max(min.x, Math.min(max.x, point.x)),
292
+ Math.max(min.y, Math.min(max.y, point.y)),
293
+ );
294
+ const returnedMap = testMap.panInside(latlng, { ...padding, animate: false });
295
+ const actual = testMap.latLngToContainerPoint(latlng);
296
+ const after = testMap.project(testMap.containerPointToLatLng(center));
297
+ results.push({
298
+ point, expected, actual, rotate, bearing,
299
+ chainable: returnedMap === testMap,
300
+ movement: before.distanceTo(after),
301
+ expectedMovement: point.distanceTo(expected),
302
+ error: actual.distanceTo(expected),
303
+ });
304
+ }
305
+ }
306
+ testMap.remove();
307
+ element.remove();
308
+ return results;
309
+ }, { rotate, bearing });
310
+ });
311
+
312
+ Then('panInside moves only enough to reach the padded viewport', function () {
313
+ for (const result of this.paddedPans) {
314
+ assert.equal(result.chainable, true, JSON.stringify(result));
315
+ assert.ok(result.error < 1.5, JSON.stringify(result));
316
+ assert.ok(Math.abs(result.movement - result.expectedMovement) < 1.5, JSON.stringify(result));
317
+ if (result.expectedMovement === 0) {
318
+ assert.equal(result.movement, 0, JSON.stringify(result));
319
+ }
320
+ }
321
+ });
322
+
181
323
  When('I redraw a heat layer after rotating, panning, and zooming', async function () {
182
324
  await this.page.evaluate(() => {
183
325
  map.setView([55, 10], 6, { animate: false });
@@ -425,3 +567,47 @@ Then('the second map center remains unchanged and its popup stays anchored', fun
425
567
  assert.ok(Math.hypot(...this.secondaryPopup.delta) < 2, JSON.stringify(this.secondaryPopup));
426
568
  assert.ok(Math.hypot(...this.secondaryPopup.tooltipDelta) < 2, JSON.stringify(this.secondaryPopup));
427
569
  });
570
+
571
+ When('I add markers to a Canvas-preferred map at pitch {int}', async function (pitch) {
572
+ this.domMarkers = await this.page.evaluate(pitch => {
573
+ const container = document.createElement('div');
574
+ container.style.cssText = 'height: 300px; width: 500px';
575
+ document.body.appendChild(container);
576
+ const map = new L.Map(container, {
577
+ center: [55, 10], zoom: 6, rotate: true, bearing: 35, pitch, preferCanvas: true,
578
+ });
579
+ const marker = new L.Marker([55, 10], { draggable: true, rotation: Math.PI / 4 })
580
+ .bindPopup('DOM popup', { autoPan: false }).addTo(map);
581
+ const divMarker = new L.Marker([55.1, 10.1], { icon: new L.DivIcon({ html: 'Label' }) }).addTo(map);
582
+ const vector = new L.CircleMarker([55.2, 10.2]).addTo(map);
583
+ map.panBy([40, 20], { animate: false });
584
+ marker.setLatLng([55.3, 10.3]).setOpacity(0.5);
585
+ const element = marker.getElement();
586
+ const transform = new DOMMatrix(getComputedStyle(element).transform);
587
+ const position = L.DomUtil.getPosition(element).add(map._getMapPanePos());
588
+ const expected = map.latLngToContainerPoint(marker.getLatLng());
589
+ element.click();
590
+ const result = {
591
+ icon: element.tagName,
592
+ iconPane: element.parentNode === map.getPane('markerPane'),
593
+ shadow: marker._shadow.tagName,
594
+ divIcon: divMarker.getElement().tagName,
595
+ vectorCanvas: vector._renderer instanceof L.Canvas,
596
+ aligned: position.distanceTo(expected) < 1,
597
+ rotation: Math.abs(Math.atan2(transform.b, transform.a) - Math.PI / 4) < 1e-6,
598
+ opacity: element.style.opacity,
599
+ draggable: marker.dragging.enabled(),
600
+ popup: marker.isPopupOpen(),
601
+ };
602
+ marker.remove();
603
+ result.removed = !element.isConnected && !marker.getElement();
604
+ return result;
605
+ }, pitch);
606
+ });
607
+
608
+ Then('the markers use Leaflet DOM icons while vector paths use Canvas', function () {
609
+ assert.deepEqual(this.domMarkers, {
610
+ icon: 'IMG', iconPane: true, shadow: 'IMG', divIcon: 'DIV', vectorCanvas: true,
611
+ aligned: true, rotation: true, opacity: '0.5', draggable: true, popup: true, removed: true,
612
+ });
613
+ });
@@ -0,0 +1,264 @@
1
+ import assert from 'node:assert/strict';
2
+ import { Given, Then } from '@cucumber/cucumber';
3
+
4
+ Given('a standalone pitched map is open', async function () {
5
+ const leaflet = process.env.LEAFLET_VERSION === '2'
6
+ ? '<script src="https://unpkg.com/leaflet@2.0.0-alpha.1/dist/leaflet-global-src.js"></script><script>globalThis.L = globalThis.leaflet;</script>'
7
+ : '<script src="https://unpkg.com/leaflet@1.9/dist/leaflet-src.js"></script>';
8
+ await this.page.goto(`${this.baseUrl}/test/index.html`);
9
+ await this.page.setContent(`<!doctype html>
10
+ <html lang="en">
11
+ <head>
12
+ <meta charset="utf-8">
13
+ <base href="${this.baseUrl}/">
14
+ <link rel="stylesheet" href="https://unpkg.com/leaflet@${process.env.LEAFLET_VERSION === '2' ? '2.0.0-alpha.1' : '1.9'}/dist/leaflet.css">
15
+ ${leaflet}
16
+ <script src="dist/leaflet-rotate-src.js"></script>
17
+ </head>
18
+ <body>
19
+ <div id="first" style="width:400px;height:300px"></div>
20
+ <script>
21
+ window.testPitch = { map: new L.Map('first', {
22
+ center: [48.8582601, 2.29449985], zoom: 17, rotate: true, pitch: 45, bearing: 30,
23
+ }) };
24
+ </script>
25
+ </body>
26
+ </html>`);
27
+ await this.page.waitForFunction(() => window.testPitch);
28
+ assert.equal(await this.page.evaluate(() => L.Layer.types), undefined);
29
+ });
30
+
31
+ Then('raster perspective follows pitch on late and reattached layers', async function () {
32
+ const result = await this.page.evaluate(() => {
33
+ const map = testPitch.map, checks = [];
34
+ const pane = map.createPane('lateRaster', map._rotatePane);
35
+ const first = new L.GridLayer({ pane: 'lateRaster', updateWhenIdle: true }).addTo(map);
36
+ const second = new L.GridLayer({ pane: 'lateRaster' }).addTo(map);
37
+ const check = () => {
38
+ const expected = 'preserve-3d';
39
+ checks.push([map._rotatePane, pane, first._container, second._container]
40
+ .every(el => el.style.transformStyle === expected));
41
+ checks.push(getComputedStyle(map._mapPane).transformStyle === 'preserve-3d');
42
+ checks.push([first, second].every(layer => Object.values(layer._levels)
43
+ .every(level => getComputedStyle(level.el).transformStyle === 'flat')));
44
+ };
45
+ check();
46
+ map.setPitch(0); check();
47
+ first.remove();
48
+ map.setPitch(75);
49
+ first.addTo(map); check();
50
+ map.setPitch(0); check();
51
+ map.setPitch(45); check();
52
+ first.remove(); second.remove();
53
+ pane.remove();
54
+ return checks;
55
+ });
56
+ assert.ok(result.every(Boolean), 'Late layers, custom panes and updateWhenIdle must follow pitch');
57
+ });
58
+
59
+
60
+ Then('pitched raster surfaces draw loaded tiles after rapid camera changes', { timeout: 30000 }, async function () {
61
+ await this.page.addStyleTag({ content: '.leaflet-control { display: none; }' });
62
+ const png = await this.page.evaluate(() => {
63
+ const canvas = document.createElement('canvas');
64
+ canvas.width = canvas.height = 256;
65
+ const ctx = canvas.getContext('2d');
66
+ ctx.fillStyle = '#00ff00';
67
+ ctx.fillRect(0, 0, 256, 256);
68
+ return canvas.toDataURL().split(',')[1];
69
+ });
70
+ await this.page.route(/\/pitched-tile\.png/, async route => {
71
+ await new Promise(resolve => setTimeout(resolve, 100));
72
+ await route.fulfill({ contentType: 'image/png', body: Buffer.from(png, 'base64') });
73
+ });
74
+ await this.page.evaluate(() => {
75
+ const map = testPitch.map;
76
+ map.getContainer().style.width = '1000px';
77
+ map.getContainer().style.height = '731px';
78
+ map.invalidateSize({ animate: false });
79
+ map.setView([48.85478060234936, 2.28643600540333], 17, { animate: false });
80
+ testPitch.tiles = new L.TileLayer('/pitched-tile.png?{z}/{x}/{y}', { maxNativeZoom: 17 }).addTo(map);
81
+ });
82
+ await this.page.waitForFunction(() => !testPitch.tiles.isLoading());
83
+ await this.page.evaluate(() => {
84
+ const map = testPitch.map, layer = testPitch.tiles, level = layer._levels[layer._tileZoom];
85
+ map._setPitchAndBearing(60.5, 152.98861657457235);
86
+ level.origin = new L.Point(16990718, 11542325);
87
+ L.DomUtil.setTransform(level.el, new L.Point(-1816, 672), 1);
88
+ for (const tile of Object.values(layer._tiles)) {
89
+ L.DomUtil.setPosition(tile.el, new L.Point(tile.coords.x, tile.coords.y).scaleBy(layer.getTileSize()).subtract(level.origin));
90
+ }
91
+ map._pixelOrigin = new L.Point(16992534, 11541653);
92
+ L.DomUtil.setPosition(map._mapPane, new L.Point(-670, 1433));
93
+ map._setPitchTransform('matrix3d(-1.3127, 0.608476, 0.395287, -0.000360499, 0.373224, -1.19361, -0.775414, 0.000707172, -0.525431, -0.390956, 0.492424, -0.000449087, -2578.6, 2999.53, 2642.09, -1.40956)');
94
+ });
95
+ const checkPixels = async () => {
96
+ await this.page.waitForFunction(() => !testPitch.map._animatingZoom &&
97
+ !testPitch.map._bearingAnimationFrame && !testPitch.map._panAnim?._inProgress);
98
+ await this.page.waitForTimeout(400);
99
+ const image = await this.page.locator('#first').screenshot({ animations: 'disabled' });
100
+ const missing = await this.page.evaluate(async base64 => {
101
+ const img = new Image();
102
+ img.src = `data:image/png;base64,${base64}`;
103
+ await img.decode();
104
+ const canvas = document.createElement('canvas');
105
+ canvas.width = img.width; canvas.height = img.height;
106
+ const ctx = canvas.getContext('2d');
107
+ ctx.drawImage(img, 0, 0);
108
+ let missing = 0;
109
+ for (let y = 120; y < 700; y += 80) {
110
+ for (let x = 100; x < 950; x += 100) {
111
+ const [r, g, b] = ctx.getImageData(x, y, 1, 1).data;
112
+ if (g < 240 || r > 10 || b > 10) missing++;
113
+ }
114
+ }
115
+ return missing;
116
+ }, image.toString('base64'));
117
+ assert.equal(missing, 0, 'Actual raster pixels must cover the ground, not just loaded DOM images');
118
+ };
119
+ await checkPixels();
120
+ const checkMarker = async () => {
121
+ const points = await this.page.evaluate(() => {
122
+ const map = testPitch.map;
123
+ const marker = new L.Marker(map.containerPointToLatLng([550, 390]), {
124
+ icon: new L.DivIcon({ className: '', html: '<div style="width:30px;height:30px;background:#ff00ff"></div>', iconSize: [30, 30], iconAnchor: [15, 15] })
125
+ }).addTo(map);
126
+ marker.bindTooltip('<div style="width:30px;height:30px;background:#00ffff"></div>', {
127
+ permanent: true, direction: 'right', opacity: 1
128
+ });
129
+ testPitch.visibleMarker = marker;
130
+ const rect = marker.getTooltip().getElement().querySelector('div').getBoundingClientRect();
131
+ const container = map.getContainer().getBoundingClientRect();
132
+ return [map.latLngToContainerPoint(marker.getLatLng()), {
133
+ x: rect.left - container.left + rect.width / 2, y: rect.top - container.top + rect.height / 2
134
+ }];
135
+ });
136
+ await this.page.waitForTimeout(300);
137
+ const image = await this.page.locator('#first').screenshot({ animations: 'disabled' });
138
+ const colors = await this.page.evaluate(async ({ base64, points }) => {
139
+ const img = new Image(); img.src = `data:image/png;base64,${base64}`; await img.decode();
140
+ const canvas = document.createElement('canvas'); canvas.width = img.width; canvas.height = img.height;
141
+ const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0);
142
+ return points.map(point => [...ctx.getImageData(Math.round(point.x), Math.round(point.y), 1, 1).data]);
143
+ }, { base64: image.toString('base64'), points });
144
+ await this.page.evaluate(() => testPitch.visibleMarker.remove());
145
+ const expected = [[255, 0, 255, 255], [0, 255, 255, 255]];
146
+ assert.ok(colors.every((color, i) => color.every((channel, j) => Math.abs(channel - expected[i][j]) <= 32)),
147
+ `Markers and permanent tooltips paint above the raster ground: ${JSON.stringify(colors)}`);
148
+ };
149
+ await checkMarker();
150
+ await this.page.evaluate(async () => {
151
+ const map = testPitch.map;
152
+ for (let i = 0; i < 12; i++) {
153
+ map.setPitch([20, 75, 45][i % 3], { animate: true, duration: .15 });
154
+ map.setBearing(i * 61, { animate: true, duration: .15 });
155
+ map.setZoomAround([400, 350], i % 2 ? 17 : 18);
156
+ map.panBy([30, -20], { animate: true, duration: .1 });
157
+ await new Promise(resolve => setTimeout(resolve, 70));
158
+ }
159
+ await new Promise(resolve => setTimeout(resolve, 400));
160
+ map.setPitch(45); map.setBearing(153);
161
+ map.setView([48.85478060234936, 2.28643600540333], 17, { animate: false });
162
+ });
163
+ await this.page.waitForFunction(() => !testPitch.tiles.isLoading());
164
+ await checkPixels();
165
+ await checkMarker();
166
+ await this.page.addStyleTag({ content: '.leaflet-control-rotate { display: block; }' });
167
+ await this.page.locator('.leaflet-control-rotate a').click();
168
+ await this.page.waitForFunction(() => testPitch.map.getPitch() === 0 && testPitch.map.getBearing() === 0);
169
+ await this.page.waitForFunction(() => !testPitch.tiles.isLoading());
170
+ await checkPixels();
171
+ await checkMarker();
172
+ const styles = await this.page.evaluate(() => {
173
+ testPitch.map.setPitch(0);
174
+ return [testPitch.map._mapPane, testPitch.map._rotatePane, testPitch.tiles._container, testPitch.tiles.getPane()]
175
+ .map(el => el.style.transformStyle);
176
+ });
177
+ assert.deepEqual(styles, Array(4).fill('preserve-3d'), 'Compass reset preserves the ground context at pitch zero');
178
+ });
179
+
180
+
181
+ Then('pitched view resets preserve the ground perspective after accumulated pans', async function () {
182
+ const result = await this.page.evaluate(async () => {
183
+ const map = testPitch.map;
184
+ map.getContainer().style.width = '1000px';
185
+ map.getContainer().style.height = '731px';
186
+ map.invalidateSize({ animate: false });
187
+ const layer = new L.GridLayer({ maxNativeZoom: 17 }).addTo(map);
188
+ map.setZoom(18, { animate: false });
189
+ for (let i = 0; i < 5; i++) map.panBy([-250, -150], { animate: false });
190
+ map._setPitchAndBearing(85, 46.43810536434438);
191
+ const farBefore = map._getPitchFarY();
192
+ await new Promise(resolve => {
193
+ map.once('zoomend', resolve);
194
+ map.setView([48.85826, 2.2945], 19);
195
+ });
196
+ const panRetained = map._getMapPanePos().x !== 0;
197
+ map.setView([48.859, 2.294], 18, { animate: false });
198
+ const target = new L.LatLng(48.859, 2.294), size = map.getSize(), screenCenter = size.divideBy(2);
199
+ const center = map.project(target), angle = map.getBearing() * Math.PI / 180;
200
+ const pitch = map.getPitch() * Math.PI / 180, perspective = size.y * 1.5;
201
+ const errors = [];
202
+ for (const [x, y] of [[-100, -100], [100, -100], [-100, 100], [100, 100]]) {
203
+ const ll = map.unproject(center.add([x, y]));
204
+ const rx = x * Math.cos(angle) - y * Math.sin(angle);
205
+ const ry = x * Math.sin(angle) + y * Math.cos(angle);
206
+ const scale = perspective / (perspective - ry * Math.sin(pitch));
207
+ const expected = screenCenter.add([rx * scale, ry * Math.cos(pitch) * scale]);
208
+ errors.push(map.latLngToContainerPoint(ll).distanceTo(expected));
209
+ }
210
+ const farAfter = map._getPitchFarY(), pane = map._getMapPanePos().clone();
211
+ const centerError = map.latLngToContainerPoint(target).distanceTo(screenCenter);
212
+ layer.remove();
213
+ return { errors, farBefore, farAfter, pane, centerError, panRetained };
214
+ });
215
+ assert.equal(result.panRetained, true, 'Exercise animated zoom without resetting the accumulated pan');
216
+ assert.deepEqual(result.pane, { x: 0, y: 0 });
217
+ assert.ok(result.centerError < 1);
218
+ assert.ok(Math.max(...result.errors) < 1, `Reset ground projection: ${JSON.stringify(result)}`);
219
+ assert.ok(Math.abs(result.farAfter - result.farBefore) < 1, 'Resetting the view must not move the horizon');
220
+ });
221
+
222
+
223
+ Then('raster tiles remain aligned after panning before their first pitch change', async function () {
224
+ const result = await this.page.evaluate(async () => {
225
+ const map = testPitch.map;
226
+ map.getContainer().style.width = '1000px';
227
+ map.getContainer().style.height = '731px';
228
+ map.invalidateSize({ animate: false });
229
+ const layer = new L.GridLayer({ maxNativeZoom: 17 }).addTo(map);
230
+ const firstRotation = !layer._rotateOrigin;
231
+ map.setZoom(18, { animate: false });
232
+ for (let i = 0; i < 5; i++) map.panBy([-250, -150], { animate: false });
233
+ const center = map.getCenter();
234
+ map._setPitchAndBearing(85, 46.43810536434438);
235
+ await new Promise(resolve => setTimeout(resolve, 300));
236
+ const errors = [];
237
+ let missing = 0;
238
+ const size = map.getSize(), farY = map._getPitchFarY(), level = layer._levels[layer._tileZoom];
239
+ for (let y = Math.max(0, farY) + 10; y < size.y; y += 60) {
240
+ for (let x = 20; x < size.x; x += 100) {
241
+ const point = new L.Point(x, y), location = map.containerPointToLatLng(point);
242
+ const projected = map.project(location, level.zoom);
243
+ const coords = projected.divideBy(256).floor();
244
+ if (!layer._tiles[`${coords.x}:${coords.y}:${level.zoom}`]) missing++;
245
+ const position = projected.subtract(level.origin);
246
+ const reference = document.createElement('div');
247
+ reference.style.cssText = `position:absolute;width:0;height:0;left:${position.x}px;top:${position.y}px`;
248
+ level.el.appendChild(reference);
249
+ const rect = reference.getBoundingClientRect(), container = map.getContainer().getBoundingClientRect();
250
+ errors.push(Math.hypot(rect.left - container.left - x, rect.top - container.top - y));
251
+ reference.remove();
252
+ }
253
+ }
254
+ const stableCenter = map.project(center).distanceTo(map.project(map.getCenter()));
255
+ const pane = map._getMapPanePos();
256
+ layer.remove();
257
+ return { firstRotation, stableCenter, errors, missing, pane };
258
+ });
259
+ assert.equal(result.firstRotation, true, 'Exercise the first rotation after layer addition');
260
+ assert.ok(Math.abs(result.pane.x) > 1000 && Math.abs(result.pane.y) > 700);
261
+ assert.ok(result.stableCenter < 0.01);
262
+ assert.equal(result.missing, 0, 'Visible ground tiles remain present');
263
+ assert.ok(Math.max(...result.errors) < 2, `Rendered tile alignment: ${JSON.stringify(result)}`);
264
+ });
@@ -84,6 +84,28 @@ When('I set the map pitch to 45 degrees', async function () {
84
84
  roundTripPoint = map.containerPointToLayerPoint(pitched),
85
85
  centerPoint = map.latLngToContainerPoint(map.getCenter()),
86
86
  size = map.getSize();
87
+ const css = new DOMMatrix(getComputedStyle(map._rotatePane).transform),
88
+ pivot = map._getPixelCenter(),
89
+ origin = map._rotatePane._leaflet_pos,
90
+ angle = Math.PI / 4,
91
+ bearing = map._bearing,
92
+ perspective = size.y * 1.5,
93
+ container = map.getContainer().getBoundingClientRect(),
94
+ panePos = map._getMapPanePos(),
95
+ projectionError = Math.max(...[[0, 0], [size.x, 0], [0, size.y], [size.x, size.y]].map(([x, y]) => {
96
+ const dx = x + origin.x - pivot.x,
97
+ dy = y + origin.y - pivot.y,
98
+ rotatedX = dx * Math.cos(bearing) - dy * Math.sin(bearing),
99
+ rotatedY = dx * Math.sin(bearing) + dy * Math.cos(bearing),
100
+ factor = perspective / (perspective - rotatedY * Math.sin(angle)),
101
+ probe = document.createElement('div');
102
+ probe.style.cssText = `position:absolute;left:${x}px;top:${y}px;width:0;height:0`;
103
+ map._rotatePane.appendChild(probe);
104
+ const actual = probe.getBoundingClientRect();
105
+ probe.remove();
106
+ return Math.hypot(actual.x - container.left - map.getContainer().clientLeft - panePos.x - pivot.x - rotatedX * factor,
107
+ actual.y - container.top - map.getContainer().clientTop - panePos.y - pivot.y - rotatedY * Math.cos(angle) * factor);
108
+ }));
87
109
 
88
110
  return {
89
111
  pitch: map.getPitch(),
@@ -95,18 +117,20 @@ When('I set the map pitch to 45 degrees', async function () {
95
117
  roundTripPoint,
96
118
  centerPoint,
97
119
  size,
98
- transform: map._rotatePane.style.transform,
120
+ projectionError,
121
+ groundDepth: [css.m13, css.m23, css.m43],
99
122
  };
100
123
  });
101
124
  });
102
125
 
103
126
  Then('map coordinates stay consistent with the pitched view', function () {
104
- const { pitch, maxPitch, bearing, flat, pitched, layerPoint, roundTripPoint, centerPoint, size, transform } = this.pitchProjection;
127
+ const { pitch, maxPitch, bearing, flat, pitched, layerPoint, roundTripPoint, centerPoint, size,
128
+ projectionError, groundDepth } = this.pitchProjection;
105
129
  assert.equal(pitch, 45);
106
130
  assert.equal(maxPitch, 85);
107
131
  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);
132
+ assert.ok(projectionError < 0.01, `CSS pitch projection error: ${projectionError}px`);
133
+ assert.deepEqual(groundDepth, [0, 0, 0], 'Ground must not occlude upright overlays');
110
134
  assert.ok(Math.abs(flat.y - pitched.y) > 0.1);
111
135
  const details = JSON.stringify({ flat, pitched, layerPoint, roundTripPoint, centerPoint, size });
112
136
  assert.ok(Math.abs(roundTripPoint.x - layerPoint.x) < 1e-6, details);
@@ -1,6 +1,6 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import { After, AfterAll, Before, BeforeAll, Given, setDefaultTimeout } from '@cucumber/cucumber';
3
- import { chromium } from 'playwright';
3
+ import { chromium, firefox } from 'playwright';
4
4
  import { startServer } from '../../build/server.js';
5
5
 
6
6
  setDefaultTimeout(10000);
@@ -9,22 +9,30 @@ let server;
9
9
  let browser;
10
10
  let baseUrl;
11
11
 
12
- BeforeAll(async () => {
12
+ BeforeAll({ timeout: 60000 }, async () => {
13
13
  server = await startServer(0);
14
14
  baseUrl = `http://127.0.0.1:${server.address().port}`;
15
- browser = await chromium.launch();
15
+ const testBrowser = process.env.TEST_BROWSER;
16
+ const [engine, options] = testBrowser === 'firefox'
17
+ // Windows throttles requestAnimationFrame in windows it considers occluded.
18
+ ? [firefox, { firefoxUserPrefs: { 'widget.windows.window_occlusion_tracking.enabled': false } }]
19
+ : [chromium, testBrowser === 'chrome' || testBrowser === 'msedge' ? { channel: testBrowser } : {}];
20
+ browser = await engine.launch(options);
16
21
  });
17
22
 
18
23
  AfterAll({ timeout: 30000 }, async () => {
19
24
  await browser.close();
20
- server.closeAllConnections();
21
- await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
25
+ await new Promise((resolve, reject) => {
26
+ server.close(error => error ? reject(error) : resolve());
27
+ server.closeAllConnections();
28
+ });
22
29
  });
23
30
 
24
- Before(async function () {
31
+ // @unit scenarios run in Node only and need no browser page.
32
+ Before({ tags: 'not @unit' }, async function () {
25
33
  this.baseUrl = baseUrl;
26
34
  this.context = await browser.newContext();
27
- await this.context.route(/.html$/, async route => {
35
+ await this.context.route(/\.html(?:\?|$)/, async route => {
28
36
  const response = await route.fetch();
29
37
  const version = process.env.LEAFLET_VERSION;
30
38
  let body = (await response.text())
@@ -37,6 +45,10 @@ Before(async function () {
37
45
  '<script src="https://unpkg.com/leaflet@1.9/dist/leaflet-src.js"></script>',
38
46
  '<script src="https://unpkg.com/leaflet@2.0.0-alpha.1/dist/leaflet-global-src.js"></script><script>globalThis.L = globalThis.leaflet;</script>'
39
47
  )
48
+ .replace(
49
+ '"leaflet": "https://unpkg.com/leaflet@1.9/dist/leaflet-src.js"',
50
+ '"leaflet": "https://unpkg.com/leaflet@2.0.0-alpha.1/dist/leaflet-global-src.js"'
51
+ )
40
52
  .replace('https://unpkg.com/leaflet@1.9/dist/leaflet.css', 'https://unpkg.com/leaflet@2.0.0-alpha.1/dist/leaflet.css');
41
53
  }
42
54
  await route.fulfill({ response, body, headers: response.headers() });
@@ -44,7 +56,7 @@ Before(async function () {
44
56
  this.page = await this.context.newPage();
45
57
  });
46
58
 
47
- After(async function () {
59
+ After({ tags: 'not @unit' }, async function () {
48
60
  await this.context.close();
49
61
  });
50
62