leaflet-rotate 0.5.0 → 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/README.md +17 -6
- package/dist/leaflet-rotate-src.js +606 -168
- 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/lib/debug.js +62 -88
- package/lib/debug.min.js +16 -1
- package/package.json +1 -1
- package/src/control/Rotate.js +102 -118
- package/src/index.js +1 -0
- package/src/layer/tile/GridLayer.js +20 -1
- package/src/layer/vector/Renderer.js +37 -34
- package/src/map/Map.js +369 -72
- package/src/map/handler/CompassBearing.js +7 -0
- package/src/map/handler/ShiftKeyRotate.js +64 -13
- package/src/map/handler/TouchGestures.js +45 -3
- package/src/map/handler/TouchPitch.js +53 -0
- package/test/features/example.feature +8 -4
- package/test/features/gestures.feature +4 -0
- package/test/features/rotation.feature +35 -0
- package/test/index.html +559 -185
- package/test/steps/example.steps.js +56 -29
- package/test/steps/gestures.steps.js +55 -0
- package/test/steps/{bearing.steps.js → rotation.steps.js} +312 -15
package/test/index.html
CHANGED
|
@@ -1,205 +1,579 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
2
|
<html>
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
<head>
|
|
5
|
+
<title>leaflet-rotate.js</title>
|
|
6
|
+
<meta charset="UTF-8">
|
|
7
|
+
<meta name="viewport" content="initial-scale=1.0, user-scalable=no" />
|
|
8
|
+
<link id="leaflet-css" rel="stylesheet">
|
|
9
|
+
<script src="../misc/route.js"></script>
|
|
10
|
+
<script src="../misc/places.js"></script>
|
|
11
|
+
<script src="../misc/loremIpsum.js"></script>
|
|
12
|
+
<style>
|
|
13
|
+
body { font: 13px sans-serif; margin: 0; padding: 8px; color: #222; }
|
|
14
|
+
header { display: flex; flex-wrap: wrap; align-items: stretch; gap: 6px 8px; }
|
|
15
|
+
header h1 { font-size: 16px; margin: 0 8px 0 0; align-self: center; }
|
|
16
|
+
header h1 a { color: inherit; text-decoration: none; }
|
|
17
|
+
fieldset { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; margin: 0; padding: 2px 8px 4px; border: 1px solid #ccc; border-radius: 4px; }
|
|
18
|
+
legend { color: #777; font-size: 11px; padding: 0 4px; }
|
|
19
|
+
#scenario { max-width: 22em; }
|
|
20
|
+
.hint { color: #777; margin: 6px 0; }
|
|
21
|
+
kbd { font: 11px monospace; border: 1px solid #ccc; border-radius: 3px; padding: 0 3px; background: #f6f6f6; }
|
|
22
|
+
#maps { display: flex; gap: 8px; }
|
|
23
|
+
#maps > div { flex: 1; min-width: 0; }
|
|
24
|
+
#maps h2 { font-size: 13px; margin: 0 0 2px; }
|
|
25
|
+
.map { height: 60vh; border: 1px solid #ccc; }
|
|
26
|
+
.info { font-family: monospace; padding: 2px 0; color: #555; }
|
|
27
|
+
#panels { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(480px, 100%), 1fr)); gap: 8px; margin-top: 8px; align-items: start; }
|
|
28
|
+
details { border: 1px solid #ccc; border-radius: 4px; padding: 4px 8px; min-width: 0; overflow-x: auto; }
|
|
29
|
+
summary { cursor: pointer; font-weight: bold; }
|
|
30
|
+
summary button { font-weight: normal; margin-left: 8px; }
|
|
31
|
+
#results { border-collapse: collapse; margin-top: 6px; width: 100%; }
|
|
32
|
+
#results td, #results th { border: 1px solid #ddd; padding: 2px 6px; text-align: left; }
|
|
33
|
+
#results td.bad { background: #fdd; }
|
|
34
|
+
#results .running { background: #ffd; }
|
|
35
|
+
.hidden { display: none !important; }
|
|
36
|
+
</style>
|
|
37
|
+
</head>
|
|
8
38
|
|
|
9
|
-
|
|
39
|
+
<body>
|
|
40
|
+
<header>
|
|
41
|
+
<h1><a href="https://github.com/Raruto/leaflet-rotate" title="View on GitHub">leaflet-rotate</a></h1>
|
|
42
|
+
<fieldset>
|
|
43
|
+
<legend>Setup</legend>
|
|
44
|
+
<label>Leaflet <select id="leaflet-version"><option value="1">1.9.4</option><option value="2">2.0.0-alpha.1</option></select></label>
|
|
45
|
+
<label title="Compare with MapLibre GL side by side"><input type="checkbox" id="maplibre"> MapLibre</label>
|
|
46
|
+
<label class="ml-only" title="Keep both views in sync (the hovered map drives the other one)"><input type="checkbox" id="sync" checked> sync</label>
|
|
47
|
+
<label title="Show the leaflet-rotate debug overlays and info panel"><input type="checkbox" id="debug"> debug</label>
|
|
48
|
+
<label title="MapLibre-like continuous zoom (needed for an exact view sync)"><input type="checkbox" id="snap"> zoomSnap: 0</label>
|
|
49
|
+
</fieldset>
|
|
50
|
+
<fieldset>
|
|
51
|
+
<legend>Scenarios</legend>
|
|
52
|
+
<select id="scenario"></select>
|
|
53
|
+
<button id="run">Run</button>
|
|
54
|
+
<button id="run-all">Run all</button>
|
|
55
|
+
</fieldset>
|
|
56
|
+
</header>
|
|
57
|
+
<p class="hint"><kbd>Ctrl</kbd>+drag / right-drag: pitch + bearing · <kbd>Shift</kbd>+wheel: rotate · <kbd>Ctrl</kbd>+wheel: trackpad pinch · click the compass to reset bearing + pitch</p>
|
|
58
|
+
<main id="maps">
|
|
59
|
+
<div><h2 class="ml-only">leaflet-rotate</h2><div id="lf" class="map"></div><div id="lf-info" class="info"></div></div>
|
|
60
|
+
<div class="ml-only"><h2>MapLibre GL</h2><div id="ml" class="map"></div><div id="ml-info" class="info"></div></div>
|
|
61
|
+
</main>
|
|
62
|
+
<section id="panels">
|
|
63
|
+
<details id="results-panel" class="hidden" open>
|
|
64
|
+
<summary>Scenario results <button id="clear">Clear</button></summary>
|
|
65
|
+
<table id="results">
|
|
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>
|
|
67
|
+
<tbody></tbody>
|
|
68
|
+
</table>
|
|
69
|
+
</details>
|
|
70
|
+
</section>
|
|
10
71
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
+
*/
|
|
82
|
+
const $ = id => document.getElementById(id);
|
|
83
|
+
const params = new URLSearchParams(location.search);
|
|
84
|
+
$('leaflet-version').value = params.get('leaflet') === '2' ? '2' : '1';
|
|
85
|
+
$('maplibre').checked = params.get('maplibre') !== '0';
|
|
86
|
+
$('debug').checked = params.get('debug') !== '0';
|
|
87
|
+
$('snap').checked = params.get('snap') === '0';
|
|
88
|
+
/** Reload the harness with the selected library/debug/zoom configuration. */
|
|
89
|
+
const reload = () => {
|
|
90
|
+
const query = new URLSearchParams({ leaflet: $('leaflet-version').value });
|
|
91
|
+
if (!$('maplibre').checked) query.set('maplibre', '0');
|
|
92
|
+
if (!$('debug').checked) query.set('debug', '0');
|
|
93
|
+
if ($('snap').checked) query.set('snap', '0');
|
|
94
|
+
location.search = query;
|
|
95
|
+
};
|
|
96
|
+
for (const id of ['leaflet-version', 'maplibre', 'debug', 'snap']) $(id).onchange = reload;
|
|
97
|
+
const compare = $('maplibre').checked;
|
|
98
|
+
for (const el of document.querySelectorAll('.ml-only')) el.classList.toggle('hidden', !compare);
|
|
15
99
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
100
|
+
/** Load scripts in dependency order; reject explicitly if a CDN or local file fails. */
|
|
101
|
+
const loadScript = src => new Promise((resolve, reject) => {
|
|
102
|
+
const script = Object.assign(document.createElement('script'), { src, onload: resolve, onerror: () => reject(new Error(`Could not load ${src}`)) });
|
|
103
|
+
document.head.appendChild(script);
|
|
104
|
+
});
|
|
105
|
+
const loadStyle = href => document.head.appendChild(Object.assign(document.createElement('link'), { rel: 'stylesheet', href }));
|
|
106
|
+
const v2 = $('leaflet-version').value === '2';
|
|
107
|
+
$('leaflet-css').href = `https://unpkg.com/leaflet@${v2 ? '2.0.0-alpha.1' : '1.9.4'}/dist/leaflet.css`;
|
|
108
|
+
await loadScript(`https://unpkg.com/leaflet@${v2 ? '2.0.0-alpha.1/dist/leaflet-global-src.js' : '1.9.4/dist/leaflet-src.js'}`);
|
|
109
|
+
if (v2) globalThis.L = globalThis.leaflet;
|
|
110
|
+
await loadScript('../dist/leaflet-rotate-src.js');
|
|
111
|
+
if ($('debug').checked) await loadScript('../lib/debug.js');
|
|
112
|
+
if (compare) {
|
|
113
|
+
loadStyle('https://unpkg.com/maplibre-gl@5/dist/maplibre-gl.css');
|
|
114
|
+
await loadScript('https://unpkg.com/maplibre-gl@5/dist/maplibre-gl.js');
|
|
115
|
+
}
|
|
116
|
+
const L = globalThis.L;
|
|
30
117
|
|
|
31
|
-
|
|
118
|
+
const tiles = 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}';
|
|
119
|
+
const polygonLatLngs = [[48, -3], [50, -4], [52, 4]];
|
|
32
120
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
121
|
+
// leaflet-rotate
|
|
122
|
+
const esri = window.esri = new L.TileLayer(tiles, {
|
|
123
|
+
maxZoom: 24,
|
|
124
|
+
maxNativeZoom: 18,
|
|
125
|
+
attribution: 'Tiles © Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community',
|
|
126
|
+
});
|
|
127
|
+
const osm = window.osm = new L.TileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
128
|
+
maxZoom: 24,
|
|
129
|
+
maxNativeZoom: 19,
|
|
130
|
+
attribution: '© <a href="http://openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
|
131
|
+
});
|
|
132
|
+
const lf = window.lf = window.map = new L.Map('lf', {
|
|
133
|
+
center: [55, 10],
|
|
134
|
+
zoom: 2,
|
|
135
|
+
maxZoom: 20,
|
|
136
|
+
layers: [esri],
|
|
137
|
+
rotate: true,
|
|
138
|
+
bearing: 30,
|
|
139
|
+
rotateControl: { closeOnZeroBearing: false },
|
|
140
|
+
compassBearing: !compare,
|
|
141
|
+
touchRotate: true,
|
|
142
|
+
touchPitch: true,
|
|
143
|
+
zoomSnap: $('snap').checked ? 0 : 1,
|
|
144
|
+
});
|
|
145
|
+
window.layers = new L.Control.Layers({ 'Empty': new L.TileLayer(''), 'Streets': osm, 'Satellite': esri }, null, { collapsed: false }).addTo(lf);
|
|
146
|
+
window.markers = Object.keys(places).map((name, i) => new L.Marker(places[name], { draggable: true })
|
|
147
|
+
.bindPopup(`<b>${name}</b><br>${loremIpsum}`)
|
|
148
|
+
.bindTooltip(`<b>${name}</b>`, i ? {} : { direction: 'right', permanent: true })
|
|
149
|
+
.addTo(lf));
|
|
150
|
+
window.path = new L.Polyline(route, { color: 'yellow', renderer: new L.Canvas() }).addTo(lf);
|
|
151
|
+
window.circle = new L.Circle([53, 4], 111111).addTo(lf);
|
|
152
|
+
window.polygon = new L.Polygon(polygonLatLngs).addTo(lf);
|
|
153
|
+
window.rectangle = new L.Rectangle([[60.81123, 25.71632], [60.81028, 25.73166], [60.7997, 25.72892], [60.80061, 25.71362]]).addTo(lf);
|
|
154
|
+
if ($('debug').checked) {
|
|
155
|
+
L.Rotate.debug(lf, { container: $('panels') });
|
|
156
|
+
}
|
|
38
157
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
158
|
+
// MapLibre (512px zoom levels => zoom - 1, bearing sign is inverted)
|
|
159
|
+
const ml = window.ml = compare ? new maplibregl.Map({
|
|
160
|
+
container: 'ml', center: [5, 50], zoom: 3, maxPitch: 85, maxZoom: 19,
|
|
161
|
+
style: {
|
|
162
|
+
version: 8,
|
|
163
|
+
sources: {
|
|
164
|
+
esri: { type: 'raster', tiles: [tiles], tileSize: 256, maxzoom: 18 },
|
|
165
|
+
polygon: { type: 'geojson', data: { type: 'Polygon', coordinates: [[...polygonLatLngs, polygonLatLngs[0]].map(([lat, lng]) => [lng, lat])] } },
|
|
166
|
+
route: { type: 'geojson', data: { type: 'LineString', coordinates: route.map(([lat, lng]) => [lng, lat]) } },
|
|
167
|
+
},
|
|
168
|
+
layers: [
|
|
169
|
+
{ id: 'esri', type: 'raster', source: 'esri' },
|
|
170
|
+
{ id: 'polygon', type: 'fill', source: 'polygon', paint: { 'fill-color': '#3388ff', 'fill-opacity': 0.2 } },
|
|
171
|
+
{ id: 'polygon-line', type: 'line', source: 'polygon', paint: { 'line-color': '#3388ff', 'line-width': 3 } },
|
|
172
|
+
{ id: 'route', type: 'line', source: 'route', paint: { 'line-color': 'yellow', 'line-width': 3 } },
|
|
173
|
+
],
|
|
174
|
+
},
|
|
175
|
+
}) : null;
|
|
176
|
+
if (ml) {
|
|
177
|
+
ml.addControl(new maplibregl.NavigationControl({ visualizePitch: true }));
|
|
178
|
+
for (const name in places) new maplibregl.Marker().setLngLat([places[name][1], places[name][0]]).setPopup(new maplibregl.Popup().setText(name)).addTo(ml);
|
|
179
|
+
}
|
|
42
180
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
+
*/
|
|
195
|
+
const maps = {
|
|
196
|
+
lf: {
|
|
197
|
+
el: lf.getContainer(),
|
|
198
|
+
get: () => { const c = lf.getCenter(); return { lat: c.lat, lng: c.lng, zoom: lf.getZoom(), bearing: lf.getBearing(), pitch: lf.getPitch() }; },
|
|
199
|
+
set: v => { lf.setView([v.lat, v.lng], v.zoom, { animate: false }); lf.setPitch(v.pitch); lf.setBearing(v.bearing); },
|
|
200
|
+
toLatLng: p => { const ll = lf.containerPointToLatLng(p); return [ll.lat, ll.lng]; },
|
|
201
|
+
toPoint: ([lat, lng]) => { const p = lf.latLngToContainerPoint([lat, lng]); return [p.x, p.y]; },
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
if (ml) maps.ml = {
|
|
205
|
+
el: ml.getContainer(),
|
|
206
|
+
get: () => { const c = ml.getCenter(); return { lat: c.lat, lng: c.lng, zoom: ml.getZoom() + 1, bearing: (360 - ml.getBearing()) % 360, pitch: ml.getPitch() }; },
|
|
207
|
+
set: v => ml.jumpTo({ center: [v.lng, v.lat], zoom: v.zoom - 1, bearing: -v.bearing, pitch: v.pitch }),
|
|
208
|
+
toLatLng: p => { const ll = ml.unproject(p); return [ll.lat, ll.lng]; },
|
|
209
|
+
toPoint: ([lat, lng]) => { const p = ml.project([lng, lat]); return [p.x, p.y]; },
|
|
210
|
+
};
|
|
63
211
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
212
|
+
// view sync: the last hovered map drives the other one
|
|
213
|
+
let source = 'lf', syncing = false, running = false;
|
|
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. */
|
|
216
|
+
const onView = key => () => {
|
|
217
|
+
$(`${key}-info`).textContent = fmt(maps[key].get());
|
|
218
|
+
if (!ml || key !== source || syncing || running || !$('sync').checked) return;
|
|
219
|
+
syncing = true;
|
|
220
|
+
const other = key === 'lf' ? 'ml' : 'lf';
|
|
221
|
+
maps[other].set(maps[key].get());
|
|
222
|
+
$(`${other}-info`).textContent = fmt(maps[other].get());
|
|
223
|
+
syncing = false;
|
|
224
|
+
};
|
|
225
|
+
lf.on('move zoom rotate pitch', onView('lf'));
|
|
226
|
+
onView('lf')();
|
|
227
|
+
if (ml) ml.on('move', onView('ml'));
|
|
228
|
+
for (const key in maps) maps[key].el.addEventListener('pointerenter', () => source = key);
|
|
71
229
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
230
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
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
|
+
*/
|
|
242
|
+
const driver = key => {
|
|
243
|
+
const el = maps[key].el;
|
|
244
|
+
let pressed = 0, button = 0;
|
|
245
|
+
/** Convert fractional container coordinates to viewport client pixels. */
|
|
246
|
+
const at = (fx, fy) => {
|
|
247
|
+
const r = el.getBoundingClientRect();
|
|
248
|
+
return { clientX: r.left + r.width * fx, clientY: r.top + r.height * fy };
|
|
249
|
+
};
|
|
250
|
+
/** Hit-test layers/markers; use body outside this map so document listeners still run. */
|
|
251
|
+
const target = ({ clientX, clientY }) => {
|
|
252
|
+
const hit = document.elementFromPoint(clientX, clientY);
|
|
253
|
+
return hit && el.contains(hit) ? hit : document.body;
|
|
254
|
+
};
|
|
255
|
+
const fire = (type, pos, mods = {}) => {
|
|
256
|
+
const init = { bubbles: true, cancelable: true, composed: true, view: window, ...pos, button, buttons: pressed, ...mods };
|
|
257
|
+
const t = target(pos);
|
|
258
|
+
t.dispatchEvent(new PointerEvent(`pointer${type}`, { ...init, pointerId: 1, pointerType: 'mouse', isPrimary: true }));
|
|
259
|
+
t.dispatchEvent(new MouseEvent(`mouse${type}`, init));
|
|
260
|
+
};
|
|
261
|
+
let last = at(0.5, 0.5);
|
|
262
|
+
return {
|
|
263
|
+
/** Press the selected mouse button at a fractional container position. */
|
|
264
|
+
async down(fx, fy, mods = {}) {
|
|
265
|
+
button = mods.button || 0;
|
|
266
|
+
pressed = button === 2 ? 2 : 1;
|
|
267
|
+
fire('down', last = at(fx, fy), mods);
|
|
268
|
+
await frame();
|
|
269
|
+
},
|
|
270
|
+
/** Interpolate from the last position, yielding one animation frame per step. */
|
|
271
|
+
async move(fx, fy, mods = {}, steps = 10) {
|
|
272
|
+
const from = last, to = at(fx, fy);
|
|
273
|
+
for (let i = 1; i <= steps; i++) {
|
|
274
|
+
last = { clientX: from.clientX + (to.clientX - from.clientX) * i / steps, clientY: from.clientY + (to.clientY - from.clientY) * i / steps };
|
|
275
|
+
fire('move', last, mods);
|
|
276
|
+
await frame();
|
|
277
|
+
}
|
|
278
|
+
},
|
|
279
|
+
/** Release at the last position, including contextmenu for right-button gestures. */
|
|
280
|
+
async up(mods = {}) {
|
|
281
|
+
pressed = 0;
|
|
282
|
+
fire('up', last, mods);
|
|
283
|
+
if (button === 2) target(last).dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, ...last, button: 2, ...mods }));
|
|
284
|
+
await frame();
|
|
285
|
+
},
|
|
286
|
+
/** Send a wheel burst with pixel deltas and a millisecond interval between events. */
|
|
287
|
+
async wheel(fx, fy, deltaY, mods = {}, count = 1, interval = 16) {
|
|
288
|
+
last = at(fx, fy);
|
|
289
|
+
for (let i = 0; i < count; i++) {
|
|
290
|
+
target(last).dispatchEvent(new WheelEvent('wheel', { bubbles: true, cancelable: true, ...last, deltaY, deltaMode: 0, ...mods }));
|
|
291
|
+
await sleep(interval);
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
wait: sleep,
|
|
295
|
+
};
|
|
296
|
+
};
|
|
79
297
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
+
*/
|
|
309
|
+
const ctrl = { ctrlKey: true }, right = { button: 2 };
|
|
310
|
+
/** Build a modified drag, using Ctrl by default or an explicit right-button modifier. */
|
|
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(); };
|
|
314
|
+
const flat = { lat: 50, lng: 5, zoom: 5, bearing: 0, pitch: 0 };
|
|
315
|
+
const scenarios = [
|
|
316
|
+
{ name: 'Ctrl+drag up: pitch from center', run: ctrlDrag([0.5, 0.5], [0.5, 0.3]) },
|
|
317
|
+
{ name: 'Ctrl+drag down: pitch clamps at 0', view: { pitch: 20 }, run: ctrlDrag([0.5, 0.4], [0.5, 0.9]) },
|
|
318
|
+
{ name: 'Ctrl+drag far up: pitch clamps at max', run: ctrlDrag([0.5, 0.95], [0.5, 0.0], 30) },
|
|
319
|
+
{ name: 'Ctrl+drag right, just above center', run: ctrlDrag([0.3, 0.45], [0.7, 0.45]) },
|
|
320
|
+
{ name: 'Ctrl+drag right, just below center', run: ctrlDrag([0.3, 0.55], [0.7, 0.55]) },
|
|
321
|
+
{ name: 'Ctrl+drag right, far above center (angular)', run: ctrlDrag([0.2, 0.05], [0.8, 0.05]) },
|
|
322
|
+
{ name: 'Ctrl+drag right, far below center (angular)', run: ctrlDrag([0.2, 0.95], [0.8, 0.95]) },
|
|
323
|
+
{ name: 'Ctrl+drag diagonal, pitched + rotated', view: { bearing: 40, pitch: 30 }, run: ctrlDrag([0.3, 0.7], [0.7, 0.3]) },
|
|
324
|
+
{ name: 'Ctrl+drag fast (2 big steps)', run: ctrlDrag([0.2, 0.8], [0.8, 0.2], 2) },
|
|
325
|
+
{
|
|
326
|
+
name: 'Ctrl+drag full circle around center', run: async d => {
|
|
327
|
+
await d.down(0.5, 0.1, ctrl);
|
|
328
|
+
for (let a = 0; a <= 360; a += 30) await d.move(0.5 + 0.4 * Math.sin(a * Math.PI / 180), 0.5 - 0.4 * Math.cos(a * Math.PI / 180), ctrl, 3);
|
|
329
|
+
await d.up(ctrl);
|
|
330
|
+
},
|
|
331
|
+
},
|
|
332
|
+
{ name: 'Ctrl+drag leaving the map', run: ctrlDrag([0.5, 0.5], [1.3, 0.2], 20) },
|
|
333
|
+
{ name: 'Ctrl+drag starting on a marker', view: { lat: places.London[0], lng: places.London[1], zoom: 6 }, run: ctrlDrag([0.5, 0.49], [0.7, 0.3]) },
|
|
334
|
+
{ name: 'Ctrl released mid-drag', run: async d => { await d.down(0.5, 0.6, ctrl); await d.move(0.6, 0.5, ctrl, 5); await d.move(0.7, 0.4, {}, 5); await d.up(); } },
|
|
335
|
+
{ name: 'Ctrl pressed mid-pan (should keep panning)', anchor: [[0.5, 0.5], [0.7, 0.4]], run: async d => { await d.down(0.5, 0.5); await d.move(0.6, 0.45, {}, 5); await d.move(0.7, 0.4, ctrl, 5); await d.up(ctrl); } },
|
|
336
|
+
{ name: 'Right-drag up + right', run: ctrlDrag([0.4, 0.6], [0.6, 0.4], 10, right) },
|
|
337
|
+
{ name: 'Ctrl+drag then plain pan', anchor: [[0.4, 0.5], [0.6, 0.6]], run: async d => { await ctrlDrag([0.5, 0.5], [0.6, 0.4])(d); await d.down(0.4, 0.5); await d.move(0.6, 0.6); await d.up(); } },
|
|
338
|
+
{ name: 'Wheel in at top-right, flat (control case)', anchor: [[0.85, 0.15], [0.85, 0.15]], run: async d => { await d.wheel(0.85, 0.15, -100, {}, 3, 30); } },
|
|
339
|
+
{ name: 'Wheel in at top-right, rotated', view: { bearing: 30 }, anchor: [[0.85, 0.15], [0.85, 0.15]], run: async d => { await d.wheel(0.85, 0.15, -100, {}, 3, 30); } },
|
|
340
|
+
{ name: 'Wheel in at top-right, pitch 60', view: { bearing: 30, pitch: 60 }, anchor: [[0.85, 0.15], [0.85, 0.15]], run: async d => { await d.wheel(0.85, 0.15, -100, {}, 3, 30); } },
|
|
341
|
+
{ name: 'Wheel out at bottom-left, pitch 45', view: { bearing: 200, pitch: 45, zoom: 7 }, anchor: [[0.1, 0.9], [0.1, 0.9]], run: async d => { await d.wheel(0.1, 0.9, 100, {}, 3, 30); } },
|
|
342
|
+
{ name: 'Wheel near horizon, pitch 75', view: { pitch: 75 }, anchor: [[0.5, 0.25], [0.5, 0.25]], run: async d => { await d.wheel(0.5, 0.25, -100, {}, 2, 30); } },
|
|
343
|
+
{ name: 'Ctrl+wheel pinch-in burst (trackpad), pitch 50', view: { bearing: 20, pitch: 50 }, anchor: [[0.8, 0.7], [0.8, 0.7]], run: async d => { await d.wheel(0.8, 0.7, -4, ctrl, 30); } },
|
|
344
|
+
{ name: 'Ctrl+wheel pinch-out burst (trackpad), rotated', view: { bearing: 60, zoom: 7 }, anchor: [[0.2, 0.3], [0.2, 0.3]], run: async d => { await d.wheel(0.2, 0.3, 4, ctrl, 30); } },
|
|
345
|
+
{ name: 'Ctrl+wheel mouse notches at center', view: { pitch: 40 }, anchor: [[0.5, 0.5], [0.5, 0.5]], run: async d => { await d.wheel(0.5, 0.5, -100, ctrl, 2, 200); } },
|
|
346
|
+
{ name: 'Shift+wheel rotate', run: async d => { await d.wheel(0.5, 0.5, 100, { shiftKey: true }, 6, 30); } },
|
|
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); } },
|
|
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); } },
|
|
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); } },
|
|
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]) },
|
|
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
|
+
})));
|
|
367
|
+
for (const [i, s] of scenarios.entries()) $('scenario').add(new Option(s.name, i));
|
|
84
368
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
+
}
|
|
88
415
|
} else {
|
|
89
|
-
|
|
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
|
+
}
|
|
90
422
|
}
|
|
91
|
-
|
|
92
|
-
await loadScript('../lib/debug.js');
|
|
93
|
-
const L = globalThis.L;
|
|
94
|
-
|
|
95
|
-
const esri = new L.TileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
|
|
96
|
-
id: 'mapbox.streets',
|
|
97
|
-
maxZoom: 24,
|
|
98
|
-
maxNativeZoom: 18,
|
|
99
|
-
attribution: 'Tiles © Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community',
|
|
100
|
-
// noWrap: true
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
const osm = new L.TileLayer('http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
104
|
-
maxZoom: 24,
|
|
105
|
-
maxNativeZoom: 19,
|
|
106
|
-
attribution: '© <a href="http://openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
|
107
|
-
// noWrap: true
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
const map = new L.Map('map', {
|
|
111
|
-
center: [55, 10],
|
|
112
|
-
zoom: 2,
|
|
113
|
-
layers: [esri],
|
|
114
|
-
// worldCopyJump: true,
|
|
115
|
-
// preferCanvas: false,
|
|
116
|
-
zoomAnimation: false, /* DEBUG: L.Renderer._updateTransform() */
|
|
117
|
-
rotate: true,
|
|
118
|
-
rotateControl: {
|
|
119
|
-
closeOnZeroBearing: false,
|
|
120
|
-
// position: 'bottomleft',
|
|
121
|
-
},
|
|
122
|
-
bearing: 30,
|
|
123
|
-
// attributionControl: false,
|
|
124
|
-
// zoomControl: false,
|
|
125
|
-
compassBearing: true,
|
|
126
|
-
// trackContainerMutation: false,
|
|
127
|
-
// shiftKeyRotate: false,
|
|
128
|
-
// touchGestures: true,
|
|
129
|
-
touchRotate: true,
|
|
130
|
-
// touchZoom: true
|
|
131
|
-
});
|
|
132
|
-
window.map = map;
|
|
133
|
-
window.esri = esri;
|
|
134
|
-
window.osm = osm;
|
|
423
|
+
};
|
|
135
424
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
.
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
)
|
|
168
|
-
.
|
|
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
|
+
*/
|
|
441
|
+
const runOn = async (key, scenario) => {
|
|
442
|
+
const m = maps[key];
|
|
443
|
+
const initialView = { ...flat, ...scenario.view },
|
|
444
|
+
other = maps[key === 'lf' ? 'ml' : 'lf'];
|
|
445
|
+
for (const map of Object.values(maps)) map.set(initialView);
|
|
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');
|
|
169
467
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
.
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
468
|
+
const anchor = scenario.anchor && (() => {
|
|
469
|
+
const r = m.el.getBoundingClientRect();
|
|
470
|
+
return scenario.anchor.map(([fx, fy]) => [r.width * fx, r.height * fy]);
|
|
471
|
+
})();
|
|
472
|
+
const latlng = anchor && m.toLatLng(anchor[0]);
|
|
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
|
+
}
|
|
483
|
+
const end = m.get(), p = anchor && m.toPoint(latlng);
|
|
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
|
+
};
|
|
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
|
+
*/
|
|
514
|
+
const run = async scenario => {
|
|
515
|
+
running = true;
|
|
516
|
+
$('run').disabled = $('run-all').disabled = true;
|
|
517
|
+
$('results-panel').classList.remove('hidden');
|
|
518
|
+
const row = $('results').tBodies[0].insertRow();
|
|
519
|
+
row.className = 'running';
|
|
520
|
+
row.insertCell().textContent = scenario.name;
|
|
521
|
+
// sequential: both libraries listen on document/window while dragging
|
|
522
|
+
const results = [];
|
|
523
|
+
for (const key in maps) results.push(await runOn(key, scenario));
|
|
524
|
+
for (const r of results) {
|
|
525
|
+
row.insertCell().textContent = `${r.end.zoom.toFixed(2)} / ${r.end.bearing.toFixed(1)} / ${r.end.pitch.toFixed(1)}`;
|
|
526
|
+
const cell = row.insertCell();
|
|
527
|
+
cell.textContent = r.drift == null ? '' : `${r.drift.toFixed(1)}px`;
|
|
528
|
+
cell.classList.toggle('bad', r.drift > 3);
|
|
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));
|
|
563
|
+
row.className = '';
|
|
564
|
+
running = false;
|
|
565
|
+
$('run').disabled = $('run-all').disabled = false;
|
|
566
|
+
return results;
|
|
567
|
+
};
|
|
568
|
+
// Expose the same cases/runner to browser automation and the interactive controls.
|
|
569
|
+
window.scenarios = scenarios;
|
|
570
|
+
window.runScenario = run;
|
|
571
|
+
$('run').onclick = () => run(scenarios[$('scenario').value]);
|
|
572
|
+
$('run-all').onclick = async () => { for (const s of scenarios) await run(s); };
|
|
573
|
+
$('clear').onclick = e => { e.preventDefault(); $('results').tBodies[0].replaceChildren(); $('results-panel').classList.add('hidden'); };
|
|
574
|
+
</script>
|
|
202
575
|
|
|
203
|
-
</
|
|
576
|
+
<a href="https://github.com/Raruto/leaflet-rotate" class="view-on-github" style="position: fixed;bottom: 10px;left: calc(50% - 60px);z-index: 9999;"> <img alt="View on Github" src="https://raruto.github.io/img/view-on-github.png" title="View on Github" width="163"> </a>
|
|
577
|
+
</body>
|
|
204
578
|
|
|
205
|
-
</html>
|
|
579
|
+
</html>
|