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/README.md +17 -6
- package/dist/leaflet-rotate-src.js +502 -164
- 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 +248 -68
- 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 +30 -0
- package/test/index.html +342 -187
- package/test/steps/bearing.steps.js +193 -0
- package/test/steps/example.steps.js +56 -29
- package/test/steps/gestures.steps.js +55 -0
package/test/index.html
CHANGED
|
@@ -1,205 +1,360 @@
|
|
|
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>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 · <kbd>Shift</kbd>+wheel: rotate · <kbd>Ctrl</kbd>+wheel: trackpad pinch · 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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
});
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
114
|
+
// leaflet-rotate
|
|
115
|
+
const esri = window.esri = new L.TileLayer(tiles, {
|
|
116
|
+
maxZoom: 24,
|
|
117
|
+
maxNativeZoom: 18,
|
|
118
|
+
attribution: 'Tiles © Esri — 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: '© <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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
}
|
|
154
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
</
|
|
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>
|