leaflet-rotate 0.5.1 → 0.5.3

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.
@@ -56,3 +56,12 @@ Feature: Rotating a Leaflet map
56
56
  Scenario: Opening a popup in a second rotated map does not move it
57
57
  When I open a popup in a second rotated map with map listeners
58
58
  Then the second map center remains unchanged and its popup stays anchored
59
+
60
+ Scenario Outline: Markers keep Leaflet DOM rendering when the map prefers Canvas
61
+ When I add markers to a Canvas-preferred map at pitch <pitch>
62
+ Then the markers use Leaflet DOM icons while vector paths use Canvas
63
+
64
+ Examples:
65
+ | pitch |
66
+ | 0 |
67
+ | 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
@@ -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
  }
@@ -165,23 +162,100 @@
165
162
  version: 8,
166
163
  sources: {
167
164
  esri: { type: 'raster', tiles: [tiles], tileSize: 256, maxzoom: 18 },
168
- polygon: { type: 'geojson', data: { type: 'Polygon', coordinates: [[...polygonLatLngs, polygonLatLngs[0]].map(([lat, lng]) => [lng, lat])] } },
169
- route: { type: 'geojson', data: { type: 'LineString', coordinates: route.map(([lat, lng]) => [lng, lat]) } },
165
+ overlays: { type: 'geojson', data: { type: 'FeatureCollection', features: [] } },
170
166
  },
171
167
  layers: [
172
168
  { id: 'esri', type: 'raster', source: 'esri' },
173
- { id: 'polygon', type: 'fill', source: 'polygon', paint: { 'fill-color': '#3388ff', 'fill-opacity': 0.2 } },
174
- { id: 'polygon-line', type: 'line', source: 'polygon', paint: { 'line-color': '#3388ff', 'line-width': 3 } },
175
- { 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'] } },
176
172
  ],
177
173
  },
178
174
  }) : null;
179
175
  if (ml) {
180
176
  ml.addControl(new maplibregl.NavigationControl({ visualizePitch: true }));
181
- 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);
182
243
  }
183
244
 
184
- // unified view access: { lat, lng, zoom (leaflet scale), bearing (leaflet sign), pitch }
245
+ /**
246
+ * @typedef {Object} View
247
+ * @property {number} lat Center latitude in degrees.
248
+ * @property {number} lng Center longitude in degrees.
249
+ * @property {number} zoom Leaflet zoom scale (256px world at zoom 0).
250
+ * @property {number} bearing Leaflet bearing sign, in degrees.
251
+ * @property {number} pitch Pitch in degrees.
252
+ */
253
+ /**
254
+ * Renderer adapters use View values and [lat, lng] geographic arrays.
255
+ * `get`/`set` read/apply a view without animation; `toLatLng`/`toPoint` convert
256
+ * container pixels to/from geography. MapLibre uses 512px zoom levels,
257
+ * the opposite bearing sign, and [lng, lat] arrays internally.
258
+ */
185
259
  const maps = {
186
260
  lf: {
187
261
  el: lf.getContainer(),
@@ -202,6 +276,7 @@
202
276
  // view sync: the last hovered map drives the other one
203
277
  let source = 'lf', syncing = false, running = false;
204
278
  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)}`;
279
+ /** Create a view listener; the hovered renderer drives sync, without recursive updates. */
205
280
  const onView = key => () => {
206
281
  $(`${key}-info`).textContent = fmt(maps[key].get());
207
282
  if (!ml || key !== source || syncing || running || !$('sync').checked) return;
@@ -216,16 +291,27 @@
216
291
  if (ml) ml.on('move', onView('ml'));
217
292
  for (const key in maps) maps[key].el.addEventListener('pointerenter', () => source = key);
218
293
 
219
- // synthetic input driver, coordinates are fractions of the map container
220
294
  const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
221
295
  const frame = () => new Promise(resolve => requestAnimationFrame(resolve));
296
+ /**
297
+ * Create synthetic mouse/pointer/wheel input for one renderer.
298
+ * Positions are container fractions (0..1 inside, outside values allowed).
299
+ * Emit both pointer and mouse events for the supported library versions.
300
+ * These untrusted events exercise handlers, not native browser input behavior;
301
+ * animation-frame pacing also depends on tab visibility.
302
+ *
303
+ * @param {'lf'|'ml'} key Renderer receiving the input.
304
+ * @returns {Object} Async down/move/up/wheel methods and a millisecond wait helper.
305
+ */
222
306
  const driver = key => {
223
307
  const el = maps[key].el;
224
308
  let pressed = 0, button = 0;
309
+ /** Convert fractional container coordinates to viewport client pixels. */
225
310
  const at = (fx, fy) => {
226
311
  const r = el.getBoundingClientRect();
227
312
  return { clientX: r.left + r.width * fx, clientY: r.top + r.height * fy };
228
313
  };
314
+ /** Hit-test layers/markers; use body outside this map so document listeners still run. */
229
315
  const target = ({ clientX, clientY }) => {
230
316
  const hit = document.elementFromPoint(clientX, clientY);
231
317
  return hit && el.contains(hit) ? hit : document.body;
@@ -238,12 +324,14 @@
238
324
  };
239
325
  let last = at(0.5, 0.5);
240
326
  return {
327
+ /** Press the selected mouse button at a fractional container position. */
241
328
  async down(fx, fy, mods = {}) {
242
329
  button = mods.button || 0;
243
330
  pressed = button === 2 ? 2 : 1;
244
331
  fire('down', last = at(fx, fy), mods);
245
332
  await frame();
246
333
  },
334
+ /** Interpolate from the last position, yielding one animation frame per step. */
247
335
  async move(fx, fy, mods = {}, steps = 10) {
248
336
  const from = last, to = at(fx, fy);
249
337
  for (let i = 1; i <= steps; i++) {
@@ -252,12 +340,14 @@
252
340
  await frame();
253
341
  }
254
342
  },
343
+ /** Release at the last position, including contextmenu for right-button gestures. */
255
344
  async up(mods = {}) {
256
345
  pressed = 0;
257
346
  fire('up', last, mods);
258
347
  if (button === 2) target(last).dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, ...last, button: 2, ...mods }));
259
348
  await frame();
260
349
  },
350
+ /** Send a wheel burst with pixel deltas and a millisecond interval between events. */
261
351
  async wheel(fx, fy, deltaY, mods = {}, count = 1, interval = 16) {
262
352
  last = at(fx, fy);
263
353
  for (let i = 0; i < count; i++) {
@@ -269,9 +359,22 @@
269
359
  };
270
360
  };
271
361
 
272
- // scenarios: view = initial view (leaflet units), anchor = [from, to] screen fractions that should keep the same latlng
362
+ /**
363
+ * @typedef {Object} Scenario
364
+ * @property {string} name Display label.
365
+ * @property {Partial<View>} [view] Overrides of the shared `flat` starting view.
366
+ * @property {number[][]} [anchor] [from, to] container fractions expected to hold
367
+ * the same geographic point; absent when cursor anchoring is not measured.
368
+ * @property {boolean} [noInertia] Disable/stop inertia for endpoint measurements.
369
+ * @property {function(Object): Promise<void>} [run] Synthetic gesture using a driver.
370
+ * @property {string} [goTo] Navigation destination, used instead of `run`.
371
+ * @property {number} [settle=900] Milliseconds to wait after input/navigation completes.
372
+ */
273
373
  const ctrl = { ctrlKey: true }, right = { button: 2 };
374
+ /** Build a modified drag, using Ctrl by default or an explicit right-button modifier. */
274
375
  const ctrlDrag = (from, to, steps, mods = ctrl) => async d => { await d.down(...from, mods); await d.move(...to, mods, steps); await d.up(mods); };
376
+ /** Build one uninterrupted plain drag through fractional-container waypoints. */
377
+ 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
378
  const flat = { lat: 50, lng: 5, zoom: 5, bearing: 0, pitch: 0 };
276
379
  const scenarios = [
277
380
  { name: 'Ctrl+drag up: pitch from center', run: ctrlDrag([0.5, 0.5], [0.5, 0.3]) },
@@ -308,24 +411,170 @@
308
411
  { 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
412
  { 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
413
  { 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(); } },
414
+ { 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(); } },
415
+ { 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]) },
416
+ { 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]) },
417
+ { 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]) },
418
+ { 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]) },
419
+ { 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]) },
420
+ { 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]) },
421
+ { 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]) },
422
+ { 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]) },
423
+ { 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
424
  ];
425
+ scenarios.push(...['Circle', 'Polygon', 'Path', ...Object.keys(places), 'Rectangle (fit from z 0)'].map(name => ({
426
+ name: `Go to: ${name}`,
427
+ goTo: name,
428
+ view: { bearing: 45, pitch: 70 },
429
+ settle: 300,
430
+ })));
313
431
  for (const [i, s] of scenarios.entries()) $('scenario').add(new Option(s.name, i));
314
432
 
433
+ /**
434
+ * Run equivalent fly/fit navigation, preserving each renderer's bearing and pitch.
435
+ * Wait for moveend (8s timeout), not a fixed flight duration. Fit algorithms and
436
+ * zoom snapping differ between renderers, so final views need not match exactly.
437
+ *
438
+ * @param {'lf'|'ml'} key Renderer to navigate.
439
+ * @param {string} name Layer bounds, a `places` key, or the rectangle reset case.
440
+ */
441
+ const runGoTo = async (key, name) => {
442
+ const map = key === 'lf' ? lf : ml,
443
+ // Subscribe before invoking the action: nonanimated fits can end synchronously.
444
+ finishMove = action => {
445
+ const finished = new Promise((resolve, reject) => {
446
+ const onMoveEnd = () => {
447
+ clearTimeout(timer);
448
+ resolve();
449
+ };
450
+ const timer = setTimeout(() => {
451
+ map.off('moveend', onMoveEnd);
452
+ reject(new Error(`Go to "${name}" did not finish moving`));
453
+ }, 8000);
454
+ map.once('moveend', onMoveEnd);
455
+ });
456
+ action();
457
+ return finished;
458
+ },
459
+ view = maps[key].get(),
460
+ mapLibreCamera = { bearing: -view.bearing, pitch: view.pitch };
461
+ if (name === 'Circle' || name === 'Polygon' || name === 'Path') {
462
+ const bounds = name === 'Circle' ? circle.getBounds() : name === 'Polygon' ? polygon.getBounds() : path.getBounds();
463
+ if (key === 'lf') {
464
+ await finishMove(() => lf.flyToBounds(bounds));
465
+ } else {
466
+ await finishMove(() => ml.fitBounds([[bounds.getWest(), bounds.getSouth()], [bounds.getEast(), bounds.getNorth()]], { ...mapLibreCamera, duration: 1000 }));
467
+ }
468
+ } else if (name === 'Rectangle (fit from z 0)') {
469
+ if (key === 'lf') {
470
+ lf.setZoom(0, { animate: false });
471
+ await sleep(1000);
472
+ await finishMove(() => lf.fitBounds(rectangle.getBounds(), { animate: false }));
473
+ } else {
474
+ ml.setZoom(-1);
475
+ await sleep(1000);
476
+ const bounds = rectangle.getBounds();
477
+ await finishMove(() => ml.fitBounds([[bounds.getWest(), bounds.getSouth()], [bounds.getEast(), bounds.getNorth()]], { ...mapLibreCamera, duration: 0 }));
478
+ }
479
+ } else {
480
+ const latlng = places[name];
481
+ if (key === 'lf') {
482
+ await finishMove(() => lf.flyTo(latlng, 10));
483
+ } else {
484
+ await finishMove(() => ml.flyTo({ center: [latlng[1], latlng[0]], zoom: 9, ...mapLibreCamera, duration: 1000 }));
485
+ }
486
+ }
487
+ };
488
+
489
+ /**
490
+ * Reset both views, run one renderer, and measure its independent final state.
491
+ *
492
+ * Drift is the screen distance between the original geographic anchor and its
493
+ * expected endpoint. For spatial delta, first sync the other renderer to the
494
+ * final view, then compare six fixed geographic probes relative to viewport
495
+ * centers, subtracting each probe's initial cross-renderer difference.
496
+ * Initially non-finite probes are excluded; a valid probe becoming non-finite
497
+ * produces Infinity. After large flights, probes can be offscreen/near the
498
+ * horizon: spatial delta is a diagnostic, not a viewport-wide accuracy bound.
499
+ *
500
+ * @param {'lf'|'ml'} key Renderer whose input is being tested.
501
+ * @param {Scenario} scenario Gesture or navigation definition.
502
+ * @returns {Promise<{end: View, drift: ?number, spatialDelta: ?number}>}
503
+ * Final view and pixel errors; null denotes an unavailable measurement.
504
+ */
315
505
  const runOn = async (key, scenario) => {
316
506
  const m = maps[key];
317
- m.set({ ...flat, ...scenario.view });
507
+ const initialView = { ...flat, ...scenario.view },
508
+ other = maps[key === 'lf' ? 'ml' : 'lf'];
509
+ for (const map of Object.values(maps)) map.set(initialView);
318
510
  await sleep(300);
511
+ let relativePoints, initialDifferences;
512
+ if (other) {
513
+ 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]]
514
+ .map(([x, y]) => {
515
+ const r = m.el.getBoundingClientRect();
516
+ return m.toLatLng([r.width * x, r.height * y]);
517
+ });
518
+ // Center-relative coordinates remove the offset from unequal container sizes.
519
+ relativePoints = map => {
520
+ const r = map.el.getBoundingClientRect();
521
+ return probes.map(ll => {
522
+ const p = map.toPoint(ll);
523
+ return p.every(Number.isFinite) ? [p[0] - r.width / 2, p[1] - r.height / 2] : null;
524
+ });
525
+ };
526
+ const initialLf = relativePoints(maps.lf), initialMl = relativePoints(maps.ml);
527
+ initialDifferences = initialLf.map((p, i) => p && initialMl[i]
528
+ ? [p[0] - initialMl[i][0], p[1] - initialMl[i][1]]
529
+ : null);
530
+ if (!initialDifferences.some(Boolean)) throw new Error('No common finite geographic probes for this view');
531
+ }
319
532
  const anchor = scenario.anchor && (() => {
320
533
  const r = m.el.getBoundingClientRect();
321
534
  return scenario.anchor.map(([fx, fy]) => [r.width * fx, r.height * fy]);
322
535
  })();
323
536
  const latlng = anchor && m.toLatLng(anchor[0]);
324
- await scenario.run(driver(key));
325
- await sleep(900);
537
+ const inertia = key === 'lf' && scenario.noInertia ? lf.options.inertia : undefined;
538
+ if (inertia !== undefined) lf.options.inertia = false;
539
+ try {
540
+ if (scenario.goTo) await runGoTo(key, scenario.goTo);
541
+ else await scenario.run(driver(key));
542
+ if (key === 'ml' && scenario.noInertia) ml.stop();
543
+ await sleep(scenario.settle || 900);
544
+ } finally {
545
+ if (inertia !== undefined) lf.options.inertia = inertia;
546
+ }
326
547
  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 };
548
+ let spatialDelta = null;
549
+ if (other) {
550
+ other.set(end);
551
+ await sleep(100);
552
+ const endLf = relativePoints(maps.lf), endMl = relativePoints(maps.ml);
553
+ spatialDelta = Math.max(...initialDifferences.map((baseline, i) => {
554
+ if (!baseline) return 0;
555
+ if (!endLf[i] || !endMl[i]) return Infinity;
556
+ const point = endLf[i];
557
+ const dx = point[0] - endMl[i][0] - initialDifferences[i][0],
558
+ dy = point[1] - endMl[i][1] - initialDifferences[i][1];
559
+ return Math.hypot(dx, dy);
560
+ }));
561
+ }
562
+ return {
563
+ end,
564
+ drift: anchor ? Math.hypot(p[0] - anchor[1][0], p[1] - anchor[1][1]) : null,
565
+ spatialDelta,
566
+ };
328
567
  };
568
+ /**
569
+ * Run a scenario sequentially on each enabled renderer and append a result row.
570
+ * Compare independent final views before spatial synchronization affects them:
571
+ * center distance uses wrapped Web Mercator pixels at the mean Leaflet zoom.
572
+ * Red cells flag drift/spatial/center errors >3px, zoom differences >0.05,
573
+ * or bearing/pitch differences >0.1 degrees; these are diagnostic thresholds.
574
+ *
575
+ * @param {Scenario} scenario Selected test case.
576
+ * @returns {Promise<Object[]>} Per-renderer measurements in adapter iteration order.
577
+ */
329
578
  const run = async scenario => {
330
579
  running = true;
331
580
  $('run').disabled = $('run-all').disabled = true;
@@ -342,11 +591,45 @@
342
591
  cell.textContent = r.drift == null ? '' : `${r.drift.toFixed(1)}px`;
343
592
  cell.classList.toggle('bad', r.drift > 3);
344
593
  }
594
+ let viewDelta = null, viewMismatch = false;
595
+ if (ml) {
596
+ const [lfView, mlView] = results.map(result => result.end),
597
+ // Normalized Web Mercator world coordinates, clamped to its latitude limit.
598
+ project = ({ lat, lng }, zoom) => {
599
+ const sin = Math.sin(Math.max(-85.05112878, Math.min(85.05112878, lat)) * Math.PI / 180);
600
+ return [
601
+ (lng + 180) / 360,
602
+ 0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI),
603
+ ];
604
+ },
605
+ zoom = (lfView.zoom + mlView.zoom) / 2,
606
+ a = project(lfView, zoom),
607
+ b = project(mlView, zoom),
608
+ dx = ((a[0] - b[0] + 0.5) % 1 + 1) % 1 - 0.5,
609
+ dy = a[1] - b[1],
610
+ centerDelta = Math.hypot(dx, dy) * 256 * 2 ** zoom,
611
+ zoomDelta = Math.abs(lfView.zoom - mlView.zoom),
612
+ bearingDelta = Math.abs(((lfView.bearing - mlView.bearing + 180) % 360 + 360) % 360 - 180),
613
+ pitchDelta = Math.abs(lfView.pitch - mlView.pitch);
614
+ viewDelta = `${centerDelta.toFixed(1)}px / z ${zoomDelta.toFixed(2)}`;
615
+ viewMismatch = centerDelta > 3 || zoomDelta > 0.05 || bearingDelta > 0.1 || pitchDelta > 0.1;
616
+ }
617
+ const viewCell = row.insertCell();
618
+ viewCell.className = 'ml-only';
619
+ viewCell.classList.toggle('hidden', !ml);
620
+ viewCell.textContent = viewDelta || '';
621
+ viewCell.classList.toggle('bad', viewMismatch);
622
+ const spatialCell = row.insertCell();
623
+ spatialCell.className = 'ml-only';
624
+ spatialCell.classList.toggle('hidden', !ml);
625
+ spatialCell.textContent = ml ? `${Math.max(...results.map(r => r.spatialDelta)).toFixed(1)}px` : '';
626
+ spatialCell.classList.toggle('bad', results.some(r => r.spatialDelta > 3));
345
627
  row.className = '';
346
628
  running = false;
347
629
  $('run').disabled = $('run-all').disabled = false;
348
630
  return results;
349
631
  };
632
+ // Expose the same cases/runner to browser automation and the interactive controls.
350
633
  window.scenarios = scenarios;
351
634
  window.runScenario = run;
352
635
  $('run').onclick = () => run(scenarios[$('scenario').value]);