leaflet-rotate 0.5.0 → 0.5.1

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/test/index.html CHANGED
@@ -1,205 +1,360 @@
1
1
  <!DOCTYPE html>
2
2
  <html>
3
3
 
4
- <head>
5
- <title>leaflet-rotate.js</title>
6
- <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
7
- <meta name="viewport" content="initial-scale=1.0, user-scalable=no" />
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
- <link id="leaflet-css" rel="stylesheet">
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>Go to</legend>
52
+ <select id="goto" title="Fly to a layer or place"><option value="">—</option></select>
53
+ </fieldset>
54
+ <fieldset>
55
+ <legend>Scenarios</legend>
56
+ <select id="scenario"></select>
57
+ <button id="run">Run</button>
58
+ <button id="run-all">Run all</button>
59
+ </fieldset>
60
+ </header>
61
+ <p class="hint"><kbd>Ctrl</kbd>+drag / right-drag: pitch + bearing &middot; <kbd>Shift</kbd>+wheel: rotate &middot; <kbd>Ctrl</kbd>+wheel: trackpad pinch &middot; click the compass to reset bearing + pitch</p>
62
+ <main id="maps">
63
+ <div><h2 class="ml-only">leaflet-rotate</h2><div id="lf" class="map"></div><div id="lf-info" class="info"></div></div>
64
+ <div class="ml-only"><h2>MapLibre GL</h2><div id="ml" class="map"></div><div id="ml-info" class="info"></div></div>
65
+ </main>
66
+ <section id="panels">
67
+ <details id="results-panel" class="hidden" open>
68
+ <summary>Scenario results <button id="clear">Clear</button></summary>
69
+ <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>
71
+ <tbody></tbody>
72
+ </table>
73
+ </details>
74
+ </section>
10
75
 
11
- <!-- include some miscellaneous -->
12
- <script src="../misc/route.js"></script>
13
- <script src="../misc/places.js"></script>
14
- <script src="../misc/loremIpsum.js"></script>
76
+ <script type="module">
77
+ const $ = id => document.getElementById(id);
78
+ const params = new URLSearchParams(location.search);
79
+ $('leaflet-version').value = params.get('leaflet') === '2' ? '2' : '1';
80
+ $('maplibre').checked = params.get('maplibre') !== '0';
81
+ $('debug').checked = params.get('debug') !== '0';
82
+ $('snap').checked = params.get('snap') === '0';
83
+ const reload = () => {
84
+ const query = new URLSearchParams({ leaflet: $('leaflet-version').value });
85
+ if (!$('maplibre').checked) query.set('maplibre', '0');
86
+ if (!$('debug').checked) query.set('debug', '0');
87
+ if ($('snap').checked) query.set('snap', '0');
88
+ location.search = query;
89
+ };
90
+ for (const id of ['leaflet-version', 'maplibre', 'debug', 'snap']) $(id).onchange = reload;
91
+ const compare = $('maplibre').checked;
92
+ for (const el of document.querySelectorAll('.ml-only')) el.classList.toggle('hidden', !compare);
15
93
 
16
- <style>
17
- #map {
18
- width: 100%;
19
- height: 600px;
20
- border: 1px solid #ccc;
21
- max-height: 50vh;
22
- }
23
- /* TODO: fix incorrect map corners position when map has some margin */
24
- html, body {
25
- /* margin: 0; */
26
- /* padding: 0; */
27
- }
28
- </style>
29
- </head>
30
-
31
- <body>
32
-
33
- <label for="leaflet-version">Leaflet version:</label>
34
- <select id="leaflet-version">
35
- <option value="1">1.9.4</option>
36
- <option value="2">2.0.0-alpha.1</option>
37
- </select>
38
-
39
- <div id="map"></div>
40
-
41
- <hr>
42
-
43
- <div>
44
- <p>
45
- <strong>Fly to bounds: </strong>
46
- <button onclick="map.flyToBounds(circle.getBounds());">CIRCLE</button>
47
- <button onclick="map.flyToBounds(polygon.getBounds());"> POLYGON</button>
48
- <button onclick="map.flyToBounds(path.getBounds());">PATH</button>
49
- </p>
50
- <p>
51
- <strong>Fly to: </strong>
52
- <button onclick="map.flyTo(places['Kyiv'], 10);">Kyiv</button>
53
- <button onclick="map.flyTo(places['London'], 10);">London</button>
54
- <button onclick="map.flyTo(places['San Francisco'], 10);">San Francisco</button>
55
- <button onclick="map.flyTo(places['Washington'], 10);">Washington</button>
56
- <button onclick="map.flyTo(places['Trondheim'], 10);">Trondheim</button>
57
- </p>
58
- <p>
59
- <strong>Fit bounds (z = 0): </strong>
60
- <button onclick="map.setZoom(0, {animate: false}) && setTimeout(() => map.fitBounds(rectangle.getBounds(), { animate: false }), 1000);" title="DEBUG: map.setZoom(0) && map.getBoundsZoom(rectangle.getBounds())">RECTANGLE</button>
61
- </p>
62
- </div>
63
-
64
- <script type="module">
65
- const versionSelect = document.getElementById('leaflet-version');
66
- const params = new URLSearchParams(location.search);
67
- versionSelect.value = params.get('leaflet') === '2' ? '2' : '1';
68
- versionSelect.addEventListener('change', () => {
69
- location.search = `?leaflet=${versionSelect.value}`;
70
- });
71
-
72
- const loadScript = src => new Promise((resolve, reject) => {
73
- const script = document.createElement('script');
74
- script.src = src;
75
- script.onload = resolve;
76
- script.onerror = () => reject(new Error(`Could not load ${src}`));
77
- document.head.appendChild(script);
78
- });
79
-
80
- const version = versionSelect.value;
81
- document.getElementById('leaflet-css').href = version === '2'
82
- ? 'https://unpkg.com/leaflet@2.0.0-alpha.1/dist/leaflet.css'
83
- : 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
84
-
85
- if (version === '2') {
86
- await loadScript('https://unpkg.com/leaflet@2.0.0-alpha.1/dist/leaflet-global-src.js');
87
- globalThis.L = globalThis.leaflet;
88
- } else {
89
- await loadScript('https://unpkg.com/leaflet@1.9.4/dist/leaflet-src.js');
90
- }
91
- await loadScript('../dist/leaflet-rotate-src.js');
92
- await loadScript('../lib/debug.js');
93
- const L = globalThis.L;
94
+ const loadScript = src => new Promise((resolve, reject) => {
95
+ const script = Object.assign(document.createElement('script'), { src, onload: resolve, onerror: () => reject(new Error(`Could not load ${src}`)) });
96
+ document.head.appendChild(script);
97
+ });
98
+ const loadStyle = href => document.head.appendChild(Object.assign(document.createElement('link'), { rel: 'stylesheet', href }));
99
+ const v2 = $('leaflet-version').value === '2';
100
+ $('leaflet-css').href = `https://unpkg.com/leaflet@${v2 ? '2.0.0-alpha.1' : '1.9.4'}/dist/leaflet.css`;
101
+ await loadScript(`https://unpkg.com/leaflet@${v2 ? '2.0.0-alpha.1/dist/leaflet-global-src.js' : '1.9.4/dist/leaflet-src.js'}`);
102
+ if (v2) globalThis.L = globalThis.leaflet;
103
+ await loadScript('../dist/leaflet-rotate-src.js');
104
+ if ($('debug').checked) await loadScript('../lib/debug.js');
105
+ if (compare) {
106
+ loadStyle('https://unpkg.com/maplibre-gl@5/dist/maplibre-gl.css');
107
+ await loadScript('https://unpkg.com/maplibre-gl@5/dist/maplibre-gl.js');
108
+ }
109
+ const L = globalThis.L;
94
110
 
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 &copy; Esri &mdash; Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community',
100
- // noWrap: true
101
- });
111
+ const tiles = 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}';
112
+ const polygonLatLngs = [[48, -3], [50, -4], [52, 4]];
102
113
 
103
- const osm = new L.TileLayer('http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
104
- maxZoom: 24,
105
- maxNativeZoom: 19,
106
- attribution: '&copy; <a href="http://openstreetmap.org/copyright">OpenStreetMap</a> contributors',
107
- // noWrap: true
108
- });
114
+ // leaflet-rotate
115
+ const esri = window.esri = new L.TileLayer(tiles, {
116
+ maxZoom: 24,
117
+ maxNativeZoom: 18,
118
+ attribution: 'Tiles &copy; Esri &mdash; Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community',
119
+ });
120
+ const osm = window.osm = new L.TileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
121
+ maxZoom: 24,
122
+ maxNativeZoom: 19,
123
+ attribution: '&copy; <a href="http://openstreetmap.org/copyright">OpenStreetMap</a> contributors',
124
+ });
125
+ const lf = window.lf = window.map = new L.Map('lf', {
126
+ center: [55, 10],
127
+ zoom: 2,
128
+ maxZoom: 20,
129
+ layers: [esri],
130
+ rotate: true,
131
+ bearing: 30,
132
+ rotateControl: { closeOnZeroBearing: false },
133
+ compassBearing: !compare,
134
+ touchRotate: true,
135
+ touchPitch: true,
136
+ zoomSnap: $('snap').checked ? 0 : 1,
137
+ });
138
+ window.layers = new L.Control.Layers({ 'Empty': new L.TileLayer(''), 'Streets': osm, 'Satellite': esri }, null, { collapsed: false }).addTo(lf);
139
+ window.markers = Object.keys(places).map((name, i) => new L.Marker(places[name], { draggable: true })
140
+ .bindPopup(`<b>${name}</b><br>${loremIpsum}`)
141
+ .bindTooltip(`<b>${name}</b>`, i ? {} : { direction: 'right', permanent: true })
142
+ .addTo(lf));
143
+ window.path = new L.Polyline(route, { color: 'yellow', renderer: new L.Canvas() }).addTo(lf);
144
+ window.circle = new L.Circle([53, 4], 111111).addTo(lf);
145
+ window.polygon = new L.Polygon(polygonLatLngs).addTo(lf);
146
+ 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
+ if ($('debug').checked) {
158
+ L.Rotate.debug(lf, { container: $('panels') });
159
+ }
109
160
 
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',
161
+ // MapLibre (512px zoom levels => zoom - 1, bearing sign is inverted)
162
+ const ml = window.ml = compare ? new maplibregl.Map({
163
+ container: 'ml', center: [5, 50], zoom: 3, maxPitch: 85, maxZoom: 19,
164
+ style: {
165
+ version: 8,
166
+ sources: {
167
+ 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]) } },
121
170
  },
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;
171
+ layers: [
172
+ { 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 } },
176
+ ],
177
+ },
178
+ }) : null;
179
+ if (ml) {
180
+ 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);
182
+ }
135
183
 
136
- // map.setBearing(90);
137
- // map.touchRotate.enable();
184
+ // unified view access: { lat, lng, zoom (leaflet scale), bearing (leaflet sign), pitch }
185
+ const maps = {
186
+ lf: {
187
+ el: lf.getContainer(),
188
+ get: () => { const c = lf.getCenter(); return { lat: c.lat, lng: c.lng, zoom: lf.getZoom(), bearing: lf.getBearing(), pitch: lf.getPitch() }; },
189
+ set: v => { lf.setView([v.lat, v.lng], v.zoom, { animate: false }); lf.setPitch(v.pitch); lf.setBearing(v.bearing); },
190
+ toLatLng: p => { const ll = lf.containerPointToLatLng(p); return [ll.lat, ll.lng]; },
191
+ toPoint: ([lat, lng]) => { const p = lf.latLngToContainerPoint([lat, lng]); return [p.x, p.y]; },
192
+ },
193
+ };
194
+ if (ml) maps.ml = {
195
+ el: ml.getContainer(),
196
+ get: () => { const c = ml.getCenter(); return { lat: c.lat, lng: c.lng, zoom: ml.getZoom() + 1, bearing: (360 - ml.getBearing()) % 360, pitch: ml.getPitch() }; },
197
+ set: v => ml.jumpTo({ center: [v.lng, v.lat], zoom: v.zoom - 1, bearing: -v.bearing, pitch: v.pitch }),
198
+ toLatLng: p => { const ll = ml.unproject(p); return [ll.lat, ll.lng]; },
199
+ toPoint: ([lat, lng]) => { const p = ml.project([lng, lat]); return [p.x, p.y]; },
200
+ };
138
201
 
139
- // map.touchZoom.disable()
140
- // map.compassBearing.disable()
141
- // map.touchGestures.enable()
202
+ // view sync: the last hovered map drives the other one
203
+ let source = 'lf', syncing = false, running = false;
204
+ 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)}`;
205
+ const onView = key => () => {
206
+ $(`${key}-info`).textContent = fmt(maps[key].get());
207
+ if (!ml || key !== source || syncing || running || !$('sync').checked) return;
208
+ syncing = true;
209
+ const other = key === 'lf' ? 'ml' : 'lf';
210
+ maps[other].set(maps[key].get());
211
+ $(`${other}-info`).textContent = fmt(maps[other].get());
212
+ syncing = false;
213
+ };
214
+ lf.on('move zoom rotate pitch', onView('lf'));
215
+ onView('lf')();
216
+ if (ml) ml.on('move', onView('ml'));
217
+ for (const key in maps) maps[key].el.addEventListener('pointerenter', () => source = key);
142
218
 
143
- // map.zoomControl.setPosition('bottomleft');
144
- // map.rotateControl.setPosition('bottomleft');
145
- // map.setMaxBounds([[-90,-180], [90,180]]);
219
+ // synthetic input driver, coordinates are fractions of the map container
220
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
221
+ const frame = () => new Promise(resolve => requestAnimationFrame(resolve));
222
+ const driver = key => {
223
+ const el = maps[key].el;
224
+ let pressed = 0, button = 0;
225
+ const at = (fx, fy) => {
226
+ const r = el.getBoundingClientRect();
227
+ return { clientX: r.left + r.width * fx, clientY: r.top + r.height * fy };
228
+ };
229
+ const target = ({ clientX, clientY }) => {
230
+ const hit = document.elementFromPoint(clientX, clientY);
231
+ return hit && el.contains(hit) ? hit : document.body;
232
+ };
233
+ const fire = (type, pos, mods = {}) => {
234
+ const init = { bubbles: true, cancelable: true, composed: true, view: window, ...pos, button, buttons: pressed, ...mods };
235
+ const t = target(pos);
236
+ t.dispatchEvent(new PointerEvent(`pointer${type}`, { ...init, pointerId: 1, pointerType: 'mouse', isPrimary: true }));
237
+ t.dispatchEvent(new MouseEvent(`mouse${type}`, init));
238
+ };
239
+ let last = at(0.5, 0.5);
240
+ return {
241
+ async down(fx, fy, mods = {}) {
242
+ button = mods.button || 0;
243
+ pressed = button === 2 ? 2 : 1;
244
+ fire('down', last = at(fx, fy), mods);
245
+ await frame();
246
+ },
247
+ async move(fx, fy, mods = {}, steps = 10) {
248
+ const from = last, to = at(fx, fy);
249
+ for (let i = 1; i <= steps; i++) {
250
+ last = { clientX: from.clientX + (to.clientX - from.clientX) * i / steps, clientY: from.clientY + (to.clientY - from.clientY) * i / steps };
251
+ fire('move', last, mods);
252
+ await frame();
253
+ }
254
+ },
255
+ async up(mods = {}) {
256
+ pressed = 0;
257
+ fire('up', last, mods);
258
+ if (button === 2) target(last).dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, ...last, button: 2, ...mods }));
259
+ await frame();
260
+ },
261
+ async wheel(fx, fy, deltaY, mods = {}, count = 1, interval = 16) {
262
+ last = at(fx, fy);
263
+ for (let i = 0; i < count; i++) {
264
+ target(last).dispatchEvent(new WheelEvent('wheel', { bubbles: true, cancelable: true, ...last, deltaY, deltaMode: 0, ...mods }));
265
+ await sleep(interval);
266
+ }
267
+ },
268
+ wait: sleep,
269
+ };
270
+ };
146
271
 
147
- const layers = new L.Control.Layers({
148
- 'Empty': new L.TileLayer(''),
149
- 'Streets': osm,
150
- 'Satellite': esri,
151
- }, null, {
152
- collapsed: false
153
- }).addTo(map);
154
- window.layers = layers;
272
+ // scenarios: view = initial view (leaflet units), anchor = [from, to] screen fractions that should keep the same latlng
273
+ const ctrl = { ctrlKey: true }, right = { button: 2 };
274
+ const ctrlDrag = (from, to, steps, mods = ctrl) => async d => { await d.down(...from, mods); await d.move(...to, mods, steps); await d.up(mods); };
275
+ const flat = { lat: 50, lng: 5, zoom: 5, bearing: 0, pitch: 0 };
276
+ const scenarios = [
277
+ { name: 'Ctrl+drag up: pitch from center', run: ctrlDrag([0.5, 0.5], [0.5, 0.3]) },
278
+ { name: 'Ctrl+drag down: pitch clamps at 0', view: { pitch: 20 }, run: ctrlDrag([0.5, 0.4], [0.5, 0.9]) },
279
+ { name: 'Ctrl+drag far up: pitch clamps at max', run: ctrlDrag([0.5, 0.95], [0.5, 0.0], 30) },
280
+ { name: 'Ctrl+drag right, just above center', run: ctrlDrag([0.3, 0.45], [0.7, 0.45]) },
281
+ { name: 'Ctrl+drag right, just below center', run: ctrlDrag([0.3, 0.55], [0.7, 0.55]) },
282
+ { name: 'Ctrl+drag right, far above center (angular)', run: ctrlDrag([0.2, 0.05], [0.8, 0.05]) },
283
+ { name: 'Ctrl+drag right, far below center (angular)', run: ctrlDrag([0.2, 0.95], [0.8, 0.95]) },
284
+ { name: 'Ctrl+drag diagonal, pitched + rotated', view: { bearing: 40, pitch: 30 }, run: ctrlDrag([0.3, 0.7], [0.7, 0.3]) },
285
+ { name: 'Ctrl+drag fast (2 big steps)', run: ctrlDrag([0.2, 0.8], [0.8, 0.2], 2) },
286
+ {
287
+ name: 'Ctrl+drag full circle around center', run: async d => {
288
+ await d.down(0.5, 0.1, ctrl);
289
+ 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);
290
+ await d.up(ctrl);
291
+ },
292
+ },
293
+ { name: 'Ctrl+drag leaving the map', run: ctrlDrag([0.5, 0.5], [1.3, 0.2], 20) },
294
+ { 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]) },
295
+ { 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(); } },
296
+ { 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); } },
297
+ { name: 'Right-drag up + right', run: ctrlDrag([0.4, 0.6], [0.6, 0.4], 10, right) },
298
+ { 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(); } },
299
+ { 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); } },
300
+ { 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); } },
301
+ { 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); } },
302
+ { 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); } },
303
+ { 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); } },
304
+ { 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); } },
305
+ { 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); } },
306
+ { 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); } },
307
+ { name: 'Shift+wheel rotate', run: async d => { await d.wheel(0.5, 0.5, 100, { shiftKey: true }, 6, 30); } },
308
+ { 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
+ { 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
+ { 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(); } },
312
+ ];
313
+ for (const [i, s] of scenarios.entries()) $('scenario').add(new Option(s.name, i));
155
314
 
156
- const markers = [];
157
- for (const i in places) {
158
- markers.push(new L.Marker(places[i], {
159
- draggable: true
160
- })
161
- .bindPopup('<b>' + i + '</b><br>' + loremIpsum)
162
- .bindTooltip('<b>' + i + '</b>',
163
- markers.length ? {} : {
164
- direction: 'right',
165
- permanent: true
166
- }
167
- )
168
- .addTo(map));
315
+ const runOn = async (key, scenario) => {
316
+ const m = maps[key];
317
+ m.set({ ...flat, ...scenario.view });
318
+ await sleep(300);
319
+ const anchor = scenario.anchor && (() => {
320
+ const r = m.el.getBoundingClientRect();
321
+ return scenario.anchor.map(([fx, fy]) => [r.width * fx, r.height * fy]);
322
+ })();
323
+ const latlng = anchor && m.toLatLng(anchor[0]);
324
+ await scenario.run(driver(key));
325
+ await sleep(900);
326
+ 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 };
328
+ };
329
+ const run = async scenario => {
330
+ running = true;
331
+ $('run').disabled = $('run-all').disabled = true;
332
+ $('results-panel').classList.remove('hidden');
333
+ const row = $('results').tBodies[0].insertRow();
334
+ row.className = 'running';
335
+ row.insertCell().textContent = scenario.name;
336
+ // sequential: both libraries listen on document/window while dragging
337
+ const results = [];
338
+ for (const key in maps) results.push(await runOn(key, scenario));
339
+ for (const r of results) {
340
+ row.insertCell().textContent = `${r.end.zoom.toFixed(2)} / ${r.end.bearing.toFixed(1)} / ${r.end.pitch.toFixed(1)}`;
341
+ const cell = row.insertCell();
342
+ cell.textContent = r.drift == null ? '' : `${r.drift.toFixed(1)}px`;
343
+ cell.classList.toggle('bad', r.drift > 3);
169
344
  }
170
- window.markers = markers;
171
-
172
- const path = new L.Polyline(route, {
173
- renderer: new L.Canvas()
174
- }).addTo(map);
175
- window.path = path;
176
-
177
- const circle = new L.Circle([53, 4], 111111)
178
- .addTo(map);
179
- window.circle = circle;
180
-
181
- const polygon = new L.Polygon([
182
- [48, -3],
183
- [50, -4],
184
- [52, 4]
185
- ]).addTo(map);
186
- window.polygon = polygon;
187
-
188
- const rectangle = new L.Rectangle([
189
- [60.81123, 25.71632],
190
- [60.81028, 25.73166],
191
- [60.7997, 25.72892],
192
- [60.80061, 25.71362]
193
- ]).addTo(map);
194
- window.rectangle = rectangle;
195
-
196
- // Display some debug info
197
- L.Rotate.debug(map);
198
- </script>
199
-
200
- <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"
201
- width="163"> </a>
345
+ row.className = '';
346
+ running = false;
347
+ $('run').disabled = $('run-all').disabled = false;
348
+ return results;
349
+ };
350
+ window.scenarios = scenarios;
351
+ window.runScenario = run;
352
+ $('run').onclick = () => run(scenarios[$('scenario').value]);
353
+ $('run-all').onclick = async () => { for (const s of scenarios) await run(s); };
354
+ $('clear').onclick = e => { e.preventDefault(); $('results').tBodies[0].replaceChildren(); $('results-panel').classList.add('hidden'); };
355
+ </script>
202
356
 
203
- </body>
357
+ <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>
358
+ </body>
204
359
 
205
- </html>
360
+ </html>