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/lib/debug.js CHANGED
@@ -34,6 +34,9 @@
34
34
  .leaflet-rotate-info tr[title] {
35
35
  cursor: help;
36
36
  }
37
+ div.crosshair, div.pivot, div.pixelorigin, div.panebounds {
38
+ pointer-events: none;
39
+ }
37
40
  div.crosshair {
38
41
  position:absolute;
39
42
  width:1px;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leaflet-rotate",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
4
4
  "description": "A Leaflet plugin that allows to add rotation functionality to map tiles",
5
5
  "type": "module",
6
6
  "main": "dist/leaflet-rotate.js",
@@ -1,6 +1,6 @@
1
1
  /**
2
- * This plugin does not modify the `L.Draggable` API; it adapts marker drag
3
- * coordinates in `L.Marker` when rotation or pitch is enabled.
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.include({
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 || (!this._pitch && Math.abs(this._bearing).toFixed(1) < 0.1)) {
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.latLngToContainerPoint(latlng)._subtract(this.getSize()._divideBy(2));
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
- const r = el.getBoundingClientRect();
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]) => [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
+ });
535
542
  })();
536
- 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
+ }
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(driver(key));
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 });