leaflet-rotate 0.5.1 → 0.5.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leaflet-rotate",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
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",
package/src/map/Map.js CHANGED
@@ -1,6 +1,17 @@
1
1
  /**
2
2
  * Extends Leaflet maps with bearing rotation and 3D pitch.
3
3
  *
4
+ * Coordinate spaces used by the overrides below:
5
+ * - projected pixels: CRS coordinates at a given zoom;
6
+ * - layer points: projected pixels minus `_pixelOrigin`, on the flat ground plane;
7
+ * - map-pane points: layer points after bearing/pitch, before the outer pane translation;
8
+ * - container points: map-pane points plus `_getMapPanePos()`, in viewport pixels.
9
+ *
10
+ * Tiles and vectors share `rotatePane`; upright markers and popups use `norotatePane`
11
+ * and the same point conversions. Public angles are degrees; `_bearing` and `_pitch`
12
+ * are radians. With pitch enabled, CSS and coordinate conversions must use the same
13
+ * projective transform, including while the outer map pane is being dragged.
14
+ *
4
15
  * @external L.Map
5
16
  *
6
17
  * @since leaflet-rotate (v0.1.0)
@@ -95,6 +106,9 @@ L.Map.include({
95
106
  mapProto.initialize.apply(this, arguments);
96
107
  if (this.options.rotate) {
97
108
  this.on('resize', this._updatePitchTransform, this);
109
+ this.on('movestart dragstart', this._startPitchPan, this);
110
+ this.on('move', this._updatePitchPan, this);
111
+ this.on('moveend', this._endPitchPan, this);
98
112
  this.on('move', this._updatePitchSky, this);
99
113
  this.setBearing(this.options.bearing);
100
114
  this.setPitch(this.options.pitch);
@@ -242,7 +256,8 @@ L.Map.include({
242
256
  /**
243
257
  * Return the geographic bounds of the current viewport, accounting for rotation and pitch.
244
258
  *
245
- * The result is the smallest geographic bounds containing all four viewport corners.
259
+ * The result encloses the four corners of the visible ground rectangle. With pitch,
260
+ * the rectangle stops at the configured far plane, excluding the sky.
246
261
  *
247
262
  * @returns {L.LatLngBounds} Geographic bounds visible in the map viewport.
248
263
  *
@@ -507,10 +522,12 @@ L.Map.include({
507
522
  },
508
523
 
509
524
  /**
510
- * Transform a point with a projective matrix, accepting DOMMatrix or its 2D inverse.
525
+ * Transform a ground-plane point with a DOMMatrix, or reverse its screen projection.
526
+ * Both branches divide by the homogeneous coordinate after applying the matrix.
511
527
  *
512
528
  * @param {L.Point|number[]} point Input pixel coordinate.
513
- * @param {DOMMatrix|Object} matrix Forward or inverse transformation matrix.
529
+ * @param {DOMMatrix|Object} matrix Forward CSS matrix or inverse homography with
530
+ * row-major coefficients `a` through `i`.
514
531
  *
515
532
  * @returns {L.Point} Transformed pixel coordinate.
516
533
  *
@@ -543,6 +560,21 @@ L.Map.include({
543
560
  position = this._rotatePane._leaflet_pos || new L.Point(0, 0),
544
561
  transform = `translate3d(${pivot.x}px,${pivot.y}px,0) perspective(${perspective}px) rotateX(${this.getPitch()}deg) rotateZ(${this.getBearing()}deg) translate3d(${position.x - pivot.x}px,${position.y - pivot.y}px,0)`;
545
562
 
563
+ this._setPitchTransform(transform);
564
+ },
565
+
566
+ /**
567
+ * Apply the CSS transform and cache matching forward/inverse point conversions.
568
+ *
569
+ * Ground points have z = 0, so the 4x4 CSS matrix reduces to the homography
570
+ * [a b c; d e f; g h i]. Its inverse maps screen points back onto that plane;
571
+ * inverting the full 4x4 matrix at screen z = 0 would not undo perspective.
572
+ *
573
+ * @param {string} transform CSS transform list, including a serialized DOMMatrix.
574
+ *
575
+ * @since leaflet-rotate (0.5.2)
576
+ */
577
+ _setPitchTransform(transform) {
546
578
  this._rotatePane.style.transformOrigin = '0 0';
547
579
  this._rotatePane.style.transform = transform;
548
580
  // parse the transform directly: getComputedStyle() would force a style recalculation on every frame
@@ -566,6 +598,76 @@ L.Map.include({
566
598
  this._updatePitchSky();
567
599
  },
568
600
 
601
+ /**
602
+ * Capture a fixed projection baseline for a pitched drag or programmatic pan.
603
+ *
604
+ * `movestart` uses the viewport center; `dragstart` replaces it with the original
605
+ * pointer position. Store both the screen anchor and its ground-plane coordinate
606
+ * so subsequent frames can keep the grabbed geographic point under the pointer.
607
+ * Pane positions are in screen pixels and the baseline matrices stay unchanged.
608
+ *
609
+ * @param {L.Event} e Leaflet `movestart` or `dragstart` event.
610
+ *
611
+ * @since leaflet-rotate (0.5.2)
612
+ */
613
+ _startPitchPan(e) {
614
+ if (!this._pitch || !this._loaded) { return; }
615
+ const draggable = this.dragging && this.dragging._draggable,
616
+ anchor = e.type === 'dragstart' && draggable
617
+ ? (this.pointerEventToContainerPoint || this.mouseEventToContainerPoint).call(this, {
618
+ clientX: draggable._startPoint.x, clientY: draggable._startPoint.y,
619
+ })
620
+ : this.getSize().divideBy(2),
621
+ panePos = this._getMapPanePos().clone();
622
+ this._pitchPan = {
623
+ anchor,
624
+ panePos,
625
+ lastPanePos: panePos,
626
+ matrix: this._pitchTransform,
627
+ inverse: this._pitchInverseTransform,
628
+ layerAnchor: this.containerPointToLayerPoint(anchor),
629
+ zoom: this._zoom,
630
+ };
631
+ },
632
+
633
+ /**
634
+ * Translate the ground plane without moving the camera's vanishing point.
635
+ *
636
+ * For pane displacement delta, invert the displaced anchor with the baseline
637
+ * projection to find the required layer-space shift. Compose
638
+ * T(-delta) * baseline * T(shift): the left translation cancels Leaflet's outer
639
+ * pane movement, while the right translation moves geography before perspective.
640
+ * Always derive this from the baseline, not the previous frame, to avoid drift.
641
+ * Zoom frames are handled by Leaflet's zoom/origin updates instead.
642
+ *
643
+ * @since leaflet-rotate (0.5.2)
644
+ */
645
+ _updatePitchPan() {
646
+ const pan = this._pitchPan;
647
+ if (!this._pitch || !pan || this._animatingZoom || this._zoom !== pan.zoom) { return; }
648
+ const panePos = this._getMapPanePos();
649
+ if (panePos.equals(pan.lastPanePos)) { return; }
650
+ const delta = panePos.subtract(pan.panePos),
651
+ target = this._transformPitchPoint(pan.anchor.add(delta).subtract(pan.panePos), pan.inverse),
652
+ shift = target.subtract(pan.layerAnchor);
653
+ const matrix = new DOMMatrix().translate(-delta.x, -delta.y)
654
+ .multiply(pan.matrix).translate(shift.x, shift.y);
655
+ this._setPitchTransform(matrix.toString());
656
+ pan.lastPanePos = panePos.clone();
657
+ delete this._lastCenter;
658
+ },
659
+
660
+ /**
661
+ * Discard the baseline only at `moveend`, after any drag inertia has completed.
662
+ * Keep the final transform and pixel origin: resetting the view here would
663
+ * reproject layers abruptly and cause a visible jump on release.
664
+ *
665
+ * @since leaflet-rotate (0.5.2)
666
+ */
667
+ _endPitchPan() {
668
+ delete this._pitchPan;
669
+ },
670
+
569
671
  /**
570
672
  * Fill the area above the far plane (where no tiles are loaded) with a sky gradient.
571
673
  * Style it through the `.leaflet-pitch-sky` class.
@@ -966,7 +1068,10 @@ L.Map.include({
966
1068
  // },
967
1069
 
968
1070
  /**
969
- * Return the pixel offset from the current center to the given geographic point.
1071
+ * Return the screen-space pan offset needed to center the given geographic point.
1072
+ * Under perspective, projecting a negated layer offset is not equivalent to
1073
+ * negating its screen projection. Reflect the target about the layer center
1074
+ * to match the opposite anchor displacement performed by Leaflet's `panBy`.
970
1075
  *
971
1076
  * @param {L.LatLng} latlng Geographic point.
972
1077
  *
@@ -978,6 +1083,12 @@ L.Map.include({
978
1083
  if (!this._rotate) {
979
1084
  return mapProto._getCenterOffset.apply(this, arguments);
980
1085
  }
1086
+ if (this._pitch) {
1087
+ const center = this.getSize().divideBy(2),
1088
+ layerCenter = this.containerPointToLayerPoint(center),
1089
+ opposite = layerCenter.multiplyBy(2).subtract(this.project(latlng).subtract(this._pixelOrigin));
1090
+ return center.subtract(this.layerPointToContainerPoint(opposite));
1091
+ }
981
1092
  return this.latLngToContainerPoint(latlng)._subtract(this.getSize()._divideBy(2));
982
1093
  },
983
1094
 
@@ -1056,9 +1167,12 @@ L.Map.include({
1056
1167
  },
1057
1168
 
1058
1169
  /**
1170
+ * Return the viewport center before the outer map-pane translation.
1171
+ * Used as the bearing/pitch pivot in map-pane coordinates, not layer coordinates.
1172
+ *
1059
1173
  * @since leaflet-rotate (v0.2.0)
1060
1174
  *
1061
- * @returns {L.Point} Map pivot point in pixel coordinates.
1175
+ * @returns {L.Point} Map pivot in map-pane pixels.
1062
1176
  */
1063
1177
  _getPixelCenter() {
1064
1178
  if (!this._rotate && mapProto._getPixelCenter) {
@@ -1068,6 +1182,9 @@ L.Map.include({
1068
1182
  },
1069
1183
 
1070
1184
  /**
1185
+ * Convert the padded visible ground rectangle to layer bounds for vector renderers.
1186
+ * Clip its far edge before reversing perspective to avoid unbounded render areas.
1187
+ *
1071
1188
  * @see src\layer\vector\Renderer::_update()
1072
1189
  *
1073
1190
  * @param {number} padding Bounds padding as a fraction of map size.
@@ -36,6 +36,11 @@ Feature: Rotating a Leaflet map
36
36
  When I drag the pitched map
37
37
  Then vector layers stay aligned with their map coordinates
38
38
 
39
+ @issue-74
40
+ Scenario: Pitched panning does not jump or reset the view on release
41
+ When I pan a highly pitched map in several directions
42
+ Then releasing the map does not jump or reset its layers
43
+
39
44
  @issue-72
40
45
  Scenario: Vector layers stay aligned after off-center zoom and rotation
41
46
  When I zoom off-center then rotate the map
package/test/index.html CHANGED
@@ -47,10 +47,6 @@
47
47
  <label title="Show the leaflet-rotate debug overlays and info panel"><input type="checkbox" id="debug"> debug</label>
48
48
  <label title="MapLibre-like continuous zoom (needed for an exact view sync)"><input type="checkbox" id="snap"> zoomSnap: 0</label>
49
49
  </fieldset>
50
- <fieldset>
51
- <legend>Go to</legend>
52
- <select id="goto" title="Fly to a layer or place"><option value="">—</option></select>
53
- </fieldset>
54
50
  <fieldset>
55
51
  <legend>Scenarios</legend>
56
52
  <select id="scenario"></select>
@@ -67,19 +63,29 @@
67
63
  <details id="results-panel" class="hidden" open>
68
64
  <summary>Scenario results <button id="clear">Clear</button></summary>
69
65
  <table id="results">
70
- <thead><tr><th>Scenario</th><th>leaflet-rotate z / bearing / pitch</th><th>drift</th><th class="ml-only">MapLibre z / bearing / pitch</th><th class="ml-only">drift</th></tr></thead>
66
+ <thead><tr><th>Scenario</th><th>leaflet-rotate z / bearing / pitch</th><th>drift</th><th class="ml-only">MapLibre z / bearing / pitch</th><th class="ml-only">drift</th><th class="ml-only">view Δ</th><th class="ml-only" title="Largest change in the relative positions of six geographic probes, compared with the other renderer.">max spatial Δ</th></tr></thead>
71
67
  <tbody></tbody>
72
68
  </table>
73
69
  </details>
74
70
  </section>
75
71
 
76
72
  <script type="module">
73
+ /**
74
+ * Interactive regression harness, optionally comparing Leaflet with MapLibre.
75
+ * Setup is persisted through query parameters: `leaflet=2`, `maplibre=0`,
76
+ * `debug=0`, and `snap=0` (continuous Leaflet zoom). Defaults enable comparison
77
+ * and debugging with Leaflet 1 and integer zoom snapping.
78
+ *
79
+ * Manual view sync is suspended during scenarios: each renderer receives the
80
+ * same starting view and input independently, then its results are measured.
81
+ */
77
82
  const $ = id => document.getElementById(id);
78
83
  const params = new URLSearchParams(location.search);
79
84
  $('leaflet-version').value = params.get('leaflet') === '2' ? '2' : '1';
80
85
  $('maplibre').checked = params.get('maplibre') !== '0';
81
86
  $('debug').checked = params.get('debug') !== '0';
82
87
  $('snap').checked = params.get('snap') === '0';
88
+ /** Reload the harness with the selected library/debug/zoom configuration. */
83
89
  const reload = () => {
84
90
  const query = new URLSearchParams({ leaflet: $('leaflet-version').value });
85
91
  if (!$('maplibre').checked) query.set('maplibre', '0');
@@ -91,6 +97,7 @@
91
97
  const compare = $('maplibre').checked;
92
98
  for (const el of document.querySelectorAll('.ml-only')) el.classList.toggle('hidden', !compare);
93
99
 
100
+ /** Load scripts in dependency order; reject explicitly if a CDN or local file fails. */
94
101
  const loadScript = src => new Promise((resolve, reject) => {
95
102
  const script = Object.assign(document.createElement('script'), { src, onload: resolve, onerror: () => reject(new Error(`Could not load ${src}`)) });
96
103
  document.head.appendChild(script);
@@ -144,16 +151,6 @@
144
151
  window.circle = new L.Circle([53, 4], 111111).addTo(lf);
145
152
  window.polygon = new L.Polygon(polygonLatLngs).addTo(lf);
146
153
  window.rectangle = new L.Rectangle([[60.81123, 25.71632], [60.81028, 25.73166], [60.7997, 25.72892], [60.80061, 25.71362]]).addTo(lf);
147
- const goto = {
148
- 'Circle': () => lf.flyToBounds(circle.getBounds()),
149
- 'Polygon': () => lf.flyToBounds(polygon.getBounds()),
150
- 'Path': () => lf.flyToBounds(path.getBounds()),
151
- ...Object.fromEntries(Object.keys(places).map(name => [name, () => lf.flyTo(places[name], 10)])),
152
- // DEBUG: map.setZoom(0) && map.getBoundsZoom(rectangle.getBounds())
153
- 'Rectangle (fit from z 0)': () => { lf.setZoom(0, { animate: false }); setTimeout(() => lf.fitBounds(rectangle.getBounds(), { animate: false }), 1000); },
154
- };
155
- for (const name in goto) $('goto').add(new Option(name, name));
156
- $('goto').onchange = e => { goto[e.target.value](); e.target.value = ''; };
157
154
  if ($('debug').checked) {
158
155
  L.Rotate.debug(lf, { container: $('panels') });
159
156
  }
@@ -181,7 +178,20 @@
181
178
  for (const name in places) new maplibregl.Marker().setLngLat([places[name][1], places[name][0]]).setPopup(new maplibregl.Popup().setText(name)).addTo(ml);
182
179
  }
183
180
 
184
- // unified view access: { lat, lng, zoom (leaflet scale), bearing (leaflet sign), pitch }
181
+ /**
182
+ * @typedef {Object} View
183
+ * @property {number} lat Center latitude in degrees.
184
+ * @property {number} lng Center longitude in degrees.
185
+ * @property {number} zoom Leaflet zoom scale (256px world at zoom 0).
186
+ * @property {number} bearing Leaflet bearing sign, in degrees.
187
+ * @property {number} pitch Pitch in degrees.
188
+ */
189
+ /**
190
+ * Renderer adapters use View values and [lat, lng] geographic arrays.
191
+ * `get`/`set` read/apply a view without animation; `toLatLng`/`toPoint` convert
192
+ * container pixels to/from geography. MapLibre uses 512px zoom levels,
193
+ * the opposite bearing sign, and [lng, lat] arrays internally.
194
+ */
185
195
  const maps = {
186
196
  lf: {
187
197
  el: lf.getContainer(),
@@ -202,6 +212,7 @@
202
212
  // view sync: the last hovered map drives the other one
203
213
  let source = 'lf', syncing = false, running = false;
204
214
  const fmt = v => `z ${v.zoom.toFixed(2)} bearing ${v.bearing.toFixed(1)} pitch ${v.pitch.toFixed(1)} ${v.lat.toFixed(4)}, ${v.lng.toFixed(4)}`;
215
+ /** Create a view listener; the hovered renderer drives sync, without recursive updates. */
205
216
  const onView = key => () => {
206
217
  $(`${key}-info`).textContent = fmt(maps[key].get());
207
218
  if (!ml || key !== source || syncing || running || !$('sync').checked) return;
@@ -216,16 +227,27 @@
216
227
  if (ml) ml.on('move', onView('ml'));
217
228
  for (const key in maps) maps[key].el.addEventListener('pointerenter', () => source = key);
218
229
 
219
- // synthetic input driver, coordinates are fractions of the map container
220
230
  const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
221
231
  const frame = () => new Promise(resolve => requestAnimationFrame(resolve));
232
+ /**
233
+ * Create synthetic mouse/pointer/wheel input for one renderer.
234
+ * Positions are container fractions (0..1 inside, outside values allowed).
235
+ * Emit both pointer and mouse events for the supported library versions.
236
+ * These untrusted events exercise handlers, not native browser input behavior;
237
+ * animation-frame pacing also depends on tab visibility.
238
+ *
239
+ * @param {'lf'|'ml'} key Renderer receiving the input.
240
+ * @returns {Object} Async down/move/up/wheel methods and a millisecond wait helper.
241
+ */
222
242
  const driver = key => {
223
243
  const el = maps[key].el;
224
244
  let pressed = 0, button = 0;
245
+ /** Convert fractional container coordinates to viewport client pixels. */
225
246
  const at = (fx, fy) => {
226
247
  const r = el.getBoundingClientRect();
227
248
  return { clientX: r.left + r.width * fx, clientY: r.top + r.height * fy };
228
249
  };
250
+ /** Hit-test layers/markers; use body outside this map so document listeners still run. */
229
251
  const target = ({ clientX, clientY }) => {
230
252
  const hit = document.elementFromPoint(clientX, clientY);
231
253
  return hit && el.contains(hit) ? hit : document.body;
@@ -238,12 +260,14 @@
238
260
  };
239
261
  let last = at(0.5, 0.5);
240
262
  return {
263
+ /** Press the selected mouse button at a fractional container position. */
241
264
  async down(fx, fy, mods = {}) {
242
265
  button = mods.button || 0;
243
266
  pressed = button === 2 ? 2 : 1;
244
267
  fire('down', last = at(fx, fy), mods);
245
268
  await frame();
246
269
  },
270
+ /** Interpolate from the last position, yielding one animation frame per step. */
247
271
  async move(fx, fy, mods = {}, steps = 10) {
248
272
  const from = last, to = at(fx, fy);
249
273
  for (let i = 1; i <= steps; i++) {
@@ -252,12 +276,14 @@
252
276
  await frame();
253
277
  }
254
278
  },
279
+ /** Release at the last position, including contextmenu for right-button gestures. */
255
280
  async up(mods = {}) {
256
281
  pressed = 0;
257
282
  fire('up', last, mods);
258
283
  if (button === 2) target(last).dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, ...last, button: 2, ...mods }));
259
284
  await frame();
260
285
  },
286
+ /** Send a wheel burst with pixel deltas and a millisecond interval between events. */
261
287
  async wheel(fx, fy, deltaY, mods = {}, count = 1, interval = 16) {
262
288
  last = at(fx, fy);
263
289
  for (let i = 0; i < count; i++) {
@@ -269,9 +295,22 @@
269
295
  };
270
296
  };
271
297
 
272
- // scenarios: view = initial view (leaflet units), anchor = [from, to] screen fractions that should keep the same latlng
298
+ /**
299
+ * @typedef {Object} Scenario
300
+ * @property {string} name Display label.
301
+ * @property {Partial<View>} [view] Overrides of the shared `flat` starting view.
302
+ * @property {number[][]} [anchor] [from, to] container fractions expected to hold
303
+ * the same geographic point; absent when cursor anchoring is not measured.
304
+ * @property {boolean} [noInertia] Disable/stop inertia for endpoint measurements.
305
+ * @property {function(Object): Promise<void>} [run] Synthetic gesture using a driver.
306
+ * @property {string} [goTo] Navigation destination, used instead of `run`.
307
+ * @property {number} [settle=900] Milliseconds to wait after input/navigation completes.
308
+ */
273
309
  const ctrl = { ctrlKey: true }, right = { button: 2 };
310
+ /** Build a modified drag, using Ctrl by default or an explicit right-button modifier. */
274
311
  const ctrlDrag = (from, to, steps, mods = ctrl) => async d => { await d.down(...from, mods); await d.move(...to, mods, steps); await d.up(mods); };
312
+ /** Build one uninterrupted plain drag through fractional-container waypoints. */
313
+ const panPath = (...points) => async d => { await d.down(...points[0]); for (const point of points.slice(1)) await d.move(...point, {}, 12); await d.up(); };
275
314
  const flat = { lat: 50, lng: 5, zoom: 5, bearing: 0, pitch: 0 };
276
315
  const scenarios = [
277
316
  { name: 'Ctrl+drag up: pitch from center', run: ctrlDrag([0.5, 0.5], [0.5, 0.3]) },
@@ -308,24 +347,170 @@
308
347
  { name: 'Wheel then Ctrl+drag while zoom pending', run: async d => { await d.wheel(0.9, 0.1, -100); await ctrlDrag([0.5, 0.5], [0.55, 0.4], 4)(d); } },
309
348
  { name: 'Wheel during Ctrl+drag', run: async d => { await d.down(0.5, 0.6, ctrl); await d.move(0.55, 0.5, ctrl, 4); await d.wheel(0.55, 0.5, -100, ctrl); await d.move(0.6, 0.4, ctrl, 4); await d.up(ctrl); } },
310
349
  { name: 'Low zoom rotated, wheel in (layer jump check)', view: { zoom: 1, bearing: 30 }, anchor: [[0.6, 0.4], [0.6, 0.4]], run: async d => { await d.wheel(0.6, 0.4, -100, {}, 1); await d.wait(400); await d.wheel(0.6, 0.4, -100, {}, 1); } },
311
- { name: 'Pan pitched 70', view: { bearing: 45, pitch: 70 }, anchor: [[0.5, 0.7], [0.3, 0.5]], run: async d => { await d.down(0.5, 0.7); await d.move(0.3, 0.5); await d.up(); } },
350
+ { name: 'Pan pitched 70', view: { bearing: 45, pitch: 70 }, anchor: [[0.5, 0.7], [0.3, 0.5]], noInertia: true, run: async d => { await d.down(0.5, 0.7); await d.move(0.3, 0.5); await d.up(); } },
351
+ { name: 'Pan pitched 70: vertical up', view: { bearing: 45, pitch: 70 }, anchor: [[0.5, 0.7], [0.5, 0.5]], noInertia: true, run: panPath([0.5, 0.7], [0.5, 0.5]) },
352
+ { name: 'Pan pitched 70: vertical down', view: { bearing: 45, pitch: 70 }, anchor: [[0.5, 0.3], [0.5, 0.5]], noInertia: true, run: panPath([0.5, 0.3], [0.5, 0.5]) },
353
+ { name: 'Pan pitched 70: horizontal left', view: { bearing: 45, pitch: 70 }, anchor: [[0.7, 0.5], [0.5, 0.5]], noInertia: true, run: panPath([0.7, 0.5], [0.5, 0.5]) },
354
+ { name: 'Pan pitched 70: horizontal right', view: { bearing: 45, pitch: 70 }, anchor: [[0.3, 0.5], [0.5, 0.5]], noInertia: true, run: panPath([0.3, 0.5], [0.5, 0.5]) },
355
+ { name: 'Pan pitched 70: diagonal up-right', view: { bearing: 45, pitch: 70 }, anchor: [[0.3, 0.7], [0.5, 0.5]], noInertia: true, run: panPath([0.3, 0.7], [0.5, 0.5]) },
356
+ { name: 'Pan pitched 70: diagonal down-left', view: { bearing: 45, pitch: 70 }, anchor: [[0.7, 0.3], [0.5, 0.5]], noInertia: true, run: panPath([0.7, 0.3], [0.5, 0.5]) },
357
+ { name: 'Pan pitched 70: reverse vertical drag', view: { bearing: 45, pitch: 70 }, anchor: [[0.5, 0.7], [0.5, 0.4]], noInertia: true, run: panPath([0.5, 0.7], [0.5, 0.5], [0.5, 0.7], [0.5, 0.4]) },
358
+ { name: 'Pan pitched 70: multi-direction loop', view: { bearing: 45, pitch: 70 }, anchor: [[0.5, 0.7], [0.5, 0.7]], noInertia: true, run: panPath([0.5, 0.7], [0.7, 0.5], [0.3, 0.5], [0.5, 0.7]) },
359
+ { name: 'Pan pitched 70, bearing 225: diagonal', view: { bearing: 225, pitch: 70 }, anchor: [[0.3, 0.7], [0.7, 0.3]], noInertia: true, run: panPath([0.3, 0.7], [0.7, 0.3]) },
312
360
  ];
361
+ scenarios.push(...['Circle', 'Polygon', 'Path', ...Object.keys(places), 'Rectangle (fit from z 0)'].map(name => ({
362
+ name: `Go to: ${name}`,
363
+ goTo: name,
364
+ view: { bearing: 45, pitch: 70 },
365
+ settle: 300,
366
+ })));
313
367
  for (const [i, s] of scenarios.entries()) $('scenario').add(new Option(s.name, i));
314
368
 
369
+ /**
370
+ * Run equivalent fly/fit navigation, preserving each renderer's bearing and pitch.
371
+ * Wait for moveend (8s timeout), not a fixed flight duration. Fit algorithms and
372
+ * zoom snapping differ between renderers, so final views need not match exactly.
373
+ *
374
+ * @param {'lf'|'ml'} key Renderer to navigate.
375
+ * @param {string} name Layer bounds, a `places` key, or the rectangle reset case.
376
+ */
377
+ const runGoTo = async (key, name) => {
378
+ const map = key === 'lf' ? lf : ml,
379
+ // Subscribe before invoking the action: nonanimated fits can end synchronously.
380
+ finishMove = action => {
381
+ const finished = new Promise((resolve, reject) => {
382
+ const onMoveEnd = () => {
383
+ clearTimeout(timer);
384
+ resolve();
385
+ };
386
+ const timer = setTimeout(() => {
387
+ map.off('moveend', onMoveEnd);
388
+ reject(new Error(`Go to "${name}" did not finish moving`));
389
+ }, 8000);
390
+ map.once('moveend', onMoveEnd);
391
+ });
392
+ action();
393
+ return finished;
394
+ },
395
+ view = maps[key].get(),
396
+ mapLibreCamera = { bearing: -view.bearing, pitch: view.pitch };
397
+ if (name === 'Circle' || name === 'Polygon' || name === 'Path') {
398
+ const bounds = name === 'Circle' ? circle.getBounds() : name === 'Polygon' ? polygon.getBounds() : path.getBounds();
399
+ if (key === 'lf') {
400
+ await finishMove(() => lf.flyToBounds(bounds));
401
+ } else {
402
+ await finishMove(() => ml.fitBounds([[bounds.getWest(), bounds.getSouth()], [bounds.getEast(), bounds.getNorth()]], { ...mapLibreCamera, duration: 1000 }));
403
+ }
404
+ } else if (name === 'Rectangle (fit from z 0)') {
405
+ if (key === 'lf') {
406
+ lf.setZoom(0, { animate: false });
407
+ await sleep(1000);
408
+ await finishMove(() => lf.fitBounds(rectangle.getBounds(), { animate: false }));
409
+ } else {
410
+ ml.setZoom(-1);
411
+ await sleep(1000);
412
+ const bounds = rectangle.getBounds();
413
+ await finishMove(() => ml.fitBounds([[bounds.getWest(), bounds.getSouth()], [bounds.getEast(), bounds.getNorth()]], { ...mapLibreCamera, duration: 0 }));
414
+ }
415
+ } else {
416
+ const latlng = places[name];
417
+ if (key === 'lf') {
418
+ await finishMove(() => lf.flyTo(latlng, 10));
419
+ } else {
420
+ await finishMove(() => ml.flyTo({ center: [latlng[1], latlng[0]], zoom: 9, ...mapLibreCamera, duration: 1000 }));
421
+ }
422
+ }
423
+ };
424
+
425
+ /**
426
+ * Reset both views, run one renderer, and measure its independent final state.
427
+ *
428
+ * Drift is the screen distance between the original geographic anchor and its
429
+ * expected endpoint. For spatial delta, first sync the other renderer to the
430
+ * final view, then compare six fixed geographic probes relative to viewport
431
+ * centers, subtracting each probe's initial cross-renderer difference.
432
+ * Initially non-finite probes are excluded; a valid probe becoming non-finite
433
+ * produces Infinity. After large flights, probes can be offscreen/near the
434
+ * horizon: spatial delta is a diagnostic, not a viewport-wide accuracy bound.
435
+ *
436
+ * @param {'lf'|'ml'} key Renderer whose input is being tested.
437
+ * @param {Scenario} scenario Gesture or navigation definition.
438
+ * @returns {Promise<{end: View, drift: ?number, spatialDelta: ?number}>}
439
+ * Final view and pixel errors; null denotes an unavailable measurement.
440
+ */
315
441
  const runOn = async (key, scenario) => {
316
442
  const m = maps[key];
317
- m.set({ ...flat, ...scenario.view });
443
+ const initialView = { ...flat, ...scenario.view },
444
+ other = maps[key === 'lf' ? 'ml' : 'lf'];
445
+ for (const map of Object.values(maps)) map.set(initialView);
318
446
  await sleep(300);
447
+ let relativePoints, initialDifferences;
448
+ if (other) {
449
+ const probes = [[0.2, 0.2], [0.8, 0.2], [0.2, 0.8], [0.8, 0.8], [0.5, 0.25], [0.5, 0.75]]
450
+ .map(([x, y]) => {
451
+ const r = m.el.getBoundingClientRect();
452
+ return m.toLatLng([r.width * x, r.height * y]);
453
+ });
454
+ // Center-relative coordinates remove the offset from unequal container sizes.
455
+ relativePoints = map => {
456
+ const r = map.el.getBoundingClientRect();
457
+ return probes.map(ll => {
458
+ const p = map.toPoint(ll);
459
+ return p.every(Number.isFinite) ? [p[0] - r.width / 2, p[1] - r.height / 2] : null;
460
+ });
461
+ };
462
+ const initialLf = relativePoints(maps.lf), initialMl = relativePoints(maps.ml);
463
+ initialDifferences = initialLf.map((p, i) => p && initialMl[i]
464
+ ? [p[0] - initialMl[i][0], p[1] - initialMl[i][1]]
465
+ : null);
466
+ if (!initialDifferences.some(Boolean)) throw new Error('No common finite geographic probes for this view');
467
+ }
319
468
  const anchor = scenario.anchor && (() => {
320
469
  const r = m.el.getBoundingClientRect();
321
470
  return scenario.anchor.map(([fx, fy]) => [r.width * fx, r.height * fy]);
322
471
  })();
323
472
  const latlng = anchor && m.toLatLng(anchor[0]);
324
- await scenario.run(driver(key));
325
- await sleep(900);
473
+ const inertia = key === 'lf' && scenario.noInertia ? lf.options.inertia : undefined;
474
+ if (inertia !== undefined) lf.options.inertia = false;
475
+ try {
476
+ if (scenario.goTo) await runGoTo(key, scenario.goTo);
477
+ else await scenario.run(driver(key));
478
+ if (key === 'ml' && scenario.noInertia) ml.stop();
479
+ await sleep(scenario.settle || 900);
480
+ } finally {
481
+ if (inertia !== undefined) lf.options.inertia = inertia;
482
+ }
326
483
  const end = m.get(), p = anchor && m.toPoint(latlng);
327
- return { end, drift: anchor ? Math.hypot(p[0] - anchor[1][0], p[1] - anchor[1][1]) : null };
484
+ let spatialDelta = null;
485
+ if (other) {
486
+ other.set(end);
487
+ await sleep(100);
488
+ const endLf = relativePoints(maps.lf), endMl = relativePoints(maps.ml);
489
+ spatialDelta = Math.max(...initialDifferences.map((baseline, i) => {
490
+ if (!baseline) return 0;
491
+ if (!endLf[i] || !endMl[i]) return Infinity;
492
+ const point = endLf[i];
493
+ const dx = point[0] - endMl[i][0] - initialDifferences[i][0],
494
+ dy = point[1] - endMl[i][1] - initialDifferences[i][1];
495
+ return Math.hypot(dx, dy);
496
+ }));
497
+ }
498
+ return {
499
+ end,
500
+ drift: anchor ? Math.hypot(p[0] - anchor[1][0], p[1] - anchor[1][1]) : null,
501
+ spatialDelta,
502
+ };
328
503
  };
504
+ /**
505
+ * Run a scenario sequentially on each enabled renderer and append a result row.
506
+ * Compare independent final views before spatial synchronization affects them:
507
+ * center distance uses wrapped Web Mercator pixels at the mean Leaflet zoom.
508
+ * Red cells flag drift/spatial/center errors >3px, zoom differences >0.05,
509
+ * or bearing/pitch differences >0.1 degrees; these are diagnostic thresholds.
510
+ *
511
+ * @param {Scenario} scenario Selected test case.
512
+ * @returns {Promise<Object[]>} Per-renderer measurements in adapter iteration order.
513
+ */
329
514
  const run = async scenario => {
330
515
  running = true;
331
516
  $('run').disabled = $('run-all').disabled = true;
@@ -342,11 +527,45 @@
342
527
  cell.textContent = r.drift == null ? '' : `${r.drift.toFixed(1)}px`;
343
528
  cell.classList.toggle('bad', r.drift > 3);
344
529
  }
530
+ let viewDelta = null, viewMismatch = false;
531
+ if (ml) {
532
+ const [lfView, mlView] = results.map(result => result.end),
533
+ // Normalized Web Mercator world coordinates, clamped to its latitude limit.
534
+ project = ({ lat, lng }, zoom) => {
535
+ const sin = Math.sin(Math.max(-85.05112878, Math.min(85.05112878, lat)) * Math.PI / 180);
536
+ return [
537
+ (lng + 180) / 360,
538
+ 0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI),
539
+ ];
540
+ },
541
+ zoom = (lfView.zoom + mlView.zoom) / 2,
542
+ a = project(lfView, zoom),
543
+ b = project(mlView, zoom),
544
+ dx = ((a[0] - b[0] + 0.5) % 1 + 1) % 1 - 0.5,
545
+ dy = a[1] - b[1],
546
+ centerDelta = Math.hypot(dx, dy) * 256 * 2 ** zoom,
547
+ zoomDelta = Math.abs(lfView.zoom - mlView.zoom),
548
+ bearingDelta = Math.abs(((lfView.bearing - mlView.bearing + 180) % 360 + 360) % 360 - 180),
549
+ pitchDelta = Math.abs(lfView.pitch - mlView.pitch);
550
+ viewDelta = `${centerDelta.toFixed(1)}px / z ${zoomDelta.toFixed(2)}`;
551
+ viewMismatch = centerDelta > 3 || zoomDelta > 0.05 || bearingDelta > 0.1 || pitchDelta > 0.1;
552
+ }
553
+ const viewCell = row.insertCell();
554
+ viewCell.className = 'ml-only';
555
+ viewCell.classList.toggle('hidden', !ml);
556
+ viewCell.textContent = viewDelta || '';
557
+ viewCell.classList.toggle('bad', viewMismatch);
558
+ const spatialCell = row.insertCell();
559
+ spatialCell.className = 'ml-only';
560
+ spatialCell.classList.toggle('hidden', !ml);
561
+ spatialCell.textContent = ml ? `${Math.max(...results.map(r => r.spatialDelta)).toFixed(1)}px` : '';
562
+ spatialCell.classList.toggle('bad', results.some(r => r.spatialDelta > 3));
345
563
  row.className = '';
346
564
  running = false;
347
565
  $('run').disabled = $('run-all').disabled = false;
348
566
  return results;
349
567
  };
568
+ // Expose the same cases/runner to browser automation and the interactive controls.
350
569
  window.scenarios = scenarios;
351
570
  window.runScenario = run;
352
571
  $('run').onclick = () => run(scenarios[$('scenario').value]);