leaflet-rotate 0.5.3 → 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/dist/leaflet-rotate-src.js +21 -7
- package/dist/leaflet-rotate-src.js.map +2 -2
- package/dist/leaflet-rotate.js +1 -1
- package/dist/leaflet-rotate.js.map +3 -3
- 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/map/Map.js +6 -4
- package/test/features/example.feature +5 -0
- package/test/features/harness.feature +84 -0
- package/test/features/overlays.feature +16 -0
- package/test/index.html +20 -5
- package/test/steps/example.steps.js +18 -0
- package/test/steps/harness.steps.js +207 -0
- package/test/steps/overlays.steps.js +142 -0
- package/test/support/hooks.js +1 -1
package/lib/debug.js
CHANGED
package/lib/debug.min.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
(function(){const e=Object.isExtensible(globalThis.L)?globalThis.L:Object.assign({},globalThis.L);globalThis.L=e,e.Rotate={debug(t,o={}){this._map=t,document.head.insertAdjacentHTML("beforeend",'<style>.leaflet-rotate-tools{display:flex;flex-wrap:wrap;align-items:center;gap:4px 16px;margin-bottom:6px}.leaflet-rotate-tools input,.leaflet-rotate-tools button{vertical-align:middle}.leaflet-rotate-info{border-collapse:collapse}.leaflet-rotate-info td,.leaflet-rotate-info th{padding:1px 12px 1px 0;text-align:left;white-space:nowrap;vertical-align:top}.leaflet-rotate-info td:nth-of-type(1),.leaflet-rotate-info td:nth-of-type(2){font-family:monospace;min-width:12ch}.leaflet-rotate-info tr[title]{cursor:help}div.crosshair{position:absolute;width:1px;height:1px;border:1px solid green;z-index:100;left:50%;right:50%}div.pivot,div.pixelorigin{position:absolute;width:1px;height:1px;border:5px solid yellow;border-radius:100%;z-index:1000}div.pivot:before{left:-1.5em;bottom:-2em;position:absolute;content:"PIVOT";color:#ff0}div.pixelorigin:before{left:-1.75em;top:-2em;position:absolute;content:"ORIGIN";color:#ff0}div.panebounds.main{position:absolute;width:800px;height:600px;box-sizing:border-box;border:1px solid red;border-bottom:1px red dashed;border-right:1px red dashed;z-index:100}div.panebounds.rotate{position:absolute;width:800px;height:600px;box-sizing:border-box;border:1px solid purple;border-bottom:1px purple dashed;border-right:1px purple dashed;z-index:100}div.crosshair.c1{left:calc(50% - 5px);top:50%;height:0;width:10px}div.crosshair.c2{left:50%;top:calc(50% - 5px);width:0;height:10px}#rho_input{vertical-align:middle}.panebounds:before{padding:1ch}.panebounds.rotate:before{float:right;content:"ROTATED"}.panebounds.main:before{float:left;content:"MAIN"}</style>'),t._container.insertAdjacentHTML("afterbegin",'<div class="crosshair c1"></div><div class="crosshair c2"></div>');const i=`
|
|
1
|
+
(function(){const e=Object.isExtensible(globalThis.L)?globalThis.L:Object.assign({},globalThis.L);globalThis.L=e,e.Rotate={debug(t,o={}){this._map=t,document.head.insertAdjacentHTML("beforeend",'<style>.leaflet-rotate-tools{display:flex;flex-wrap:wrap;align-items:center;gap:4px 16px;margin-bottom:6px}.leaflet-rotate-tools input,.leaflet-rotate-tools button{vertical-align:middle}.leaflet-rotate-info{border-collapse:collapse}.leaflet-rotate-info td,.leaflet-rotate-info th{padding:1px 12px 1px 0;text-align:left;white-space:nowrap;vertical-align:top}.leaflet-rotate-info td:nth-of-type(1),.leaflet-rotate-info td:nth-of-type(2){font-family:monospace;min-width:12ch}.leaflet-rotate-info tr[title]{cursor:help}div.crosshair,div.pivot,div.pixelorigin,div.panebounds{pointer-events:none}div.crosshair{position:absolute;width:1px;height:1px;border:1px solid green;z-index:100;left:50%;right:50%}div.pivot,div.pixelorigin{position:absolute;width:1px;height:1px;border:5px solid yellow;border-radius:100%;z-index:1000}div.pivot:before{left:-1.5em;bottom:-2em;position:absolute;content:"PIVOT";color:#ff0}div.pixelorigin:before{left:-1.75em;top:-2em;position:absolute;content:"ORIGIN";color:#ff0}div.panebounds.main{position:absolute;width:800px;height:600px;box-sizing:border-box;border:1px solid red;border-bottom:1px red dashed;border-right:1px red dashed;z-index:100}div.panebounds.rotate{position:absolute;width:800px;height:600px;box-sizing:border-box;border:1px solid purple;border-bottom:1px purple dashed;border-right:1px purple dashed;z-index:100}div.crosshair.c1{left:calc(50% - 5px);top:50%;height:0;width:10px}div.crosshair.c2{left:50%;top:calc(50% - 5px);width:0;height:10px}#rho_input{vertical-align:middle}.panebounds:before{padding:1ch}.panebounds.rotate:before{float:right;content:"ROTATED"}.panebounds.main:before{float:left;content:"MAIN"}</style>'),t._container.insertAdjacentHTML("afterbegin",'<div class="crosshair c1"></div><div class="crosshair c2"></div>');const i=`
|
|
2
2
|
<div class="leaflet-rotate-tools">
|
|
3
3
|
<span>Bearing
|
|
4
4
|
${[0,15,30,45,60,75,90,180].map(n=>`<button onclick="L.Rotate._map.setBearing(${n});" title="map.setBearing(${n});">${n}</button>`).join("")}
|
package/package.json
CHANGED
package/src/dom/Draggable.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Preserve the final position when an active drag returns within its starting
|
|
3
|
+
* click tolerance. Marker geographic coordinates are adapted in `L.Marker`.
|
|
4
4
|
*
|
|
5
5
|
* @external L.Draggable
|
|
6
6
|
*
|
|
@@ -15,11 +15,23 @@
|
|
|
15
15
|
* @since leaflet-rotate (v0.1.0)
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
L.Draggable.
|
|
19
|
-
|
|
20
|
-
/** @TODO */
|
|
21
|
-
// updateMapBearing(mapBearing) {
|
|
22
|
-
// this._mapBearing = mapBearing;
|
|
23
|
-
// },
|
|
18
|
+
const draggableProto = Object.defineProperties({}, Object.getOwnPropertyDescriptors(L.Draggable.prototype));
|
|
24
19
|
|
|
20
|
+
L.Draggable.include({
|
|
21
|
+
_onMove(e) {
|
|
22
|
+
const pointer = e.touches ? e.touches[0] : e;
|
|
23
|
+
if (this._enabled && this._moving && (!e.touches || e.touches.length === 1)) {
|
|
24
|
+
const offset = new L.Point(pointer.clientX, pointer.clientY).subtract(this._startPoint);
|
|
25
|
+
if ((!offset.x && !offset.y) || Math.abs(offset.x) + Math.abs(offset.y) < this.options.clickTolerance) {
|
|
26
|
+
L.DomEvent.preventDefault(e);
|
|
27
|
+
this._newPos = this._startPos.add(new L.Point(
|
|
28
|
+
offset.x / this._parentScale.x, offset.y / this._parentScale.y
|
|
29
|
+
));
|
|
30
|
+
this._lastEvent = e;
|
|
31
|
+
this._updatePosition();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return draggableProto._onMove.apply(this, arguments);
|
|
36
|
+
},
|
|
25
37
|
});
|
package/src/map/Map.js
CHANGED
|
@@ -932,10 +932,11 @@ L.Map.include({
|
|
|
932
932
|
* @since leaflet-rotate (v0.1.4)
|
|
933
933
|
*/
|
|
934
934
|
panInside(latlng, options) {
|
|
935
|
-
if (!this._rotate
|
|
935
|
+
if (!this._rotate) {
|
|
936
936
|
return mapProto.panInside.apply(this, arguments);
|
|
937
937
|
}
|
|
938
938
|
|
|
939
|
+
// The rotate pane can retain an offset after returning to bearing zero.
|
|
939
940
|
options = options || {};
|
|
940
941
|
|
|
941
942
|
const paddingTL = toPoint(options.paddingTopLeft || options.padding || [0, 0]),
|
|
@@ -1179,7 +1180,8 @@ L.Map.include({
|
|
|
1179
1180
|
opposite = layerCenter.multiplyBy(2).subtract(this.project(latlng).subtract(this._pixelOrigin));
|
|
1180
1181
|
return center.subtract(this.layerPointToContainerPoint(opposite));
|
|
1181
1182
|
}
|
|
1182
|
-
return this.
|
|
1183
|
+
return this.layerPointToContainerPoint(this.project(latlng).subtract(this._pixelOrigin))
|
|
1184
|
+
._subtract(this.getSize()._divideBy(2));
|
|
1183
1185
|
},
|
|
1184
1186
|
|
|
1185
1187
|
/**
|
|
@@ -1215,9 +1217,9 @@ L.Map.include({
|
|
|
1215
1217
|
}
|
|
1216
1218
|
// the layer point under the view center doesn't depend on zoom (pane transforms only rotate/pitch around the pivot),
|
|
1217
1219
|
// so this matches `_setBearing` also when the pivot is no longer the view center (eg. after panning)
|
|
1220
|
+
// Keep subpixel centers exact: wheel bursts magnify rounding from earlier zooms.
|
|
1218
1221
|
return this.project(center, zoom)
|
|
1219
|
-
._subtract(this.containerPointToLayerPoint(this.getSize()._divideBy(2)))
|
|
1220
|
-
._round();
|
|
1222
|
+
._subtract(this.containerPointToLayerPoint(this.getSize()._divideBy(2)));
|
|
1221
1223
|
},
|
|
1222
1224
|
|
|
1223
1225
|
/**
|
|
@@ -51,6 +51,11 @@ Feature: Rotating a Leaflet map
|
|
|
51
51
|
When I inspect debugger geometry after resizing and moving the map
|
|
52
52
|
Then the debugger reports the current map geometry
|
|
53
53
|
|
|
54
|
+
@debugger @issue-76
|
|
55
|
+
Scenario: Debugger overlays do not intercept marker clicks
|
|
56
|
+
When I place the debugger overlays over a marker
|
|
57
|
+
Then the marker receives the pointer events
|
|
58
|
+
|
|
54
59
|
@example-ui
|
|
55
60
|
Scenario: Example base-layer radios select one layer
|
|
56
61
|
When I switch between the example base layers
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Feature: Interactive regression scenarios have executable BDD coverage
|
|
2
|
+
Background:
|
|
3
|
+
Given the interactive regression page is open
|
|
4
|
+
|
|
5
|
+
Scenario: Every interactive scenario has a BDD example
|
|
6
|
+
Then every interactive scenario is listed in the BDD examples
|
|
7
|
+
|
|
8
|
+
Scenario: An active drag can finish within its starting click tolerance
|
|
9
|
+
Then active drags return to their starting position without turning clicks into drags
|
|
10
|
+
|
|
11
|
+
Scenario Outline: Camera gestures retain their intended behavior
|
|
12
|
+
When I run the interactive scenario "<scenario>"
|
|
13
|
+
Then the interactive camera result is "<result>"
|
|
14
|
+
|
|
15
|
+
Examples:
|
|
16
|
+
| scenario | result |
|
|
17
|
+
| Ctrl+drag up: pitch from center | pitch up |
|
|
18
|
+
| Ctrl+drag down: pitch clamps at 0 | pitch zero |
|
|
19
|
+
| Ctrl+drag far up: pitch clamps at max | pitch max |
|
|
20
|
+
| Ctrl+drag right, just above center | clockwise |
|
|
21
|
+
| Ctrl+drag right, just below center | counterclockwise |
|
|
22
|
+
| Ctrl+drag right, far above center (angular) | clockwise |
|
|
23
|
+
| Ctrl+drag right, far below center (angular) | counterclockwise |
|
|
24
|
+
| Ctrl+drag diagonal, pitched + rotated | pitch and bearing |
|
|
25
|
+
| Ctrl+drag fast (2 big steps) | pitch and bearing |
|
|
26
|
+
| Ctrl+drag full circle around center | pitch and bearing |
|
|
27
|
+
| Ctrl+drag leaving the map | pitch and bearing |
|
|
28
|
+
| Ctrl+drag starting on a marker | pitch and bearing |
|
|
29
|
+
| Ctrl released mid-drag | pitch and bearing |
|
|
30
|
+
| Ctrl pressed mid-pan (should keep panning) | pan |
|
|
31
|
+
| Right-drag up + right | pitch and bearing |
|
|
32
|
+
| Ctrl+drag then plain pan | rotate then pan |
|
|
33
|
+
| Shift+wheel rotate | shift rotate |
|
|
34
|
+
| Wheel then Ctrl+drag while zoom pending | cancel zoom |
|
|
35
|
+
| Wheel during Ctrl+drag | zoom and rotate |
|
|
36
|
+
|
|
37
|
+
Scenario Outline: Wheel input zooms around its geographic anchor
|
|
38
|
+
When I run the interactive scenario "<scenario>"
|
|
39
|
+
Then the interactive camera result is "<result>"
|
|
40
|
+
|
|
41
|
+
Examples:
|
|
42
|
+
| scenario | result |
|
|
43
|
+
| Wheel in at top-right, flat (control case) | zoom in |
|
|
44
|
+
| Wheel in at top-right, rotated | zoom in |
|
|
45
|
+
| Wheel in at top-right, pitch 60 | zoom in |
|
|
46
|
+
| Wheel out at bottom-left, pitch 45 | zoom out |
|
|
47
|
+
| Wheel near horizon, pitch 75 | zoom in |
|
|
48
|
+
| Ctrl+wheel pinch-in burst (trackpad), pitch 50 | zoom in |
|
|
49
|
+
| Ctrl+wheel pinch-out burst (trackpad), rotated | zoom out |
|
|
50
|
+
| Ctrl+wheel mouse notches at center | zoom in |
|
|
51
|
+
| Low zoom rotated, wheel in (layer jump check) | zoom in |
|
|
52
|
+
|
|
53
|
+
Scenario Outline: Pitched drags retain their geographic anchor
|
|
54
|
+
When I run the interactive scenario "<scenario>"
|
|
55
|
+
Then the interactive camera result is "pan"
|
|
56
|
+
|
|
57
|
+
Examples:
|
|
58
|
+
| scenario |
|
|
59
|
+
| Pan pitched 70 |
|
|
60
|
+
| Pan pitched 70: vertical up |
|
|
61
|
+
| Pan pitched 70: vertical down |
|
|
62
|
+
| Pan pitched 70: horizontal left |
|
|
63
|
+
| Pan pitched 70: horizontal right |
|
|
64
|
+
| Pan pitched 70: diagonal up-right |
|
|
65
|
+
| Pan pitched 70: diagonal down-left |
|
|
66
|
+
| Pan pitched 70: reverse vertical drag |
|
|
67
|
+
| Pan pitched 70: multi-direction loop |
|
|
68
|
+
| Pan pitched 70, bearing 225: diagonal |
|
|
69
|
+
|
|
70
|
+
Scenario Outline: Navigation reaches its destination while pitched and rotated
|
|
71
|
+
When I run the interactive scenario "<scenario>"
|
|
72
|
+
Then the interactive camera result is "navigate"
|
|
73
|
+
|
|
74
|
+
Examples:
|
|
75
|
+
| scenario |
|
|
76
|
+
| Go to: Circle |
|
|
77
|
+
| Go to: Polygon |
|
|
78
|
+
| Go to: Path |
|
|
79
|
+
| Go to: Kyiv |
|
|
80
|
+
| Go to: London |
|
|
81
|
+
| Go to: San Francisco |
|
|
82
|
+
| Go to: Washington |
|
|
83
|
+
| Go to: Trondheim |
|
|
84
|
+
| Go to: Rectangle (fit from z 0) |
|
|
@@ -47,6 +47,22 @@ Feature: Rotating a Leaflet map
|
|
|
47
47
|
When I focus a marker near the edge of a rotated map
|
|
48
48
|
Then the map center remains unchanged
|
|
49
49
|
|
|
50
|
+
@issue-76
|
|
51
|
+
Scenario: Marker popups still work after dragging and resetting the bearing
|
|
52
|
+
When I drag between city markers and reset north before clicking Trondheim
|
|
53
|
+
Then the Trondheim popup opens without jumping back to London
|
|
54
|
+
|
|
55
|
+
Scenario Outline: PanInside preserves visibility and minimal movement near bearing zero
|
|
56
|
+
When I keep padded points visible with rotation <rotate> and bearing <bearing>
|
|
57
|
+
Then panInside moves only enough to reach the padded viewport
|
|
58
|
+
|
|
59
|
+
Examples:
|
|
60
|
+
| rotate | bearing |
|
|
61
|
+
| false | 0 |
|
|
62
|
+
| true | 0 |
|
|
63
|
+
| true | 2.8 |
|
|
64
|
+
| true | 2.9 |
|
|
65
|
+
|
|
50
66
|
@issue-51
|
|
51
67
|
Scenario: Opening an edge popup pans only enough to keep it visible
|
|
52
68
|
When I open a popup near the edge of a rotated map
|
package/test/index.html
CHANGED
|
@@ -293,6 +293,11 @@
|
|
|
293
293
|
|
|
294
294
|
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
295
295
|
const frame = () => new Promise(resolve => requestAnimationFrame(resolve));
|
|
296
|
+
const clientPoint = (el, fx, fy) => {
|
|
297
|
+
const r = el.getBoundingClientRect();
|
|
298
|
+
// MouseEvent client coordinates are integers; anchors must use the same pixels.
|
|
299
|
+
return { clientX: Math.round(r.left + el.clientLeft + el.clientWidth * fx), clientY: Math.round(r.top + el.clientTop + el.clientHeight * fy) };
|
|
300
|
+
};
|
|
296
301
|
/**
|
|
297
302
|
* Create synthetic mouse/pointer/wheel input for one renderer.
|
|
298
303
|
* Positions are container fractions (0..1 inside, outside values allowed).
|
|
@@ -308,8 +313,7 @@
|
|
|
308
313
|
let pressed = 0, button = 0;
|
|
309
314
|
/** Convert fractional container coordinates to viewport client pixels. */
|
|
310
315
|
const at = (fx, fy) => {
|
|
311
|
-
|
|
312
|
-
return { clientX: r.left + r.width * fx, clientY: r.top + r.height * fy };
|
|
316
|
+
return clientPoint(el, fx, fy);
|
|
313
317
|
};
|
|
314
318
|
/** Hit-test layers/markers; use body outside this map so document listeners still run. */
|
|
315
319
|
const target = ({ clientX, clientY }) => {
|
|
@@ -531,14 +535,25 @@
|
|
|
531
535
|
}
|
|
532
536
|
const anchor = scenario.anchor && (() => {
|
|
533
537
|
const r = m.el.getBoundingClientRect();
|
|
534
|
-
return scenario.anchor.map(([fx, fy]) =>
|
|
538
|
+
return scenario.anchor.map(([fx, fy]) => {
|
|
539
|
+
const p = clientPoint(m.el, fx, fy);
|
|
540
|
+
return [p.clientX - r.left - m.el.clientLeft, p.clientY - r.top - m.el.clientTop];
|
|
541
|
+
});
|
|
535
542
|
})();
|
|
536
|
-
|
|
543
|
+
let latlng = anchor && m.toLatLng(anchor[0]);
|
|
544
|
+
const input = driver(key), down = input.down;
|
|
545
|
+
if (anchor) {
|
|
546
|
+
input.down = async (...args) => {
|
|
547
|
+
// A composite rotation + pan anchors the point when the plain pan starts.
|
|
548
|
+
if (!args[2]?.ctrlKey && !args[2]?.button) latlng = m.toLatLng(anchor[0]);
|
|
549
|
+
await down(...args);
|
|
550
|
+
};
|
|
551
|
+
}
|
|
537
552
|
const inertia = key === 'lf' && scenario.noInertia ? lf.options.inertia : undefined;
|
|
538
553
|
if (inertia !== undefined) lf.options.inertia = false;
|
|
539
554
|
try {
|
|
540
555
|
if (scenario.goTo) await runGoTo(key, scenario.goTo);
|
|
541
|
-
else await scenario.run(
|
|
556
|
+
else await scenario.run(input);
|
|
542
557
|
if (key === 'ml' && scenario.noInertia) ml.stop();
|
|
543
558
|
await sleep(scenario.settle || 900);
|
|
544
559
|
} finally {
|
|
@@ -59,6 +59,24 @@ Then('every debugger rotation control matches the map bearing', function () {
|
|
|
59
59
|
});
|
|
60
60
|
});
|
|
61
61
|
|
|
62
|
+
When('I place the debugger overlays over a marker', async function () {
|
|
63
|
+
this.markerHit = await this.page.evaluate(() => {
|
|
64
|
+
map.setView(markers[0].getLatLng(), 6, { animate: false });
|
|
65
|
+
map.setBearing(0);
|
|
66
|
+
const icon = markers[0]._icon;
|
|
67
|
+
const box = icon.getBoundingClientRect();
|
|
68
|
+
const mapBox = map._mapPane.getBoundingClientRect();
|
|
69
|
+
const x = box.left + box.width / 2, y = box.top + box.height / 2;
|
|
70
|
+
map.___rotatePivotDot.style.left = `${x - mapBox.left - 5}px`;
|
|
71
|
+
map.___rotatePivotDot.style.top = `${y - mapBox.top - 5}px`;
|
|
72
|
+
return document.elementFromPoint(x, y) === icon;
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
Then('the marker receives the pointer events', function () {
|
|
77
|
+
assert.equal(this.markerHit, true);
|
|
78
|
+
});
|
|
79
|
+
|
|
62
80
|
When('I rotate a map with the default rotate control', async function () {
|
|
63
81
|
this.rotateControlVisibility = await this.page.evaluate(() => {
|
|
64
82
|
const container = document.createElement('div');
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { Given, Then, When } from '@cucumber/cucumber';
|
|
4
|
+
import { generateMessages } from '@cucumber/gherkin';
|
|
5
|
+
import { IdGenerator, SourceMediaType } from '@cucumber/messages';
|
|
6
|
+
|
|
7
|
+
Given('the interactive regression page is open', { timeout: 30000 }, async function () {
|
|
8
|
+
this.harnessErrors = [];
|
|
9
|
+
this.page.on('pageerror', error => this.harnessErrors.push(error.message));
|
|
10
|
+
await this.page.route(/server\.arcgisonline\.com|tile\.openstreetmap\.org/, route =>
|
|
11
|
+
route.fulfill({ contentType: 'image/svg+xml', body: '<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256"/>' }));
|
|
12
|
+
const version = process.env.LEAFLET_VERSION === '2' ? '2' : '1';
|
|
13
|
+
await this.page.goto(`${this.baseUrl}/test/index.html?leaflet=${version}&maplibre=0&debug=0`, {
|
|
14
|
+
waitUntil: 'domcontentloaded',
|
|
15
|
+
});
|
|
16
|
+
await this.page.waitForFunction(() => window.runScenario && window.scenarios);
|
|
17
|
+
await this.page.evaluate(() => { map.options.inertia = false; });
|
|
18
|
+
assert.match(await this.page.evaluate(() => L.version), version === '2' ? /^2\.0\.0-alpha/ : /^1\.9/);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
Then('every interactive scenario is listed in the BDD examples', async function () {
|
|
22
|
+
const source = await readFile(new URL('../features/harness.feature', import.meta.url), 'utf8');
|
|
23
|
+
const messages = generateMessages(source, 'harness.feature', SourceMediaType.TEXT_X_CUCUMBER_GHERKIN_PLAIN, {
|
|
24
|
+
includeGherkinDocument: true,
|
|
25
|
+
includePickles: false,
|
|
26
|
+
includeSource: false,
|
|
27
|
+
newId: IdGenerator.incrementing(),
|
|
28
|
+
});
|
|
29
|
+
const document = messages.find(message => message.gherkinDocument)?.gherkinDocument;
|
|
30
|
+
assert.ok(document, 'Could not parse harness.feature');
|
|
31
|
+
const examples = document.feature.children.flatMap(child =>
|
|
32
|
+
child.scenario?.examples.flatMap(example => {
|
|
33
|
+
const index = example.tableHeader.cells.findIndex(cell => cell.value === 'scenario');
|
|
34
|
+
return example.tableBody.map(row => row.cells[index].value);
|
|
35
|
+
}) || []);
|
|
36
|
+
const scenarios = await this.page.evaluate(() => scenarios.map(scenario => scenario.name));
|
|
37
|
+
assert.equal(new Set(examples).size, examples.length, 'Duplicate BDD examples');
|
|
38
|
+
assert.deepEqual(examples.sort(), scenarios.sort(), 'Interactive scenarios and BDD examples must match');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
Then('active drags return to their starting position without turning clicks into drags', async function () {
|
|
42
|
+
await this.page.evaluate(() => {
|
|
43
|
+
const parent = document.createElement('div');
|
|
44
|
+
parent.style.cssText = 'position:absolute;z-index:10000;left:100px;top:100px;width:100px;height:100px;transform:scale(2);transform-origin:0 0';
|
|
45
|
+
const element = document.createElement('div');
|
|
46
|
+
element.id = 'returning-drag';
|
|
47
|
+
element.style.cssText = 'width:20px;height:20px;background:red';
|
|
48
|
+
parent.append(element);
|
|
49
|
+
document.body.append(parent);
|
|
50
|
+
const draggable = new L.Draggable(element);
|
|
51
|
+
draggable.enable();
|
|
52
|
+
window.returningDrag = { draggable, parent, element, starts: 0 };
|
|
53
|
+
draggable.on('dragstart', () => returningDrag.starts++);
|
|
54
|
+
L.DomUtil.setPosition(element, new L.Point(0, 0));
|
|
55
|
+
});
|
|
56
|
+
for (const offset of [0, 1]) {
|
|
57
|
+
const rect = await this.page.locator('#returning-drag').boundingBox();
|
|
58
|
+
const start = { x: rect.x + 10, y: rect.y + 10 };
|
|
59
|
+
await this.page.mouse.move(start.x, start.y);
|
|
60
|
+
await this.page.mouse.down();
|
|
61
|
+
await this.page.mouse.move(start.x + 30, start.y + 30, { steps: 3 });
|
|
62
|
+
await this.page.mouse.move(start.x + offset, start.y);
|
|
63
|
+
await this.page.mouse.up();
|
|
64
|
+
const result = await this.page.evaluate(() => {
|
|
65
|
+
const { element, draggable, starts } = returningDrag;
|
|
66
|
+
const position = L.DomUtil.getPosition(element);
|
|
67
|
+
L.DomUtil.setPosition(element, new L.Point(0, 0));
|
|
68
|
+
return { x: position.x, y: position.y, starts, moving: draggable._moving };
|
|
69
|
+
});
|
|
70
|
+
assert.ok(Math.abs(result.x - offset / 2) < 1e-7, JSON.stringify(result));
|
|
71
|
+
assert.equal(result.y, 0, JSON.stringify(result));
|
|
72
|
+
assert.equal(result.starts, offset + 1, JSON.stringify(result));
|
|
73
|
+
assert.equal(result.moving, false, JSON.stringify(result));
|
|
74
|
+
}
|
|
75
|
+
const rect = await this.page.locator('#returning-drag').boundingBox();
|
|
76
|
+
await this.page.mouse.move(rect.x + 10, rect.y + 10);
|
|
77
|
+
await this.page.mouse.down();
|
|
78
|
+
await this.page.mouse.move(rect.x + 11, rect.y + 10);
|
|
79
|
+
await this.page.mouse.up();
|
|
80
|
+
const click = await this.page.evaluate(() => {
|
|
81
|
+
const { parent, element, draggable, starts } = returningDrag;
|
|
82
|
+
const position = L.DomUtil.getPosition(element);
|
|
83
|
+
draggable.disable();
|
|
84
|
+
parent.remove();
|
|
85
|
+
return { x: position.x, y: position.y, starts };
|
|
86
|
+
});
|
|
87
|
+
assert.deepEqual(click, { x: 0, y: 0, starts: 2 });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
When('I run the interactive scenario {string}', { timeout: 30000 }, async function (name) {
|
|
91
|
+
this.harnessResult = await this.page.evaluate(async name => {
|
|
92
|
+
const scenario = scenarios.find(scenario => scenario.name === name);
|
|
93
|
+
if (!scenario) throw new Error(`Unknown interactive scenario: ${name}`);
|
|
94
|
+
const initial = { lat: 50, lng: 5, zoom: 5, bearing: 0, pitch: 0, ...scenario.view };
|
|
95
|
+
map.setView([initial.lat, initial.lng], initial.zoom, { animate: false });
|
|
96
|
+
map.setPitch(initial.pitch);
|
|
97
|
+
map.setBearing(initial.bearing);
|
|
98
|
+
const markerLocations = markers.map(marker => marker.getLatLng());
|
|
99
|
+
const probe = new L.CircleMarker([initial.lat, initial.lng], { radius: 4 }).addTo(map);
|
|
100
|
+
const bearings = [];
|
|
101
|
+
const onRotate = () => bearings.push(map.getBearing());
|
|
102
|
+
map.on('rotate', onRotate);
|
|
103
|
+
const anchorProbe = () => {
|
|
104
|
+
if (scenario.anchor) {
|
|
105
|
+
const [x, y] = scenario.anchor[0];
|
|
106
|
+
probe.setLatLng(map.containerPointToLatLng([map.getSize().x * x, map.getSize().y * y]));
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
map.once('rotate', anchorProbe);
|
|
110
|
+
const results = await runScenario(scenario);
|
|
111
|
+
map.off('rotate', onRotate);
|
|
112
|
+
map.off('rotate', anchorProbe);
|
|
113
|
+
const [{ end, drift }] = results;
|
|
114
|
+
const center = map.project(map.getCenter());
|
|
115
|
+
if (scenario.goTo) probe.setLatLng(map.getCenter());
|
|
116
|
+
const expected = map.latLngToContainerPoint(probe.getLatLng());
|
|
117
|
+
const rect = probe.getElement().getBoundingClientRect();
|
|
118
|
+
const container = map.getContainer().getBoundingClientRect();
|
|
119
|
+
const vectorError = Math.hypot(
|
|
120
|
+
rect.left + rect.width / 2 - container.left - map.getContainer().clientLeft - expected.x,
|
|
121
|
+
rect.top + rect.height / 2 - container.top - map.getContainer().clientTop - expected.y,
|
|
122
|
+
);
|
|
123
|
+
probe.remove();
|
|
124
|
+
const destination = scenario.goTo;
|
|
125
|
+
const boundsLayer = { Circle: circle, Polygon: polygon, Path: path, 'Rectangle (fit from z 0)': rectangle }[destination];
|
|
126
|
+
return {
|
|
127
|
+
name, initial, end, drift, vectorError, bearings,
|
|
128
|
+
centerMovement: center.distanceTo(map.project([initial.lat, initial.lng], end.zoom)),
|
|
129
|
+
centerError: map.latLngToContainerPoint(map.getCenter()).distanceTo(map.getSize().divideBy(2)),
|
|
130
|
+
markersUnchanged: markers.every((marker, index) => marker.getLatLng().equals(markerLocations[index])),
|
|
131
|
+
dragging: Boolean(map.shiftKeyRotate._dragging || map.dragging._draggable._moving),
|
|
132
|
+
destinationDistance: destination && !boundsLayer ? map.getCenter().distanceTo(new L.LatLng(...places[destination])) : null,
|
|
133
|
+
boundsVisible: boundsLayer ? map.getBounds().contains(boundsLayer.getBounds()) : null,
|
|
134
|
+
};
|
|
135
|
+
}, name);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
Then('the interactive camera result is {string}', function (outcome) {
|
|
139
|
+
const r = this.harnessResult, details = JSON.stringify(r);
|
|
140
|
+
assert.deepEqual(this.harnessErrors, [], 'Interactive scenario raised a browser error');
|
|
141
|
+
assert.ok(Object.values(r.end).every(Number.isFinite), details);
|
|
142
|
+
assert.ok(r.end.pitch >= 0 && r.end.pitch <= 85, details);
|
|
143
|
+
assert.ok(r.end.bearing >= 0 && r.end.bearing < 360, details);
|
|
144
|
+
assert.equal(r.markersUnchanged, true, details);
|
|
145
|
+
assert.equal(r.dragging, false, details);
|
|
146
|
+
assert.ok(r.centerError < 1.5, details);
|
|
147
|
+
assert.ok(r.vectorError < 3, details);
|
|
148
|
+
if (r.drift !== null) assert.ok(r.drift < 3, details);
|
|
149
|
+
const bearingDelta = ((r.end.bearing - r.initial.bearing + 540) % 360) - 180;
|
|
150
|
+
if (['zoom in', 'zoom out', 'pan', 'navigate'].includes(outcome)) {
|
|
151
|
+
assert.ok(Math.abs(bearingDelta) < 0.1, details);
|
|
152
|
+
assert.ok(Math.abs(r.end.pitch - r.initial.pitch) < 0.1, details);
|
|
153
|
+
}
|
|
154
|
+
switch (outcome) {
|
|
155
|
+
case 'pitch up':
|
|
156
|
+
assert.ok(r.end.pitch > r.initial.pitch + 1, details);
|
|
157
|
+
assert.ok(Math.abs(bearingDelta) < 0.1, details);
|
|
158
|
+
break;
|
|
159
|
+
case 'pitch zero':
|
|
160
|
+
assert.equal(r.end.pitch, 0, details);
|
|
161
|
+
break;
|
|
162
|
+
case 'pitch max':
|
|
163
|
+
assert.ok(Math.abs(r.end.pitch - 85) < 0.1, details);
|
|
164
|
+
break;
|
|
165
|
+
case 'clockwise':
|
|
166
|
+
case 'counterclockwise':
|
|
167
|
+
assert.ok(outcome === 'clockwise' ? bearingDelta > 1 : bearingDelta < -1, details);
|
|
168
|
+
assert.equal(r.end.pitch, r.initial.pitch, details);
|
|
169
|
+
break;
|
|
170
|
+
case 'pitch and bearing':
|
|
171
|
+
case 'rotate then pan':
|
|
172
|
+
case 'zoom and rotate':
|
|
173
|
+
assert.ok(r.bearings.some(bearing => Math.abs(((bearing - r.initial.bearing + 540) % 360) - 180) > 1), details);
|
|
174
|
+
assert.ok(r.end.pitch > r.initial.pitch + 1, details);
|
|
175
|
+
if (outcome === 'zoom and rotate') assert.ok(r.end.zoom > r.initial.zoom, details);
|
|
176
|
+
break;
|
|
177
|
+
case 'shift rotate':
|
|
178
|
+
assert.ok(Math.abs(bearingDelta - 30) < 0.1, details);
|
|
179
|
+
assert.equal(r.end.pitch, r.initial.pitch, details);
|
|
180
|
+
break;
|
|
181
|
+
case 'cancel zoom':
|
|
182
|
+
assert.equal(r.end.zoom, r.initial.zoom, details);
|
|
183
|
+
assert.ok(r.end.pitch > 1 && Math.abs(bearingDelta) > 1, details);
|
|
184
|
+
break;
|
|
185
|
+
case 'zoom in':
|
|
186
|
+
case 'zoom out':
|
|
187
|
+
assert.ok(outcome === 'zoom in' ? r.end.zoom > r.initial.zoom : r.end.zoom < r.initial.zoom, details);
|
|
188
|
+
break;
|
|
189
|
+
case 'pan':
|
|
190
|
+
assert.equal(r.end.zoom, r.initial.zoom, details);
|
|
191
|
+
break;
|
|
192
|
+
case 'navigate':
|
|
193
|
+
if (r.destinationDistance !== null) {
|
|
194
|
+
assert.ok(r.destinationDistance < 1, details);
|
|
195
|
+
assert.equal(r.end.zoom, 10, details);
|
|
196
|
+
} else {
|
|
197
|
+
assert.equal(r.boundsVisible, true, details);
|
|
198
|
+
}
|
|
199
|
+
break;
|
|
200
|
+
default:
|
|
201
|
+
assert.fail(`Unknown camera expectation: ${outcome}`);
|
|
202
|
+
}
|
|
203
|
+
if (!['zoom in', 'zoom out', 'zoom and rotate', 'pan', 'rotate then pan', 'navigate'].includes(outcome)) {
|
|
204
|
+
assert.equal(r.end.zoom, r.initial.zoom, details);
|
|
205
|
+
assert.ok(r.centerMovement < 1.5, details);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
@@ -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 });
|