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.
- package/README.md +2 -2
- package/build/server.js +19 -3
- package/dist/leaflet-rotate-src.js +229 -75
- package/dist/leaflet-rotate-src.js.map +4 -4
- package/dist/leaflet-rotate.js +1 -1
- package/dist/leaflet-rotate.js.map +4 -4
- package/lib/debug.js +3 -0
- package/lib/debug.min.js +1 -1
- package/package.json +1 -1
- package/src/dom/Draggable.js +20 -8
- package/src/index.js +1 -0
- package/src/layer/marker/Marker.js +24 -11
- package/src/layer/tile/GridLayer.js +16 -1
- package/src/map/Map.js +154 -60
- package/src/map/handler/Drag.js +59 -0
- package/test/features/example.feature +5 -0
- package/test/features/harness.feature +84 -0
- package/test/features/navigation-bounds.feature +33 -0
- package/test/features/overlays.feature +25 -0
- package/test/features/pitch.feature +19 -0
- package/test/index.html +90 -11
- package/test/steps/example.steps.js +18 -0
- package/test/steps/harness.steps.js +207 -0
- package/test/steps/navigation-bounds.steps.js +195 -0
- package/test/steps/overlays.steps.js +186 -0
- package/test/steps/pitch.steps.js +264 -0
- package/test/steps/rotation.steps.js +28 -4
- package/test/support/hooks.js +20 -8
|
@@ -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
|
-
|
|
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,
|
|
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.
|
|
109
|
-
assert.
|
|
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);
|
package/test/support/hooks.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
25
|
+
await new Promise((resolve, reject) => {
|
|
26
|
+
server.close(error => error ? reject(error) : resolve());
|
|
27
|
+
server.closeAllConnections();
|
|
28
|
+
});
|
|
22
29
|
});
|
|
23
30
|
|
|
24
|
-
|
|
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(
|
|
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
|
|