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.
@@ -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
- polygon: { type: 'geojson', data: { type: 'Polygon', coordinates: [[...polygonLatLngs, polygonLatLngs[0]].map(([lat, lng]) => [lng, lat])] } },
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: 'polygon', type: 'fill', source: 'polygon', paint: { 'fill-color': '#3388ff', 'fill-opacity': 0.2 } },
171
- { id: 'polygon-line', type: 'line', source: 'polygon', paint: { 'line-color': '#3388ff', 'line-width': 3 } },
172
- { id: 'route', type: 'line', source: 'route', paint: { 'line-color': 'yellow', 'line-width': 3 } },
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
- for (const name in places) new maplibregl.Marker().setLngLat([places[name][1], places[name][0]]).setPopup(new maplibregl.Popup().setText(name)).addTo(ml);
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
- const r = el.getBoundingClientRect();
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]) => [r.width * fx, r.height * 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
- const latlng = anchor && m.toLatLng(anchor[0]);
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(driver(key));
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
+ });