leaflet-rotate 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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)
@@ -47,6 +58,15 @@ L.Map.mergeOptions({
47
58
  */
48
59
  pitch: 0,
49
60
 
61
+ /**
62
+ * @type {number} Maximum distance of the pitched map plane from the map center, in multiples
63
+ * of the map height. Tiles, vectors and `getBounds()` stop there (and always
64
+ * before the horizon); a `.leaflet-pitch-sky` element fills the area above.
65
+ *
66
+ * @since leaflet-rotate (0.5.1)
67
+ */
68
+ pitchMaxDistance: 3,
69
+
50
70
  });
51
71
 
52
72
  L.Map.include({
@@ -86,6 +106,10 @@ L.Map.include({
86
106
  mapProto.initialize.apply(this, arguments);
87
107
  if (this.options.rotate) {
88
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);
112
+ this.on('move', this._updatePitchSky, this);
89
113
  this.setBearing(this.options.bearing);
90
114
  this.setPitch(this.options.pitch);
91
115
  }
@@ -232,7 +256,8 @@ L.Map.include({
232
256
  /**
233
257
  * Return the geographic bounds of the current viewport, accounting for rotation and pitch.
234
258
  *
235
- * The result is the smallest geographic bounds containing all four viewport corners.
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.
236
261
  *
237
262
  * @returns {L.LatLngBounds} Geographic bounds visible in the map viewport.
238
263
  *
@@ -253,15 +278,66 @@ L.Map.include({
253
278
 
254
279
  // LatLngBounds' constructor automatically
255
280
  // extends the bounds to fit the passed points
256
- const size = this.getSize();
281
+ const [min, max] = this._getPitchedContainerBounds(0);
257
282
  return new L.LatLngBounds([
258
- this.containerPointToLatLng([0, 0]), // topleft
259
- this.containerPointToLatLng([size.x, 0]), // topright
260
- this.containerPointToLatLng([size.x, size.y]), // bottomright
261
- this.containerPointToLatLng([0, size.y]), // bottomleft
283
+ this.containerPointToLatLng([min.x, min.y]), // topleft
284
+ this.containerPointToLatLng([max.x, min.y]), // topright
285
+ this.containerPointToLatLng([max.x, max.y]), // bottomright
286
+ this.containerPointToLatLng([min.x, max.y]), // bottomleft
262
287
  ]);
263
288
  },
264
289
 
290
+ /**
291
+ * Return the container rectangle covering the visible map plane, optionally padded by a
292
+ * fraction of the map size. When pitched, its top is cut at the far plane (see `pitchMaxDistance`)
293
+ * so that it never reaches the horizon, where the plane would extend to infinity.
294
+ *
295
+ * @param {number} padding Padding as a fraction of the map size.
296
+ *
297
+ * @returns {L.Point[]} Top-left and bottom-right container points.
298
+ *
299
+ * @since leaflet-rotate (0.5.1)
300
+ */
301
+ _getPitchedContainerBounds(padding) {
302
+ const size = this.getSize(),
303
+ min = size.multiplyBy(-padding),
304
+ max = size.multiplyBy(1 + padding);
305
+ if (this._pitch) {
306
+ min.y = Math.min(Math.max(min.y, this._getPitchFarY()), max.y);
307
+ }
308
+ return [min, max];
309
+ },
310
+
311
+ /**
312
+ * Return the container y coordinate of the far plane: points above it are farther than
313
+ * `pitchMaxDistance` from the map center, or beyond the horizon.
314
+ *
315
+ * @returns {number} Container y coordinate (`-Infinity` when the whole map is close enough).
316
+ *
317
+ * @since leaflet-rotate (0.5.1)
318
+ */
319
+ _getPitchFarY() {
320
+ const size = this.getSize(),
321
+ inverse = this._pitchInverseTransform,
322
+ panePos = this._getMapPanePos(),
323
+ x = size.x / 2 - panePos.x,
324
+ centerY = size.y / 2 - panePos.y,
325
+ maxDistance = this.options.pitchMaxDistance * size.y,
326
+ w = y => inverse.g * x + inverse.h * y + inverse.i,
327
+ centerW = w(centerY),
328
+ center = this._transformPitchPoint([x, centerY], inverse),
329
+ // the horizon is horizontal on screen (bearing is applied before the pitch)
330
+ isNear = y => w(y) * centerW > 0 && this._transformPitchPoint([x, y], inverse).distanceTo(center) <= maxDistance;
331
+
332
+ let far = -size.y - panePos.y, near = centerY;
333
+ if (isNear(far)) { return -Infinity; }
334
+ for (let i = 0; i < 20; i++) {
335
+ const mid = (far + near) / 2;
336
+ if (isNear(mid)) { near = mid; } else { far = mid; }
337
+ }
338
+ return near + panePos.y;
339
+ },
340
+
265
341
  /**
266
342
  * Returns the bounds of the current map view in projected pixel
267
343
  * coordinates (sometimes useful in layer and overlay implementations).
@@ -298,48 +374,110 @@ L.Map.include({
298
374
  if (!this._rotate) { return; }
299
375
 
300
376
  if (options && options.animate) {
301
- this._bearingAnimationId = (this._bearingAnimationId || 0) + 1;
302
- const animationId = this._bearingAnimationId;
303
- if (this._bearingAnimationFrame) {
304
- cancelAnimationFrame(this._bearingAnimationFrame);
305
- }
306
-
307
- const startBearing = this.getBearing(),
308
- endBearing = L.Util.wrapNum(theta, [0, 360]),
309
- delta = ((endBearing - startBearing + 540) % 360) - 180,
310
- duration = Math.max(0, options.duration == null ? 0.25 : options.duration) * 1000,
311
- easeLinearity = Math.max(options.easeLinearity || 0.5, 0.2),
312
- startTime = performance.now();
313
-
314
- const animate = function() {
315
- if (!this._mapPane) {
316
- delete this._bearingAnimationFrame;
317
- return;
318
- }
319
-
320
- const progress = duration ? Math.min((performance.now() - startTime) / duration, 1) : 1,
321
- eased = 1 - Math.pow(1 - progress, 1 / easeLinearity);
322
-
323
- this._setBearing(progress === 1 ? endBearing : startBearing + delta * eased);
324
- if (this._bearingAnimationId !== animationId) { return; }
325
- if (progress < 1) {
326
- this._bearingAnimationFrame = requestAnimationFrame(animate.bind(this));
327
- } else {
328
- delete this._bearingAnimationFrame;
329
- }
330
- };
331
-
332
- this._bearingAnimationFrame = requestAnimationFrame(animate.bind(this));
377
+ this._animatePitchBearing(this.getPitch(), theta, options);
333
378
  return;
334
379
  }
335
380
 
381
+ this._stopPitchBearingAnimation();
382
+ this._setBearing(theta);
383
+ },
384
+
385
+ /**
386
+ * Animate pitch and bearing together (bearing follows the shortest path).
387
+ *
388
+ * @param {number} pitch Target pitch in degrees.
389
+ * @param {number} bearing Target bearing in degrees.
390
+ * @param {Object} [options] Animation options.
391
+ * @param {number} [options.duration=0.25] Animation duration in seconds.
392
+ * @param {number} [options.easeLinearity=0.5] Easing curve linearity.
393
+ *
394
+ * @since leaflet-rotate (0.5.1)
395
+ */
396
+ _animatePitchBearing(pitch, bearing, options) {
397
+ this._stopPitchBearingAnimation();
398
+ const animationId = this._bearingAnimationId;
399
+
400
+ const startBearing = this.getBearing(),
401
+ startPitch = this.getPitch(),
402
+ endBearing = L.Util.wrapNum(bearing, [0, 360]),
403
+ endPitch = Math.max(0, Math.min(pitch, 85)),
404
+ delta = ((endBearing - startBearing + 540) % 360) - 180,
405
+ duration = Math.max(0, options.duration == null ? 0.25 : options.duration) * 1000,
406
+ easeLinearity = Math.max(options.easeLinearity || 0.5, 0.2),
407
+ startTime = performance.now();
408
+
409
+ const animate = function() {
410
+ if (!this._mapPane || this._bearingAnimationId !== animationId) {
411
+ return;
412
+ }
413
+
414
+ const progress = duration ? Math.min((performance.now() - startTime) / duration, 1) : 1,
415
+ eased = 1 - Math.pow(1 - progress, 1 / easeLinearity);
416
+
417
+ if (progress < 1) {
418
+ this._bearingAnimationFrame = requestAnimationFrame(animate.bind(this));
419
+ } else {
420
+ delete this._bearingAnimationFrame;
421
+ }
422
+ this._setPitchAndBearing(
423
+ progress === 1 ? endPitch : startPitch + (endPitch - startPitch) * eased,
424
+ progress === 1 ? endBearing : startBearing + delta * eased
425
+ );
426
+ };
427
+
428
+ this._bearingAnimationFrame = requestAnimationFrame(animate.bind(this));
429
+ },
430
+
431
+ /**
432
+ * Cancel a running pitch/bearing animation.
433
+ *
434
+ * @since leaflet-rotate (0.5.1)
435
+ */
436
+ _stopPitchBearingAnimation() {
336
437
  this._bearingAnimationId = (this._bearingAnimationId || 0) + 1;
337
438
  if (this._bearingAnimationFrame) {
338
439
  cancelAnimationFrame(this._bearingAnimationFrame);
339
440
  delete this._bearingAnimationFrame;
340
441
  }
442
+ },
341
443
 
342
- this._setBearing(theta);
444
+ /**
445
+ * Rotate the map back to north (bearing 0), animated by default.
446
+ *
447
+ * @param {Object} [options] Animation options (`animate`, `duration`, `easeLinearity`).
448
+ *
449
+ * @returns {L.Map} This map instance.
450
+ *
451
+ * @since leaflet-rotate (0.5.1)
452
+ */
453
+ resetNorth(options) {
454
+ options = Object.assign({ animate: true, duration: 0.5 }, options);
455
+ if (options.animate) {
456
+ this._animatePitchBearing(this.getPitch(), 0, options);
457
+ } else {
458
+ this.setBearing(0);
459
+ }
460
+ return this;
461
+ },
462
+
463
+ /**
464
+ * Rotate the map back to north and remove the pitch, animated by default.
465
+ *
466
+ * @param {Object} [options] Animation options (`animate`, `duration`, `easeLinearity`).
467
+ *
468
+ * @returns {L.Map} This map instance.
469
+ *
470
+ * @since leaflet-rotate (0.5.1)
471
+ */
472
+ resetNorthPitch(options) {
473
+ options = Object.assign({ animate: true, duration: 0.5 }, options);
474
+ if (options.animate) {
475
+ this._animatePitchBearing(0, 0, options);
476
+ } else {
477
+ this._stopPitchBearingAnimation();
478
+ this._setPitchAndBearing(0, 0);
479
+ }
480
+ return this;
343
481
  },
344
482
 
345
483
  /**
@@ -384,10 +522,12 @@ L.Map.include({
384
522
  },
385
523
 
386
524
  /**
387
- * Transform a point with a projective matrix, accepting DOMMatrix or its 2D inverse.
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.
388
527
  *
389
528
  * @param {L.Point|number[]} point Input pixel coordinate.
390
- * @param {DOMMatrix|Object} matrix Forward or inverse transformation matrix.
529
+ * @param {DOMMatrix|Object} matrix Forward CSS matrix or inverse homography with
530
+ * row-major coefficients `a` through `i`.
391
531
  *
392
532
  * @returns {L.Point} Transformed pixel coordinate.
393
533
  *
@@ -420,9 +560,25 @@ L.Map.include({
420
560
  position = this._rotatePane._leaflet_pos || new L.Point(0, 0),
421
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)`;
422
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) {
423
578
  this._rotatePane.style.transformOrigin = '0 0';
424
579
  this._rotatePane.style.transform = transform;
425
- const matrix = this._pitchTransform = new DOMMatrix(getComputedStyle(this._rotatePane).transform),
580
+ // parse the transform directly: getComputedStyle() would force a style recalculation on every frame
581
+ const matrix = this._pitchTransform = new DOMMatrix(transform),
426
582
  a = matrix.m11, b = matrix.m21, c = matrix.m41,
427
583
  d = matrix.m12, e = matrix.m22, f = matrix.m42,
428
584
  g = matrix.m14, h = matrix.m24, i = matrix.m44,
@@ -439,12 +595,108 @@ L.Map.include({
439
595
  h: (b * g - a * h) / determinant,
440
596
  i: (a * e - b * d) / determinant,
441
597
  };
598
+ this._updatePitchSky();
599
+ },
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
+
671
+ /**
672
+ * Fill the area above the far plane (where no tiles are loaded) with a sky gradient.
673
+ * Style it through the `.leaflet-pitch-sky` class.
674
+ *
675
+ * @since leaflet-rotate (0.5.1)
676
+ */
677
+ _updatePitchSky() {
678
+ const farY = this._pitch && this._pitchInverseTransform ? this._getPitchFarY() : -Infinity;
679
+ if (farY <= 0) {
680
+ if (this._pitchSky) { this._pitchSky.style.display = 'none'; }
681
+ return;
682
+ }
683
+ if (!this._pitchSky) {
684
+ this._pitchSky = L.DomUtil.create('div', 'leaflet-pitch-sky', this._container);
685
+ Object.assign(this._pitchSky.style, {
686
+ position: 'absolute', left: 0, top: 0, width: '100%', zIndex: 0, pointerEvents: 'none',
687
+ background: 'linear-gradient(#9ec5e8, #dfe9f0)',
688
+ });
689
+ }
690
+ this._pitchSky.style.display = '';
691
+ // overlap the far edge slightly to hide antialiasing gaps
692
+ this._pitchSky.style.height = `${Math.ceil(farY) + 1}px`;
442
693
  },
443
694
 
444
695
  /**
445
696
  * Set the map pitch in degrees. Values are clamped to the range 0–85.
446
697
  *
447
698
  * @param {number} pitch Pitch angle in degrees; clamped to [0, 85].
699
+ * @param {Object} [options] Animation options (`animate`, `duration`, `easeLinearity`).
448
700
  *
449
701
  * @returns {L.Map} This map instance.
450
702
  *
@@ -452,7 +704,12 @@ L.Map.include({
452
704
  *
453
705
  * @since leaflet-rotate (0.5.0)
454
706
  */
455
- setPitch(pitch) {
707
+ setPitch(pitch, options) {
708
+ if (options && options.animate) {
709
+ this._animatePitchBearing(pitch, this.getBearing(), options);
710
+ return this;
711
+ }
712
+ this._stopPitchBearingAnimation();
456
713
  this._setPitchAndBearing(pitch, this.getBearing());
457
714
  return this;
458
715
  },
@@ -484,6 +741,7 @@ L.Map.include({
484
741
  }
485
742
  this._setBearing(bearing, mapCenter);
486
743
  if (pitchChanged) {
744
+ if (!this._pitch) { this._updatePitchSky(); }
487
745
  this.fire('pitch');
488
746
  }
489
747
  },
@@ -499,6 +757,38 @@ L.Map.include({
499
757
  return (this._pitch || 0) * (180 / Math.PI);
500
758
  },
501
759
 
760
+ /**
761
+ * Zoom while keeping the given point fixed on screen, also when the map is pitched.
762
+ *
763
+ * Leaflet interpolates in container space, which is not linear under perspective;
764
+ * interpolating in layer space keeps the point under the cursor.
765
+ *
766
+ * @param {L.LatLng|L.Point} latlng Geographic point or container point to keep fixed.
767
+ * @param {number} zoom Target zoom level.
768
+ * @param {Object} [options] Zoom options.
769
+ *
770
+ * @returns {L.Map} This map instance.
771
+ *
772
+ * @since leaflet-rotate (0.5.1)
773
+ */
774
+ setZoomAround(latlng, zoom, options) {
775
+ if (!this._pitch) {
776
+ return mapProto.setZoomAround.apply(this, arguments);
777
+ }
778
+ const scale = this.getZoomScale(zoom),
779
+ size = this.getSize(),
780
+ center = this.containerPointToLayerPoint(size.divideBy(2)),
781
+ containerPoint = latlng instanceof L.Point ? latlng : this.latLngToContainerPoint(latlng),
782
+ point = this.containerPointToLayerPoint(containerPoint),
783
+ newCenter = this.layerPointToLatLng(center.add(point.subtract(center).multiplyBy(1 - 1 / scale)));
784
+ options = Object.assign({}, options);
785
+ // Leaflet skips the animation when the origin is "far" in layer space, which is always the case near the horizon.
786
+ if (options.animate === undefined && new L.Bounds(new L.Point(0, 0), size).contains(containerPoint)) {
787
+ options.animate = true;
788
+ }
789
+ return this.setView(newCenter, zoom, { zoom: options });
790
+ },
791
+
502
792
  /**
503
793
  * Get the current map bearing. Enable the `rotate` map option before calling.
504
794
  *
@@ -778,24 +1068,28 @@ L.Map.include({
778
1068
  // },
779
1069
 
780
1070
  /**
781
- * Return the pixel offset from the current center to the given geographic point.
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`.
782
1075
  *
783
1076
  * @param {L.LatLng} latlng Geographic point.
784
1077
  *
785
- * @returns {L.Point} Offset in layer pixels, adjusted for bearing and pitch.
1078
+ * @returns {L.Point} Offset in container pixels (the map pane is panned in screen space), adjusted for bearing and pitch.
786
1079
  *
787
1080
  * @since leaflet-rotate (v0.1.0)
788
1081
  */
789
1082
  _getCenterOffset(latlng) {
790
- let centerOffset = mapProto._getCenterOffset.apply(this, arguments);
1083
+ if (!this._rotate) {
1084
+ return mapProto._getCenterOffset.apply(this, arguments);
1085
+ }
791
1086
  if (this._pitch) {
792
- const center = this.getSize()._divideBy(2);
793
- centerOffset = this.containerPointToLayerPoint(center.add(centerOffset))
794
- .subtract(this.containerPointToLayerPoint(center));
795
- } else if (this._rotate) {
796
- centerOffset = centerOffset.rotate(this._bearing);
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));
797
1091
  }
798
- return centerOffset;
1092
+ return this.latLngToContainerPoint(latlng)._subtract(this.getSize()._divideBy(2));
799
1093
  },
800
1094
 
801
1095
  /**
@@ -826,16 +1120,13 @@ L.Map.include({
826
1120
  * @since leaflet-rotate (v0.1.0)
827
1121
  */
828
1122
  _getNewPixelOrigin(center, zoom) {
829
- if (!this._rotate || L.version.startsWith('2.')) {
1123
+ if (!this._rotate) {
830
1124
  return mapProto._getNewPixelOrigin.apply(this, arguments);
831
1125
  }
832
- const viewHalf = this.getSize()._divideBy(2);
1126
+ // the layer point under the view center doesn't depend on zoom (pane transforms only rotate/pitch around the pivot),
1127
+ // so this matches `_setBearing` also when the pivot is no longer the view center (eg. after panning)
833
1128
  return this.project(center, zoom)
834
- .rotate(this._bearing)
835
- ._subtract(viewHalf)
836
- ._add(this._getMapPanePos())
837
- ._add(this._getRotatePanePos())
838
- .rotate(-this._bearing)
1129
+ ._subtract(this.containerPointToLayerPoint(this.getSize()._divideBy(2)))
839
1130
  ._round();
840
1131
  },
841
1132
 
@@ -860,21 +1151,28 @@ L.Map.include({
860
1151
  const mapZoom = this._animatingZoom ? Math.max(this._animateToZoom, this.getZoom()) : this.getZoom(),
861
1152
  scale = this.getZoomScale(mapZoom, zoom),
862
1153
  pixelCenter = this.project(center, zoom).floor(),
863
- size = this.getSize(),
864
- halfSize = new L.Bounds([
865
- this.containerPointToLayerPoint([0, 0]).floor(),
866
- this.containerPointToLayerPoint([size.x, 0]).floor(),
867
- this.containerPointToLayerPoint([0, size.y]).floor(),
868
- this.containerPointToLayerPoint([size.x, size.y]).floor()
869
- ]).getSize().divideBy(scale * 2);
1154
+ [min, max] = this._getPitchedContainerBounds(0),
1155
+ corners = [[min.x, min.y], [max.x, min.y], [min.x, max.y], [max.x, max.y]]
1156
+ .map(point => this.containerPointToLayerPoint(point));
1157
+
1158
+ if (this._pitch) {
1159
+ // the pitched view is not symmetric: the far side covers much more than the near one
1160
+ const viewCenter = this.containerPointToLayerPoint(this.getSize()._divideBy(2));
1161
+ return new L.Bounds(corners.map(point => pixelCenter.add(point.subtract(viewCenter).divideBy(scale))));
1162
+ }
1163
+
1164
+ const halfSize = new L.Bounds(corners.map(point => point.floor())).getSize().divideBy(scale * 2);
870
1165
 
871
1166
  return new L.Bounds(pixelCenter.subtract(halfSize), pixelCenter.add(halfSize));
872
1167
  },
873
1168
 
874
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
+ *
875
1173
  * @since leaflet-rotate (v0.2.0)
876
1174
  *
877
- * @returns {L.Point} Map pivot point in pixel coordinates.
1175
+ * @returns {L.Point} Map pivot in map-pane pixels.
878
1176
  */
879
1177
  _getPixelCenter() {
880
1178
  if (!this._rotate && mapProto._getPixelCenter) {
@@ -884,6 +1182,9 @@ L.Map.include({
884
1182
  },
885
1183
 
886
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
+ *
887
1188
  * @see src\layer\vector\Renderer::_update()
888
1189
  *
889
1190
  * @param {number} padding Bounds padding as a fraction of map size.
@@ -896,11 +1197,7 @@ L.Map.include({
896
1197
  if (!this._rotate && mapProto._getPaddedPixelBounds) {
897
1198
  return mapProto._getPaddedPixelBounds.apply(this, arguments);
898
1199
  }
899
- const p = padding,
900
- size = this.getSize(),
901
- padMin = size.multiplyBy(-p),
902
- padMax = size.multiplyBy(1 + p);
903
- //min = this.containerPointToLayerPoint(size.multiplyBy(-p)).round();
1200
+ const [padMin, padMax] = this._getPitchedContainerBounds(padding);
904
1201
 
905
1202
  return new L.Bounds([
906
1203
  this.containerPointToLayerPoint([padMin.x, padMin.y]).floor(),
@@ -27,12 +27,19 @@ L.Map.CompassBearing = L.Handler.extend({
27
27
 
28
28
  /**
29
29
  * Subscribe to the supported device-orientation event, if available.
30
+ * On iOS, permission is requested first (call `enable()` from a user gesture).
30
31
  *
31
32
  * @since leaflet-rotate (v0.1.0)
32
33
  */
33
34
  addHooks() {
34
35
  if (this._map._rotate && this.__deviceOrientationEvent) {
35
36
  L.DomEvent.on(window, this.__deviceOrientationEvent, this._throttled, this);
37
+ const DOE = window.DeviceOrientationEvent;
38
+ if (DOE && DOE.requestPermission) {
39
+ DOE.requestPermission()
40
+ .then(state => 'denied' === state && this.enabled() && this.disable())
41
+ .catch(() => {});
42
+ }
36
43
  } else {
37
44
  // L.Map.CompassBearing handler will be automatically
38
45
  // disabled if device orientation is not supported.