leaflet-rotate 0.5.1 → 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 +2 -2
- package/dist/leaflet-rotate-src.js +105 -5
- package/dist/leaflet-rotate-src.js.map +2 -2
- package/dist/leaflet-rotate.js +1 -1
- package/dist/leaflet-rotate.js.map +3 -3
- package/package.json +1 -1
- package/src/map/Map.js +122 -5
- package/test/features/rotation.feature +5 -0
- package/test/index.html +242 -23
- package/test/steps/{bearing.steps.js → rotation.steps.js} +119 -15
package/package.json
CHANGED
package/src/map/Map.js
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Extends Leaflet maps with bearing rotation and 3D pitch.
|
|
3
3
|
*
|
|
4
|
+
* Coordinate spaces used by the overrides below:
|
|
5
|
+
* - projected pixels: CRS coordinates at a given zoom;
|
|
6
|
+
* - layer points: projected pixels minus `_pixelOrigin`, on the flat ground plane;
|
|
7
|
+
* - map-pane points: layer points after bearing/pitch, before the outer pane translation;
|
|
8
|
+
* - container points: map-pane points plus `_getMapPanePos()`, in viewport pixels.
|
|
9
|
+
*
|
|
10
|
+
* Tiles and vectors share `rotatePane`; upright markers and popups use `norotatePane`
|
|
11
|
+
* and the same point conversions. Public angles are degrees; `_bearing` and `_pitch`
|
|
12
|
+
* are radians. With pitch enabled, CSS and coordinate conversions must use the same
|
|
13
|
+
* projective transform, including while the outer map pane is being dragged.
|
|
14
|
+
*
|
|
4
15
|
* @external L.Map
|
|
5
16
|
*
|
|
6
17
|
* @since leaflet-rotate (v0.1.0)
|
|
@@ -95,6 +106,9 @@ L.Map.include({
|
|
|
95
106
|
mapProto.initialize.apply(this, arguments);
|
|
96
107
|
if (this.options.rotate) {
|
|
97
108
|
this.on('resize', this._updatePitchTransform, this);
|
|
109
|
+
this.on('movestart dragstart', this._startPitchPan, this);
|
|
110
|
+
this.on('move', this._updatePitchPan, this);
|
|
111
|
+
this.on('moveend', this._endPitchPan, this);
|
|
98
112
|
this.on('move', this._updatePitchSky, this);
|
|
99
113
|
this.setBearing(this.options.bearing);
|
|
100
114
|
this.setPitch(this.options.pitch);
|
|
@@ -242,7 +256,8 @@ L.Map.include({
|
|
|
242
256
|
/**
|
|
243
257
|
* Return the geographic bounds of the current viewport, accounting for rotation and pitch.
|
|
244
258
|
*
|
|
245
|
-
* The result
|
|
259
|
+
* The result encloses the four corners of the visible ground rectangle. With pitch,
|
|
260
|
+
* the rectangle stops at the configured far plane, excluding the sky.
|
|
246
261
|
*
|
|
247
262
|
* @returns {L.LatLngBounds} Geographic bounds visible in the map viewport.
|
|
248
263
|
*
|
|
@@ -507,10 +522,12 @@ L.Map.include({
|
|
|
507
522
|
},
|
|
508
523
|
|
|
509
524
|
/**
|
|
510
|
-
* Transform a point with a
|
|
525
|
+
* Transform a ground-plane point with a DOMMatrix, or reverse its screen projection.
|
|
526
|
+
* Both branches divide by the homogeneous coordinate after applying the matrix.
|
|
511
527
|
*
|
|
512
528
|
* @param {L.Point|number[]} point Input pixel coordinate.
|
|
513
|
-
* @param {DOMMatrix|Object} matrix Forward or inverse
|
|
529
|
+
* @param {DOMMatrix|Object} matrix Forward CSS matrix or inverse homography with
|
|
530
|
+
* row-major coefficients `a` through `i`.
|
|
514
531
|
*
|
|
515
532
|
* @returns {L.Point} Transformed pixel coordinate.
|
|
516
533
|
*
|
|
@@ -543,6 +560,21 @@ L.Map.include({
|
|
|
543
560
|
position = this._rotatePane._leaflet_pos || new L.Point(0, 0),
|
|
544
561
|
transform = `translate3d(${pivot.x}px,${pivot.y}px,0) perspective(${perspective}px) rotateX(${this.getPitch()}deg) rotateZ(${this.getBearing()}deg) translate3d(${position.x - pivot.x}px,${position.y - pivot.y}px,0)`;
|
|
545
562
|
|
|
563
|
+
this._setPitchTransform(transform);
|
|
564
|
+
},
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Apply the CSS transform and cache matching forward/inverse point conversions.
|
|
568
|
+
*
|
|
569
|
+
* Ground points have z = 0, so the 4x4 CSS matrix reduces to the homography
|
|
570
|
+
* [a b c; d e f; g h i]. Its inverse maps screen points back onto that plane;
|
|
571
|
+
* inverting the full 4x4 matrix at screen z = 0 would not undo perspective.
|
|
572
|
+
*
|
|
573
|
+
* @param {string} transform CSS transform list, including a serialized DOMMatrix.
|
|
574
|
+
*
|
|
575
|
+
* @since leaflet-rotate (0.5.2)
|
|
576
|
+
*/
|
|
577
|
+
_setPitchTransform(transform) {
|
|
546
578
|
this._rotatePane.style.transformOrigin = '0 0';
|
|
547
579
|
this._rotatePane.style.transform = transform;
|
|
548
580
|
// parse the transform directly: getComputedStyle() would force a style recalculation on every frame
|
|
@@ -566,6 +598,76 @@ L.Map.include({
|
|
|
566
598
|
this._updatePitchSky();
|
|
567
599
|
},
|
|
568
600
|
|
|
601
|
+
/**
|
|
602
|
+
* Capture a fixed projection baseline for a pitched drag or programmatic pan.
|
|
603
|
+
*
|
|
604
|
+
* `movestart` uses the viewport center; `dragstart` replaces it with the original
|
|
605
|
+
* pointer position. Store both the screen anchor and its ground-plane coordinate
|
|
606
|
+
* so subsequent frames can keep the grabbed geographic point under the pointer.
|
|
607
|
+
* Pane positions are in screen pixels and the baseline matrices stay unchanged.
|
|
608
|
+
*
|
|
609
|
+
* @param {L.Event} e Leaflet `movestart` or `dragstart` event.
|
|
610
|
+
*
|
|
611
|
+
* @since leaflet-rotate (0.5.2)
|
|
612
|
+
*/
|
|
613
|
+
_startPitchPan(e) {
|
|
614
|
+
if (!this._pitch || !this._loaded) { return; }
|
|
615
|
+
const draggable = this.dragging && this.dragging._draggable,
|
|
616
|
+
anchor = e.type === 'dragstart' && draggable
|
|
617
|
+
? (this.pointerEventToContainerPoint || this.mouseEventToContainerPoint).call(this, {
|
|
618
|
+
clientX: draggable._startPoint.x, clientY: draggable._startPoint.y,
|
|
619
|
+
})
|
|
620
|
+
: this.getSize().divideBy(2),
|
|
621
|
+
panePos = this._getMapPanePos().clone();
|
|
622
|
+
this._pitchPan = {
|
|
623
|
+
anchor,
|
|
624
|
+
panePos,
|
|
625
|
+
lastPanePos: panePos,
|
|
626
|
+
matrix: this._pitchTransform,
|
|
627
|
+
inverse: this._pitchInverseTransform,
|
|
628
|
+
layerAnchor: this.containerPointToLayerPoint(anchor),
|
|
629
|
+
zoom: this._zoom,
|
|
630
|
+
};
|
|
631
|
+
},
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Translate the ground plane without moving the camera's vanishing point.
|
|
635
|
+
*
|
|
636
|
+
* For pane displacement delta, invert the displaced anchor with the baseline
|
|
637
|
+
* projection to find the required layer-space shift. Compose
|
|
638
|
+
* T(-delta) * baseline * T(shift): the left translation cancels Leaflet's outer
|
|
639
|
+
* pane movement, while the right translation moves geography before perspective.
|
|
640
|
+
* Always derive this from the baseline, not the previous frame, to avoid drift.
|
|
641
|
+
* Zoom frames are handled by Leaflet's zoom/origin updates instead.
|
|
642
|
+
*
|
|
643
|
+
* @since leaflet-rotate (0.5.2)
|
|
644
|
+
*/
|
|
645
|
+
_updatePitchPan() {
|
|
646
|
+
const pan = this._pitchPan;
|
|
647
|
+
if (!this._pitch || !pan || this._animatingZoom || this._zoom !== pan.zoom) { return; }
|
|
648
|
+
const panePos = this._getMapPanePos();
|
|
649
|
+
if (panePos.equals(pan.lastPanePos)) { return; }
|
|
650
|
+
const delta = panePos.subtract(pan.panePos),
|
|
651
|
+
target = this._transformPitchPoint(pan.anchor.add(delta).subtract(pan.panePos), pan.inverse),
|
|
652
|
+
shift = target.subtract(pan.layerAnchor);
|
|
653
|
+
const matrix = new DOMMatrix().translate(-delta.x, -delta.y)
|
|
654
|
+
.multiply(pan.matrix).translate(shift.x, shift.y);
|
|
655
|
+
this._setPitchTransform(matrix.toString());
|
|
656
|
+
pan.lastPanePos = panePos.clone();
|
|
657
|
+
delete this._lastCenter;
|
|
658
|
+
},
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Discard the baseline only at `moveend`, after any drag inertia has completed.
|
|
662
|
+
* Keep the final transform and pixel origin: resetting the view here would
|
|
663
|
+
* reproject layers abruptly and cause a visible jump on release.
|
|
664
|
+
*
|
|
665
|
+
* @since leaflet-rotate (0.5.2)
|
|
666
|
+
*/
|
|
667
|
+
_endPitchPan() {
|
|
668
|
+
delete this._pitchPan;
|
|
669
|
+
},
|
|
670
|
+
|
|
569
671
|
/**
|
|
570
672
|
* Fill the area above the far plane (where no tiles are loaded) with a sky gradient.
|
|
571
673
|
* Style it through the `.leaflet-pitch-sky` class.
|
|
@@ -966,7 +1068,10 @@ L.Map.include({
|
|
|
966
1068
|
// },
|
|
967
1069
|
|
|
968
1070
|
/**
|
|
969
|
-
* Return the
|
|
1071
|
+
* Return the screen-space pan offset needed to center the given geographic point.
|
|
1072
|
+
* Under perspective, projecting a negated layer offset is not equivalent to
|
|
1073
|
+
* negating its screen projection. Reflect the target about the layer center
|
|
1074
|
+
* to match the opposite anchor displacement performed by Leaflet's `panBy`.
|
|
970
1075
|
*
|
|
971
1076
|
* @param {L.LatLng} latlng Geographic point.
|
|
972
1077
|
*
|
|
@@ -978,6 +1083,12 @@ L.Map.include({
|
|
|
978
1083
|
if (!this._rotate) {
|
|
979
1084
|
return mapProto._getCenterOffset.apply(this, arguments);
|
|
980
1085
|
}
|
|
1086
|
+
if (this._pitch) {
|
|
1087
|
+
const center = this.getSize().divideBy(2),
|
|
1088
|
+
layerCenter = this.containerPointToLayerPoint(center),
|
|
1089
|
+
opposite = layerCenter.multiplyBy(2).subtract(this.project(latlng).subtract(this._pixelOrigin));
|
|
1090
|
+
return center.subtract(this.layerPointToContainerPoint(opposite));
|
|
1091
|
+
}
|
|
981
1092
|
return this.latLngToContainerPoint(latlng)._subtract(this.getSize()._divideBy(2));
|
|
982
1093
|
},
|
|
983
1094
|
|
|
@@ -1056,9 +1167,12 @@ L.Map.include({
|
|
|
1056
1167
|
},
|
|
1057
1168
|
|
|
1058
1169
|
/**
|
|
1170
|
+
* Return the viewport center before the outer map-pane translation.
|
|
1171
|
+
* Used as the bearing/pitch pivot in map-pane coordinates, not layer coordinates.
|
|
1172
|
+
*
|
|
1059
1173
|
* @since leaflet-rotate (v0.2.0)
|
|
1060
1174
|
*
|
|
1061
|
-
* @returns {L.Point} Map pivot
|
|
1175
|
+
* @returns {L.Point} Map pivot in map-pane pixels.
|
|
1062
1176
|
*/
|
|
1063
1177
|
_getPixelCenter() {
|
|
1064
1178
|
if (!this._rotate && mapProto._getPixelCenter) {
|
|
@@ -1068,6 +1182,9 @@ L.Map.include({
|
|
|
1068
1182
|
},
|
|
1069
1183
|
|
|
1070
1184
|
/**
|
|
1185
|
+
* Convert the padded visible ground rectangle to layer bounds for vector renderers.
|
|
1186
|
+
* Clip its far edge before reversing perspective to avoid unbounded render areas.
|
|
1187
|
+
*
|
|
1071
1188
|
* @see src\layer\vector\Renderer::_update()
|
|
1072
1189
|
*
|
|
1073
1190
|
* @param {number} padding Bounds padding as a fraction of map size.
|
|
@@ -36,6 +36,11 @@ Feature: Rotating a Leaflet map
|
|
|
36
36
|
When I drag the pitched map
|
|
37
37
|
Then vector layers stay aligned with their map coordinates
|
|
38
38
|
|
|
39
|
+
@issue-74
|
|
40
|
+
Scenario: Pitched panning does not jump or reset the view on release
|
|
41
|
+
When I pan a highly pitched map in several directions
|
|
42
|
+
Then releasing the map does not jump or reset its layers
|
|
43
|
+
|
|
39
44
|
@issue-72
|
|
40
45
|
Scenario: Vector layers stay aligned after off-center zoom and rotation
|
|
41
46
|
When I zoom off-center then rotate the map
|
package/test/index.html
CHANGED
|
@@ -47,10 +47,6 @@
|
|
|
47
47
|
<label title="Show the leaflet-rotate debug overlays and info panel"><input type="checkbox" id="debug"> debug</label>
|
|
48
48
|
<label title="MapLibre-like continuous zoom (needed for an exact view sync)"><input type="checkbox" id="snap"> zoomSnap: 0</label>
|
|
49
49
|
</fieldset>
|
|
50
|
-
<fieldset>
|
|
51
|
-
<legend>Go to</legend>
|
|
52
|
-
<select id="goto" title="Fly to a layer or place"><option value="">—</option></select>
|
|
53
|
-
</fieldset>
|
|
54
50
|
<fieldset>
|
|
55
51
|
<legend>Scenarios</legend>
|
|
56
52
|
<select id="scenario"></select>
|
|
@@ -67,19 +63,29 @@
|
|
|
67
63
|
<details id="results-panel" class="hidden" open>
|
|
68
64
|
<summary>Scenario results <button id="clear">Clear</button></summary>
|
|
69
65
|
<table id="results">
|
|
70
|
-
<thead><tr><th>Scenario</th><th>leaflet-rotate z / bearing / pitch</th><th>drift</th><th class="ml-only">MapLibre z / bearing / pitch</th><th class="ml-only">drift</th></tr></thead>
|
|
66
|
+
<thead><tr><th>Scenario</th><th>leaflet-rotate z / bearing / pitch</th><th>drift</th><th class="ml-only">MapLibre z / bearing / pitch</th><th class="ml-only">drift</th><th class="ml-only">view Δ</th><th class="ml-only" title="Largest change in the relative positions of six geographic probes, compared with the other renderer.">max spatial Δ</th></tr></thead>
|
|
71
67
|
<tbody></tbody>
|
|
72
68
|
</table>
|
|
73
69
|
</details>
|
|
74
70
|
</section>
|
|
75
71
|
|
|
76
72
|
<script type="module">
|
|
73
|
+
/**
|
|
74
|
+
* Interactive regression harness, optionally comparing Leaflet with MapLibre.
|
|
75
|
+
* Setup is persisted through query parameters: `leaflet=2`, `maplibre=0`,
|
|
76
|
+
* `debug=0`, and `snap=0` (continuous Leaflet zoom). Defaults enable comparison
|
|
77
|
+
* and debugging with Leaflet 1 and integer zoom snapping.
|
|
78
|
+
*
|
|
79
|
+
* Manual view sync is suspended during scenarios: each renderer receives the
|
|
80
|
+
* same starting view and input independently, then its results are measured.
|
|
81
|
+
*/
|
|
77
82
|
const $ = id => document.getElementById(id);
|
|
78
83
|
const params = new URLSearchParams(location.search);
|
|
79
84
|
$('leaflet-version').value = params.get('leaflet') === '2' ? '2' : '1';
|
|
80
85
|
$('maplibre').checked = params.get('maplibre') !== '0';
|
|
81
86
|
$('debug').checked = params.get('debug') !== '0';
|
|
82
87
|
$('snap').checked = params.get('snap') === '0';
|
|
88
|
+
/** Reload the harness with the selected library/debug/zoom configuration. */
|
|
83
89
|
const reload = () => {
|
|
84
90
|
const query = new URLSearchParams({ leaflet: $('leaflet-version').value });
|
|
85
91
|
if (!$('maplibre').checked) query.set('maplibre', '0');
|
|
@@ -91,6 +97,7 @@
|
|
|
91
97
|
const compare = $('maplibre').checked;
|
|
92
98
|
for (const el of document.querySelectorAll('.ml-only')) el.classList.toggle('hidden', !compare);
|
|
93
99
|
|
|
100
|
+
/** Load scripts in dependency order; reject explicitly if a CDN or local file fails. */
|
|
94
101
|
const loadScript = src => new Promise((resolve, reject) => {
|
|
95
102
|
const script = Object.assign(document.createElement('script'), { src, onload: resolve, onerror: () => reject(new Error(`Could not load ${src}`)) });
|
|
96
103
|
document.head.appendChild(script);
|
|
@@ -144,16 +151,6 @@
|
|
|
144
151
|
window.circle = new L.Circle([53, 4], 111111).addTo(lf);
|
|
145
152
|
window.polygon = new L.Polygon(polygonLatLngs).addTo(lf);
|
|
146
153
|
window.rectangle = new L.Rectangle([[60.81123, 25.71632], [60.81028, 25.73166], [60.7997, 25.72892], [60.80061, 25.71362]]).addTo(lf);
|
|
147
|
-
const goto = {
|
|
148
|
-
'Circle': () => lf.flyToBounds(circle.getBounds()),
|
|
149
|
-
'Polygon': () => lf.flyToBounds(polygon.getBounds()),
|
|
150
|
-
'Path': () => lf.flyToBounds(path.getBounds()),
|
|
151
|
-
...Object.fromEntries(Object.keys(places).map(name => [name, () => lf.flyTo(places[name], 10)])),
|
|
152
|
-
// DEBUG: map.setZoom(0) && map.getBoundsZoom(rectangle.getBounds())
|
|
153
|
-
'Rectangle (fit from z 0)': () => { lf.setZoom(0, { animate: false }); setTimeout(() => lf.fitBounds(rectangle.getBounds(), { animate: false }), 1000); },
|
|
154
|
-
};
|
|
155
|
-
for (const name in goto) $('goto').add(new Option(name, name));
|
|
156
|
-
$('goto').onchange = e => { goto[e.target.value](); e.target.value = ''; };
|
|
157
154
|
if ($('debug').checked) {
|
|
158
155
|
L.Rotate.debug(lf, { container: $('panels') });
|
|
159
156
|
}
|
|
@@ -181,7 +178,20 @@
|
|
|
181
178
|
for (const name in places) new maplibregl.Marker().setLngLat([places[name][1], places[name][0]]).setPopup(new maplibregl.Popup().setText(name)).addTo(ml);
|
|
182
179
|
}
|
|
183
180
|
|
|
184
|
-
|
|
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
|
+
*/
|
|
185
195
|
const maps = {
|
|
186
196
|
lf: {
|
|
187
197
|
el: lf.getContainer(),
|
|
@@ -202,6 +212,7 @@
|
|
|
202
212
|
// view sync: the last hovered map drives the other one
|
|
203
213
|
let source = 'lf', syncing = false, running = false;
|
|
204
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. */
|
|
205
216
|
const onView = key => () => {
|
|
206
217
|
$(`${key}-info`).textContent = fmt(maps[key].get());
|
|
207
218
|
if (!ml || key !== source || syncing || running || !$('sync').checked) return;
|
|
@@ -216,16 +227,27 @@
|
|
|
216
227
|
if (ml) ml.on('move', onView('ml'));
|
|
217
228
|
for (const key in maps) maps[key].el.addEventListener('pointerenter', () => source = key);
|
|
218
229
|
|
|
219
|
-
// synthetic input driver, coordinates are fractions of the map container
|
|
220
230
|
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
221
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
|
+
*/
|
|
222
242
|
const driver = key => {
|
|
223
243
|
const el = maps[key].el;
|
|
224
244
|
let pressed = 0, button = 0;
|
|
245
|
+
/** Convert fractional container coordinates to viewport client pixels. */
|
|
225
246
|
const at = (fx, fy) => {
|
|
226
247
|
const r = el.getBoundingClientRect();
|
|
227
248
|
return { clientX: r.left + r.width * fx, clientY: r.top + r.height * fy };
|
|
228
249
|
};
|
|
250
|
+
/** Hit-test layers/markers; use body outside this map so document listeners still run. */
|
|
229
251
|
const target = ({ clientX, clientY }) => {
|
|
230
252
|
const hit = document.elementFromPoint(clientX, clientY);
|
|
231
253
|
return hit && el.contains(hit) ? hit : document.body;
|
|
@@ -238,12 +260,14 @@
|
|
|
238
260
|
};
|
|
239
261
|
let last = at(0.5, 0.5);
|
|
240
262
|
return {
|
|
263
|
+
/** Press the selected mouse button at a fractional container position. */
|
|
241
264
|
async down(fx, fy, mods = {}) {
|
|
242
265
|
button = mods.button || 0;
|
|
243
266
|
pressed = button === 2 ? 2 : 1;
|
|
244
267
|
fire('down', last = at(fx, fy), mods);
|
|
245
268
|
await frame();
|
|
246
269
|
},
|
|
270
|
+
/** Interpolate from the last position, yielding one animation frame per step. */
|
|
247
271
|
async move(fx, fy, mods = {}, steps = 10) {
|
|
248
272
|
const from = last, to = at(fx, fy);
|
|
249
273
|
for (let i = 1; i <= steps; i++) {
|
|
@@ -252,12 +276,14 @@
|
|
|
252
276
|
await frame();
|
|
253
277
|
}
|
|
254
278
|
},
|
|
279
|
+
/** Release at the last position, including contextmenu for right-button gestures. */
|
|
255
280
|
async up(mods = {}) {
|
|
256
281
|
pressed = 0;
|
|
257
282
|
fire('up', last, mods);
|
|
258
283
|
if (button === 2) target(last).dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, ...last, button: 2, ...mods }));
|
|
259
284
|
await frame();
|
|
260
285
|
},
|
|
286
|
+
/** Send a wheel burst with pixel deltas and a millisecond interval between events. */
|
|
261
287
|
async wheel(fx, fy, deltaY, mods = {}, count = 1, interval = 16) {
|
|
262
288
|
last = at(fx, fy);
|
|
263
289
|
for (let i = 0; i < count; i++) {
|
|
@@ -269,9 +295,22 @@
|
|
|
269
295
|
};
|
|
270
296
|
};
|
|
271
297
|
|
|
272
|
-
|
|
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
|
+
*/
|
|
273
309
|
const ctrl = { ctrlKey: true }, right = { button: 2 };
|
|
310
|
+
/** Build a modified drag, using Ctrl by default or an explicit right-button modifier. */
|
|
274
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(); };
|
|
275
314
|
const flat = { lat: 50, lng: 5, zoom: 5, bearing: 0, pitch: 0 };
|
|
276
315
|
const scenarios = [
|
|
277
316
|
{ name: 'Ctrl+drag up: pitch from center', run: ctrlDrag([0.5, 0.5], [0.5, 0.3]) },
|
|
@@ -308,24 +347,170 @@
|
|
|
308
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); } },
|
|
309
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); } },
|
|
310
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); } },
|
|
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(); } },
|
|
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]) },
|
|
312
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
|
+
})));
|
|
313
367
|
for (const [i, s] of scenarios.entries()) $('scenario').add(new Option(s.name, i));
|
|
314
368
|
|
|
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
|
+
}
|
|
415
|
+
} else {
|
|
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
|
+
}
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
|
|
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
|
+
*/
|
|
315
441
|
const runOn = async (key, scenario) => {
|
|
316
442
|
const m = maps[key];
|
|
317
|
-
|
|
443
|
+
const initialView = { ...flat, ...scenario.view },
|
|
444
|
+
other = maps[key === 'lf' ? 'ml' : 'lf'];
|
|
445
|
+
for (const map of Object.values(maps)) map.set(initialView);
|
|
318
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');
|
|
467
|
+
}
|
|
319
468
|
const anchor = scenario.anchor && (() => {
|
|
320
469
|
const r = m.el.getBoundingClientRect();
|
|
321
470
|
return scenario.anchor.map(([fx, fy]) => [r.width * fx, r.height * fy]);
|
|
322
471
|
})();
|
|
323
472
|
const latlng = anchor && m.toLatLng(anchor[0]);
|
|
324
|
-
|
|
325
|
-
|
|
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
|
+
}
|
|
326
483
|
const end = m.get(), p = anchor && m.toPoint(latlng);
|
|
327
|
-
|
|
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
|
+
};
|
|
328
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
|
+
*/
|
|
329
514
|
const run = async scenario => {
|
|
330
515
|
running = true;
|
|
331
516
|
$('run').disabled = $('run-all').disabled = true;
|
|
@@ -342,11 +527,45 @@
|
|
|
342
527
|
cell.textContent = r.drift == null ? '' : `${r.drift.toFixed(1)}px`;
|
|
343
528
|
cell.classList.toggle('bad', r.drift > 3);
|
|
344
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));
|
|
345
563
|
row.className = '';
|
|
346
564
|
running = false;
|
|
347
565
|
$('run').disabled = $('run-all').disabled = false;
|
|
348
566
|
return results;
|
|
349
567
|
};
|
|
568
|
+
// Expose the same cases/runner to browser automation and the interactive controls.
|
|
350
569
|
window.scenarios = scenarios;
|
|
351
570
|
window.runScenario = run;
|
|
352
571
|
$('run').onclick = () => run(scenarios[$('scenario').value]);
|