leaflet-rotate 0.5.2 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,195 @@
1
+ import assert from 'node:assert/strict';
2
+ import { After, Given, Then, When } from '@cucumber/cucumber';
3
+
4
+ After({ tags: '@navigation-bounds' }, function () {
5
+ assert.deepEqual(this.navigationErrors, [], 'No browser errors during navigation');
6
+ });
7
+
8
+ Given('a standalone map with navigation limits is open', { timeout: 30000 }, async function () {
9
+ this.navigationErrors = [];
10
+ this.page.on('pageerror', error => this.navigationErrors.push(error.message));
11
+ await this.page.clock.install();
12
+ const version = process.env.LEAFLET_VERSION === '2' ? '2.0.0-alpha.1' : '1.9.4';
13
+ const script = process.env.LEAFLET_VERSION === '2' ? 'leaflet-global-src.js' : 'leaflet-src.js';
14
+ await this.page.route('**/test/navigation-bounds.html', route => route.fulfill({
15
+ contentType: 'text/html',
16
+ body: `<!DOCTYPE html><html><head>
17
+ <link rel="stylesheet" href="https://unpkg.com/leaflet@${version}/dist/leaflet.css">
18
+ <script src="https://unpkg.com/leaflet@${version}/dist/${script}"></script>
19
+ <script>globalThis.L = globalThis.leaflet || globalThis.L;</script>
20
+ <script src="../dist/leaflet-rotate-src.js"></script>
21
+ </head><body style="margin:0"><div id="map" style="width:800px;height:600px"></div></body></html>`,
22
+ }));
23
+ await this.page.goto(`${this.baseUrl}/test/navigation-bounds.html`);
24
+ });
25
+
26
+ When('navigation rotation is {word} with bearing {int} and pitch {int}', async function (enabled, bearing, pitch) {
27
+ this.navigationOptions = { rotate: enabled === 'true', bearing, pitch };
28
+ await this.page.evaluate(options => {
29
+ window.map = new L.Map('map', {
30
+ ...options, center: [48.8582601, 2.29449985], zoom: 17,
31
+ minZoom: 14, maxZoom: 20, maxBounds: [[48.8162, 2.2424], [48.9022, 2.416]],
32
+ maxBoundsViscosity: 1, inertia: false, zoomAnimation: false,
33
+ rotateControl: false, touchZoom: true, touchRotate: false, touchPitch: false,
34
+ bounceAtZoomLimits: false,
35
+ });
36
+ window.navigationSnapshot = () => {
37
+ const size = map.getSize();
38
+ const [min, max] = map._rotate ? map._getPitchedContainerBounds(0) : [new L.Point(0, 0), size];
39
+ const footprint = new L.Bounds([
40
+ [min.x, min.y], [max.x, min.y], [min.x, max.y], [max.x, max.y],
41
+ ].map(point => map.project(map.containerPointToLatLng(point))));
42
+ const bounds = map.options.maxBounds;
43
+ const limits = new L.Bounds(map.project(bounds.getNorthWest()), map.project(bounds.getSouthEast()));
44
+ const errors = ['x', 'y'].map(axis => {
45
+ if (footprint.getSize()[axis] > limits.getSize()[axis]) {
46
+ // Leaflet centers an oversized viewport, leaving equal overhang on both sides.
47
+ return Math.abs(footprint.getCenter()[axis] - limits.getCenter()[axis]);
48
+ }
49
+ return Math.max(0, limits.min[axis] - footprint.min[axis], footprint.max[axis] - limits.max[axis]);
50
+ });
51
+ return { error: Math.max(...errors), center: map.getCenter(), zoom: map.getZoom(),
52
+ footprint: { min: footprint.min, max: footprint.max }, limits: { min: limits.min, max: limits.max } };
53
+ };
54
+ }, this.navigationOptions);
55
+ });
56
+
57
+ Then('minimum zoom 14 applies to view changes, wheel zoom and pinch zoom', async function () {
58
+ const zooms = await this.page.evaluate(async () => {
59
+ const zooms = [];
60
+ for (const operation of [
61
+ () => map.setView(map.getCenter(), 1, { animate: false }),
62
+ () => map.setZoom(1, { animate: false }),
63
+ () => map.zoomOut(10),
64
+ () => map.setZoomAround([200, 400], 1),
65
+ () => map.fitBounds([[40, -10], [55, 10]], { animate: false }),
66
+ ]) {
67
+ map.setZoom(17, { animate: false });
68
+ operation();
69
+ zooms.push(map.getZoom());
70
+ }
71
+ map.setZoom(14, { animate: false });
72
+ return zooms;
73
+ });
74
+ assert.deepEqual(zooms.slice(0, 4), [14, 14, 14, 14]);
75
+ assert.ok(zooms[4] >= 14, `fitBounds zoom: ${zooms[4]}`);
76
+ await this.page.mouse.move(400, 300);
77
+ await this.page.mouse.wheel(0, 2000);
78
+ await this.page.waitForTimeout(200);
79
+ assert.equal(await this.page.evaluate(() => map.getZoom()), 14);
80
+ const pinch = await this.page.evaluate(async () => {
81
+ const touch = (x, id) => new Touch({ identifier: id, target: map.getContainer(), clientX: x, clientY: 300 });
82
+ const dispatch = (type, xs) => map.getContainer().dispatchEvent(new TouchEvent(type, {
83
+ bubbles: true, cancelable: true, touches: xs.map((x, id) => touch(x, id)),
84
+ }));
85
+ dispatch('touchstart', [200, 600]);
86
+ dispatch('touchmove', [350, 450]);
87
+ const during = map.getZoom();
88
+ dispatch('touchend', []);
89
+ await new Promise(requestAnimationFrame);
90
+ return { during, after: map.getZoom() };
91
+ });
92
+ assert.deepEqual(pinch, { during: 14, after: 14 });
93
+ });
94
+
95
+ Then('maximum bounds constrain programmatic pans and camera changes', { timeout: 30000 }, async function () {
96
+ const checks = await this.page.evaluate(async () => {
97
+ const checks = [];
98
+ const record = name => checks.push({ name, ...navigationSnapshot() });
99
+ const settle = async () => {
100
+ while (map._panAnim?._inProgress) await new Promise(requestAnimationFrame);
101
+ };
102
+ for (const location of [[48.92, 2.20], [48.92, 2.46], [48.78, 2.20], [48.78, 2.46]]) {
103
+ map.setView(location, 17, { animate: false });
104
+ await settle();
105
+ record('setView');
106
+ map.panTo(location, { animate: false });
107
+ await settle();
108
+ record('panTo');
109
+ }
110
+ for (const offset of [[2000, 0], [-2000, 0], [0, 2000], [0, -2000]]) {
111
+ map.panBy(offset, { animate: false });
112
+ await settle();
113
+ record('panBy');
114
+ const limited = map._limitOffset(new L.Point(...offset), map.options.maxBounds);
115
+ map.panBy(limited, { animate: false });
116
+ await settle();
117
+ record('inertia offset');
118
+ }
119
+ if (map._rotate) {
120
+ for (const [bearing, pitch] of [[45, 0], [135, 45], [270, 85], [0, 0]]) {
121
+ map.setBearing(bearing);
122
+ map.setPitch(pitch);
123
+ await settle();
124
+ record('camera change');
125
+ }
126
+ }
127
+ map.setZoom(14, { animate: false });
128
+ await settle();
129
+ record('minimum zoom / oversized viewport');
130
+ return checks;
131
+ });
132
+ for (const check of checks) assert.ok(check.error <= 2, JSON.stringify(check));
133
+ });
134
+
135
+ async function drag(page, offset, viscosity, inertia = false) {
136
+ await page.evaluate(({ viscosity, inertia }) => {
137
+ map.options.maxBoundsViscosity = viscosity;
138
+ map.options.inertia = inertia;
139
+ map.setView([48.8582601, 2.29449985], 15, { animate: false });
140
+ window.navigationDragFrames = [];
141
+ window.navigationInertiaFrames = [];
142
+ window.navigationReleased = false;
143
+ map.once('dragend', () => { window.navigationReleased = true; });
144
+ window.navigationMoveDone = new Promise(resolve => map.once('moveend', resolve));
145
+ map.off('drag', window.recordNavigationDrag);
146
+ map.off('move', window.recordNavigationInertia);
147
+ window.recordNavigationDrag = () => navigationDragFrames.push(navigationSnapshot());
148
+ window.recordNavigationInertia = () => {
149
+ if (navigationReleased) navigationInertiaFrames.push(navigationSnapshot());
150
+ };
151
+ map.on('drag', recordNavigationDrag);
152
+ map.on('move', recordNavigationInertia);
153
+ }, { viscosity, inertia });
154
+ if (inertia) {
155
+ const now = await page.evaluate(() => Date.now());
156
+ await page.clock.pauseAt(now + 100);
157
+ }
158
+ await page.mouse.move(320, 450);
159
+ await page.mouse.down();
160
+ for (let step = 1; step <= 8; step++) {
161
+ await page.mouse.move(320 + offset[0] * step / 8, 450 + offset[1] * step / 8);
162
+ if (inertia) await page.clock.runFor(16);
163
+ else if (step < 8) await page.waitForTimeout(10);
164
+ }
165
+ await page.mouse.up();
166
+ if (inertia) await page.clock.resume();
167
+ await page.evaluate(() => navigationMoveDone);
168
+ await page.waitForFunction(() => !map._panAnim?._inProgress && !map.dragging.moving());
169
+ return { during: await page.evaluate(() => navigationDragFrames), after: await page.evaluate(() => navigationSnapshot()),
170
+ inertia: await page.evaluate(() => navigationInertiaFrames) };
171
+ }
172
+
173
+ Then('viscosity 1 prevents crossing maximum bounds during a mouse drag', { timeout: 30000 }, async function () {
174
+ for (const offset of [[3000, 0], [-3000, 0], [0, 900], [0, -100]]) {
175
+ const result = await drag(this.page, offset, 1);
176
+ assert.ok(result.during.length > 0, 'Expected actual mouse drag frames');
177
+ for (const frame of [...result.during, result.after]) assert.ok(frame.error <= 2, JSON.stringify(frame));
178
+ }
179
+ });
180
+
181
+ Then('viscosities 0 and 0.5 allow progressively less overshoot', { timeout: 30000 }, async function () {
182
+ const free = await drag(this.page, [3000, 0], 0);
183
+ const viscous = await drag(this.page, [3000, 0], 0.5);
184
+ const freeError = free.during.at(-1).error;
185
+ const viscousError = viscous.during.at(-1).error;
186
+ assert.ok(freeError > 20, JSON.stringify(free));
187
+ assert.ok(viscousError > 2 && viscousError < freeError, JSON.stringify({ freeError, viscousError }));
188
+ for (const result of [free, viscous]) assert.ok(result.after.error <= 2, JSON.stringify(result.after));
189
+ });
190
+
191
+ Then('maximum bounds are restored after drag inertia', { timeout: 30000 }, async function () {
192
+ const result = await drag(this.page, [80, 20], 1, true);
193
+ assert.ok(result.inertia.length > 0, 'Expected movement after releasing the drag');
194
+ assert.ok(result.after.error <= 2, JSON.stringify(result.after));
195
+ });
@@ -425,3 +425,47 @@ Then('the second map center remains unchanged and its popup stays anchored', fun
425
425
  assert.ok(Math.hypot(...this.secondaryPopup.delta) < 2, JSON.stringify(this.secondaryPopup));
426
426
  assert.ok(Math.hypot(...this.secondaryPopup.tooltipDelta) < 2, JSON.stringify(this.secondaryPopup));
427
427
  });
428
+
429
+ When('I add markers to a Canvas-preferred map at pitch {int}', async function (pitch) {
430
+ this.domMarkers = await this.page.evaluate(pitch => {
431
+ const container = document.createElement('div');
432
+ container.style.cssText = 'height: 300px; width: 500px';
433
+ document.body.appendChild(container);
434
+ const map = new L.Map(container, {
435
+ center: [55, 10], zoom: 6, rotate: true, bearing: 35, pitch, preferCanvas: true,
436
+ });
437
+ const marker = new L.Marker([55, 10], { draggable: true, rotation: Math.PI / 4 })
438
+ .bindPopup('DOM popup', { autoPan: false }).addTo(map);
439
+ const divMarker = new L.Marker([55.1, 10.1], { icon: new L.DivIcon({ html: 'Label' }) }).addTo(map);
440
+ const vector = new L.CircleMarker([55.2, 10.2]).addTo(map);
441
+ map.panBy([40, 20], { animate: false });
442
+ marker.setLatLng([55.3, 10.3]).setOpacity(0.5);
443
+ const element = marker.getElement();
444
+ const transform = new DOMMatrix(getComputedStyle(element).transform);
445
+ const position = L.DomUtil.getPosition(element).add(map._getMapPanePos());
446
+ const expected = map.latLngToContainerPoint(marker.getLatLng());
447
+ element.click();
448
+ const result = {
449
+ icon: element.tagName,
450
+ iconPane: element.parentNode === map.getPane('markerPane'),
451
+ shadow: marker._shadow.tagName,
452
+ divIcon: divMarker.getElement().tagName,
453
+ vectorCanvas: vector._renderer instanceof L.Canvas,
454
+ aligned: position.distanceTo(expected) < 1,
455
+ rotation: Math.abs(Math.atan2(transform.b, transform.a) - Math.PI / 4) < 1e-6,
456
+ opacity: element.style.opacity,
457
+ draggable: marker.dragging.enabled(),
458
+ popup: marker.isPopupOpen(),
459
+ };
460
+ marker.remove();
461
+ result.removed = !element.isConnected && !marker.getElement();
462
+ return result;
463
+ }, pitch);
464
+ });
465
+
466
+ Then('the markers use Leaflet DOM icons while vector paths use Canvas', function () {
467
+ assert.deepEqual(this.domMarkers, {
468
+ icon: 'IMG', iconPane: true, shadow: 'IMG', divIcon: 'DIV', vectorCanvas: true,
469
+ aligned: true, rotation: true, opacity: '0.5', draggable: true, popup: true, removed: true,
470
+ });
471
+ });
@@ -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,19 +9,27 @@ 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
35
  await this.context.route(/.html$/, async route => {
@@ -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