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
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
@navigation-bounds
|
|
2
|
+
Feature: Navigation limits on rotated and pitched maps
|
|
3
|
+
Background:
|
|
4
|
+
Given a standalone map with navigation limits is open
|
|
5
|
+
|
|
6
|
+
@issue-75
|
|
7
|
+
Scenario Outline: Zoom and programmatic navigation respect the transformed viewport
|
|
8
|
+
When navigation rotation is <enabled> with bearing <bearing> and pitch <pitch>
|
|
9
|
+
Then minimum zoom 14 applies to view changes, wheel zoom and pinch zoom
|
|
10
|
+
And maximum bounds constrain programmatic pans and camera changes
|
|
11
|
+
|
|
12
|
+
Examples:
|
|
13
|
+
| enabled | bearing | pitch |
|
|
14
|
+
| false | 0 | 0 |
|
|
15
|
+
| true | 0 | 0 |
|
|
16
|
+
| true | 45 | 0 |
|
|
17
|
+
| true | 135 | 45 |
|
|
18
|
+
| true | 270 | 85 |
|
|
19
|
+
|
|
20
|
+
@issue-75
|
|
21
|
+
Scenario Outline: Viscosity limits the geographic footprint during dragging
|
|
22
|
+
When navigation rotation is <enabled> with bearing <bearing> and pitch <pitch>
|
|
23
|
+
Then viscosity 1 prevents crossing maximum bounds during a mouse drag
|
|
24
|
+
And viscosities 0 and 0.5 allow progressively less overshoot
|
|
25
|
+
And maximum bounds are restored after drag inertia
|
|
26
|
+
|
|
27
|
+
Examples:
|
|
28
|
+
| enabled | bearing | pitch |
|
|
29
|
+
| false | 0 | 0 |
|
|
30
|
+
| true | 0 | 0 |
|
|
31
|
+
| true | 45 | 0 |
|
|
32
|
+
| true | 135 | 45 |
|
|
33
|
+
| true | 270 | 85 |
|
|
@@ -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
|
|
@@ -56,3 +72,12 @@ Feature: Rotating a Leaflet map
|
|
|
56
72
|
Scenario: Opening a popup in a second rotated map does not move it
|
|
57
73
|
When I open a popup in a second rotated map with map listeners
|
|
58
74
|
Then the second map center remains unchanged and its popup stays anchored
|
|
75
|
+
|
|
76
|
+
Scenario Outline: Markers keep Leaflet DOM rendering when the map prefers Canvas
|
|
77
|
+
When I add markers to a Canvas-preferred map at pitch <pitch>
|
|
78
|
+
Then the markers use Leaflet DOM icons while vector paths use Canvas
|
|
79
|
+
|
|
80
|
+
Examples:
|
|
81
|
+
| pitch |
|
|
82
|
+
| 0 |
|
|
83
|
+
| 45 |
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Feature: Pitched map rendering
|
|
2
|
+
Background:
|
|
3
|
+
Given a standalone pitched map is open
|
|
4
|
+
|
|
5
|
+
@issue-75
|
|
6
|
+
Scenario: Raster layers restore perspective after toggling and late attachment
|
|
7
|
+
Then raster perspective follows pitch on late and reattached layers
|
|
8
|
+
|
|
9
|
+
@issue-75
|
|
10
|
+
Scenario: Pitched raster surfaces and markers remain visible after camera changes and compass reset
|
|
11
|
+
Then pitched raster surfaces draw loaded tiles after rapid camera changes
|
|
12
|
+
|
|
13
|
+
@issue-75
|
|
14
|
+
Scenario: Raster tile levels follow the first camera change after accumulated pans
|
|
15
|
+
Then raster tiles remain aligned after panning before their first pitch change
|
|
16
|
+
|
|
17
|
+
@issue-75
|
|
18
|
+
Scenario: Resetting a pitched view after animated zoom restores the ground camera
|
|
19
|
+
Then pitched view resets preserve the ground perspective after accumulated pans
|
package/test/index.html
CHANGED
|
@@ -162,20 +162,84 @@
|
|
|
162
162
|
version: 8,
|
|
163
163
|
sources: {
|
|
164
164
|
esri: { type: 'raster', tiles: [tiles], tileSize: 256, maxzoom: 18 },
|
|
165
|
-
|
|
166
|
-
route: { type: 'geojson', data: { type: 'LineString', coordinates: route.map(([lat, lng]) => [lng, lat]) } },
|
|
165
|
+
overlays: { type: 'geojson', data: { type: 'FeatureCollection', features: [] } },
|
|
167
166
|
},
|
|
168
167
|
layers: [
|
|
169
168
|
{ id: 'esri', type: 'raster', source: 'esri' },
|
|
170
|
-
{ id: '
|
|
171
|
-
{ id: '
|
|
172
|
-
{ id: '
|
|
169
|
+
{ id: 'overlay-fills', type: 'fill', source: 'overlays', filter: ['==', ['geometry-type'], 'Polygon'], paint: { 'fill-color': ['get', 'fillColor'], 'fill-opacity': ['get', 'fillOpacity'] } },
|
|
170
|
+
{ id: 'overlay-lines', type: 'line', source: 'overlays', filter: ['!=', ['geometry-type'], 'Point'], paint: { 'line-color': ['get', 'color'], 'line-opacity': ['get', 'opacity'], 'line-width': ['get', 'weight'] } },
|
|
171
|
+
{ id: 'overlay-points', type: 'circle', source: 'overlays', filter: ['==', ['geometry-type'], 'Point'], paint: { 'circle-radius': ['get', 'radius'], 'circle-color': ['get', 'fillColor'], 'circle-opacity': ['get', 'fillOpacity'], 'circle-stroke-color': ['get', 'color'], 'circle-stroke-opacity': ['get', 'opacity'], 'circle-stroke-width': ['get', 'weight'] } },
|
|
173
172
|
],
|
|
174
173
|
},
|
|
175
174
|
}) : null;
|
|
176
175
|
if (ml) {
|
|
177
176
|
ml.addControl(new maplibregl.NavigationControl({ visualizePitch: true }));
|
|
178
|
-
|
|
177
|
+
const mirrors = new Map();
|
|
178
|
+
let mirrorFrame;
|
|
179
|
+
const syncOverlays = () => {
|
|
180
|
+
mirrorFrame = null;
|
|
181
|
+
if (!ml.getSource('overlays')) return;
|
|
182
|
+
const active = new Set(), features = [];
|
|
183
|
+
lf.eachLayer(layer => {
|
|
184
|
+
if (layer instanceof L.Marker) {
|
|
185
|
+
active.add(layer);
|
|
186
|
+
let marker = mirrors.get(layer);
|
|
187
|
+
if (!marker) {
|
|
188
|
+
const element = document.createElement('div');
|
|
189
|
+
element.style.width = element.style.height = '0px';
|
|
190
|
+
const icon = layer.options.icon.createIcon();
|
|
191
|
+
icon.style.position = 'absolute';
|
|
192
|
+
icon.style.transform = `scale(${layer.options.scale ?? 1})`;
|
|
193
|
+
element.appendChild(icon);
|
|
194
|
+
marker = new maplibregl.Marker({ element, rotationAlignment: 'viewport', pitchAlignment: 'viewport', draggable: Boolean(layer.dragging?.enabled()) });
|
|
195
|
+
marker.on('drag', () => {
|
|
196
|
+
const ll = marker.getLngLat();
|
|
197
|
+
layer.setLatLng([ll.lat, ll.lng]);
|
|
198
|
+
});
|
|
199
|
+
layer.on('move', scheduleOverlays);
|
|
200
|
+
const tooltip = layer.getTooltip();
|
|
201
|
+
if (tooltip) marker.setPopup(new maplibregl.Popup().setHTML(tooltip.getContent()));
|
|
202
|
+
mirrors.set(layer, marker);
|
|
203
|
+
marker.setLngLat([layer.getLatLng().lng, layer.getLatLng().lat]).addTo(ml);
|
|
204
|
+
}
|
|
205
|
+
const ll = layer.getLatLng();
|
|
206
|
+
marker.setLngLat([ll.lng, ll.lat]);
|
|
207
|
+
marker.setRotation(layer._getIconBearing() * 180 / Math.PI);
|
|
208
|
+
marker.getElement().style.opacity = layer.options.opacity;
|
|
209
|
+
} else if (layer instanceof L.Path && !layer._marker) {
|
|
210
|
+
let feature = layer.toGeoJSON();
|
|
211
|
+
if (layer instanceof L.Circle) {
|
|
212
|
+
const center = layer.getLatLng(), angular = layer.getRadius() / 6371000,
|
|
213
|
+
lat = center.lat * Math.PI / 180, lng = center.lng * Math.PI / 180;
|
|
214
|
+
const ring = Array.from({ length: 65 }, (_, i) => {
|
|
215
|
+
const angle = i * 2 * Math.PI / 64;
|
|
216
|
+
const y = Math.asin(Math.sin(lat) * Math.cos(angular) + Math.cos(lat) * Math.sin(angular) * Math.cos(angle));
|
|
217
|
+
const x = lng + Math.atan2(Math.sin(angle) * Math.sin(angular) * Math.cos(lat), Math.cos(angular) - Math.sin(lat) * Math.sin(y));
|
|
218
|
+
return [x * 180 / Math.PI, y * 180 / Math.PI];
|
|
219
|
+
});
|
|
220
|
+
feature = { type: 'Feature', geometry: { type: 'Polygon', coordinates: [ring] } };
|
|
221
|
+
}
|
|
222
|
+
const o = layer.options;
|
|
223
|
+
feature.properties = {
|
|
224
|
+
color: o.color, fillColor: o.fillColor || o.color,
|
|
225
|
+
opacity: o.stroke ? o.opacity : 0, fillOpacity: o.fill ? o.fillOpacity : 0,
|
|
226
|
+
weight: o.weight || 0, radius: layer instanceof L.CircleMarker ? layer.getRadius() : 0,
|
|
227
|
+
};
|
|
228
|
+
features.push(feature);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
for (const [layer, marker] of mirrors) {
|
|
232
|
+
if (!active.has(layer)) {
|
|
233
|
+
layer.off('move', scheduleOverlays);
|
|
234
|
+
marker.remove();
|
|
235
|
+
mirrors.delete(layer);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
ml.getSource('overlays').setData({ type: 'FeatureCollection', features });
|
|
239
|
+
};
|
|
240
|
+
const scheduleOverlays = () => { mirrorFrame ??= requestAnimationFrame(syncOverlays); };
|
|
241
|
+
lf.on('layeradd layerremove move zoom rotate pitch', scheduleOverlays);
|
|
242
|
+
ml.on('load', syncOverlays);
|
|
179
243
|
}
|
|
180
244
|
|
|
181
245
|
/**
|
|
@@ -229,6 +293,11 @@
|
|
|
229
293
|
|
|
230
294
|
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
231
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
|
+
};
|
|
232
301
|
/**
|
|
233
302
|
* Create synthetic mouse/pointer/wheel input for one renderer.
|
|
234
303
|
* Positions are container fractions (0..1 inside, outside values allowed).
|
|
@@ -244,8 +313,7 @@
|
|
|
244
313
|
let pressed = 0, button = 0;
|
|
245
314
|
/** Convert fractional container coordinates to viewport client pixels. */
|
|
246
315
|
const at = (fx, fy) => {
|
|
247
|
-
|
|
248
|
-
return { clientX: r.left + r.width * fx, clientY: r.top + r.height * fy };
|
|
316
|
+
return clientPoint(el, fx, fy);
|
|
249
317
|
};
|
|
250
318
|
/** Hit-test layers/markers; use body outside this map so document listeners still run. */
|
|
251
319
|
const target = ({ clientX, clientY }) => {
|
|
@@ -467,14 +535,25 @@
|
|
|
467
535
|
}
|
|
468
536
|
const anchor = scenario.anchor && (() => {
|
|
469
537
|
const r = m.el.getBoundingClientRect();
|
|
470
|
-
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
|
+
});
|
|
471
542
|
})();
|
|
472
|
-
|
|
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
|
+
}
|
|
473
552
|
const inertia = key === 'lf' && scenario.noInertia ? lf.options.inertia : undefined;
|
|
474
553
|
if (inertia !== undefined) lf.options.inertia = false;
|
|
475
554
|
try {
|
|
476
555
|
if (scenario.goTo) await runGoTo(key, scenario.goTo);
|
|
477
|
-
else await scenario.run(
|
|
556
|
+
else await scenario.run(input);
|
|
478
557
|
if (key === 'ml' && scenario.noInertia) ml.stop();
|
|
479
558
|
await sleep(scenario.settle || 900);
|
|
480
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
|
+
});
|
|
@@ -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
|
+
});
|