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.
- package/README.md +2 -2
- package/build/server.js +19 -3
- package/dist/leaflet-rotate-src.js +313 -73
- package/dist/leaflet-rotate-src.js.map +4 -4
- package/dist/leaflet-rotate.js +1 -1
- package/dist/leaflet-rotate.js.map +4 -4
- package/package.json +1 -1
- package/src/index.js +1 -0
- package/src/layer/marker/Marker.js +24 -11
- package/src/layer/tile/GridLayer.js +16 -1
- package/src/map/Map.js +270 -61
- package/src/map/handler/Drag.js +59 -0
- package/test/features/navigation-bounds.feature +33 -0
- package/test/features/overlays.feature +9 -0
- package/test/features/pitch.feature +19 -0
- package/test/features/rotation.feature +5 -0
- package/test/index.html +312 -29
- package/test/steps/navigation-bounds.steps.js +195 -0
- package/test/steps/overlays.steps.js +44 -0
- package/test/steps/pitch.steps.js +264 -0
- package/test/steps/{bearing.steps.js → rotation.steps.js} +147 -19
- package/test/support/hooks.js +19 -7
|
@@ -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
|
-
|
|
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: '
|
|
174
|
-
{ id: '
|
|
175
|
-
{ id: '
|
|
169
|
+
{ id: 'overlay-fills', type: 'fill', source: 'overlays', filter: ['==', ['geometry-type'], 'Polygon'], paint: { 'fill-color': ['get', 'fillColor'], 'fill-opacity': ['get', 'fillOpacity'] } },
|
|
170
|
+
{ id: 'overlay-lines', type: 'line', source: 'overlays', filter: ['!=', ['geometry-type'], 'Point'], paint: { 'line-color': ['get', 'color'], 'line-opacity': ['get', 'opacity'], 'line-width': ['get', 'weight'] } },
|
|
171
|
+
{ id: 'overlay-points', type: 'circle', source: 'overlays', filter: ['==', ['geometry-type'], 'Point'], paint: { 'circle-radius': ['get', 'radius'], 'circle-color': ['get', 'fillColor'], 'circle-opacity': ['get', 'fillOpacity'], 'circle-stroke-color': ['get', 'color'], 'circle-stroke-opacity': ['get', 'opacity'], 'circle-stroke-width': ['get', 'weight'] } },
|
|
176
172
|
],
|
|
177
173
|
},
|
|
178
174
|
}) : null;
|
|
179
175
|
if (ml) {
|
|
180
176
|
ml.addControl(new maplibregl.NavigationControl({ visualizePitch: true }));
|
|
181
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
325
|
-
|
|
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
|
-
|
|
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]);
|