leaflet-rotate 0.5.1 → 0.5.3

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 CHANGED
@@ -26,7 +26,7 @@ Load Leaflet first, then the plugin. For example, using the published CDN build:
26
26
  ```html
27
27
  <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9/dist/leaflet.css">
28
28
  <script src="https://unpkg.com/leaflet@1.9/dist/leaflet.js"></script>
29
- <script src="https://unpkg.com/leaflet-rotate@0.5.1/dist/leaflet-rotate.js"></script>
29
+ <script src="https://unpkg.com/leaflet-rotate@0.5.3/dist/leaflet-rotate.js"></script>
30
30
  ```
31
31
 
32
32
  Or if you want to test it with [`2.0.0-alpha.1`](https://github.com/Leaflet/Leaflet/releases/tag/v2.0.0-alpha.1):
@@ -35,7 +35,7 @@ Or if you want to test it with [`2.0.0-alpha.1`](https://github.com/Leaflet/Leaf
35
35
  <link rel="stylesheet" href="https://unpkg.com/leaflet@2.0.0-alpha.1/dist/leaflet.css">
36
36
  <script src="https://unpkg.com/leaflet@2.0.0-alpha.1/dist/leaflet-global-src.js"></script>
37
37
  <script>globalThis.L = globalThis.leaflet;</script>
38
- <script src="https://unpkg.com/leaflet-rotate@0.5.1/dist/leaflet-rotate.js"></script>
38
+ <script src="https://unpkg.com/leaflet-rotate@0.5.3/dist/leaflet-rotate.js"></script>
39
39
  ```
40
40
 
41
41
  Now, you can enable rotation in the map options. Bearing and pitch values are in degrees:
package/build/server.js CHANGED
@@ -72,11 +72,27 @@ export function startServer(port = 8080) {
72
72
  response.writeHead(404).end();
73
73
  return;
74
74
  }
75
- response.writeHead(200, {
75
+ const headers = {
76
+ 'Accept-Ranges': 'bytes',
76
77
  'Content-Type': types[extname(target).toLowerCase()] ?? 'application/octet-stream',
77
- });
78
+ };
79
+ // Serve a single byte range per request.
80
+ const range = /^bytes=(\d*)-(\d*)$/.exec(request.headers.range ?? '');
81
+ let start = 0;
82
+ let end = content.size - 1;
83
+ if (range && (range[1] || range[2])) {
84
+ start = range[1] ? Number(range[1]) : Math.max(0, content.size - Number(range[2]));
85
+ end = range[1] && range[2] ? Math.min(Number(range[2]), end) : end;
86
+ if (start > end) {
87
+ response.writeHead(416, { 'Content-Range': `bytes */${content.size}` }).end();
88
+ return;
89
+ }
90
+ response.writeHead(206, { ...headers, 'Content-Length': end - start + 1, 'Content-Range': `bytes ${start}-${end}/${content.size}` });
91
+ } else {
92
+ response.writeHead(200, { ...headers, 'Content-Length': content.size });
93
+ }
78
94
  if (request.method === 'HEAD') response.end();
79
- else createReadStream(target).on('error', error => {
95
+ else createReadStream(target, content.size ? { start, end } : undefined).on('error', error => {
80
96
  console.error(error);
81
97
  response.destroy(error);
82
98
  }).pipe(response);
@@ -478,6 +478,24 @@ var leafletRotate = (() => {
478
478
  if (this._map._rotate) {
479
479
  pos = this._map.rotatedPointToMapPanePoint(pos);
480
480
  }
481
+ const bearing = this._getIconBearing();
482
+ if (this._icon) {
483
+ L.DomUtil.setPosition(this._icon, pos, bearing, pos, this.options.scale);
484
+ }
485
+ if (this._shadow) {
486
+ L.DomUtil.setPosition(this._shadow, pos, bearing, pos, this.options.scale);
487
+ }
488
+ this._zIndex = pos.y + this.options.zIndexOffset;
489
+ this._resetZIndex();
490
+ },
491
+ /**
492
+ * Compute the on-screen icon rotation from `rotation`, `rotateWithView` and `keepUpright`.
493
+ *
494
+ * @returns {number} Angle in radians.
495
+ *
496
+ * @since leaflet-rotate (0.5.3)
497
+ */
498
+ _getIconBearing() {
481
499
  let bearing = this.options.rotation || 0;
482
500
  if (this.options.rotateWithView) {
483
501
  bearing += this._map._bearing;
@@ -489,14 +507,7 @@ var leafletRotate = (() => {
489
507
  }
490
508
  bearing %= 2 * Math.PI;
491
509
  }
492
- if (this._icon) {
493
- L.DomUtil.setPosition(this._icon, pos, bearing, pos, this.options.scale);
494
- }
495
- if (this._shadow) {
496
- L.DomUtil.setPosition(this._shadow, pos, bearing, pos, this.options.scale);
497
- }
498
- this._zIndex = pos.y + this.options.zIndexOffset;
499
- this._resetZIndex();
510
+ return bearing;
500
511
  },
501
512
  // _updateZIndex(offset) {
502
513
  // if (!this._map._rotate) {
@@ -541,9 +552,24 @@ var leafletRotate = (() => {
541
552
  }
542
553
  return events;
543
554
  },
555
+ /**
556
+ * Preserve the 3D context on tile containers without making individual tile levels 3D.
557
+ *
558
+ * @since leaflet-rotate (0.5.3)
559
+ */
560
+ _initContainer() {
561
+ gridLayerProto._initContainer.apply(this, arguments);
562
+ if (!this._map._rotate || !this._container) {
563
+ return;
564
+ }
565
+ for (let pane = this._container; pane && pane !== this._map._rotatePane; pane = pane.parentElement) {
566
+ pane.style.transformStyle = "preserve-3d";
567
+ }
568
+ },
544
569
  /**
545
570
  * Keep tile levels aligned when a bearing/pitch change shifts the pixel origin
546
571
  * (eg. when the rotation pivot is not the view center), then refresh tiles (throttled).
572
+ * The first event also synchronizes levels created before any accumulated pans.
547
573
  *
548
574
  * @listens L.Map~rotate
549
575
  *
@@ -555,7 +581,7 @@ var leafletRotate = (() => {
555
581
  return;
556
582
  }
557
583
  const origin = map.getPixelOrigin();
558
- if (this._rotateOrigin && !this._rotateOrigin.equals(origin) && !map._animatingZoom && this._levels) {
584
+ if ((!this._rotateOrigin || !this._rotateOrigin.equals(origin)) && !map._animatingZoom && this._levels) {
559
585
  this._setZoomTransforms(map.getCenter(), map.getZoom());
560
586
  }
561
587
  this._rotateOrigin = origin;
@@ -796,6 +822,19 @@ var leafletRotate = (() => {
796
822
  pitchMaxDistance: 3
797
823
  });
798
824
  L.Map.include({
825
+ /**
826
+ * Rebuild perspective before Leaflet calculates the origin of a reset view.
827
+ * A reset clears the outer pane translation, unlike a completed pan.
828
+ */
829
+ _resetView() {
830
+ if (this._pitch) {
831
+ delete this._pitchPan;
832
+ L.DomUtil.setPosition(this._mapPane, new L.Point(0, 0));
833
+ this._updatePitchTransform();
834
+ arguments[0] = this._limitCenter(arguments[0], arguments[1], this.options.maxBounds);
835
+ }
836
+ return mapProto._resetView.apply(this, arguments);
837
+ },
799
838
  /**
800
839
  * Apply an immediate animated-zoom frame without waiting for a transition delay.
801
840
  *
@@ -831,7 +870,11 @@ var leafletRotate = (() => {
831
870
  mapProto.initialize.apply(this, arguments);
832
871
  if (this.options.rotate) {
833
872
  this.on("resize", this._updatePitchTransform, this);
873
+ this.on("movestart dragstart", this._startPitchPan, this);
874
+ this.on("move", this._updatePitchPan, this);
875
+ this.on("moveend", this._endPitchPan, this);
834
876
  this.on("move", this._updatePitchSky, this);
877
+ this.on("rotate", this._panInsideMaxBounds, this);
835
878
  this.setBearing(this.options.bearing);
836
879
  this.setPitch(this.options.pitch);
837
880
  }
@@ -951,7 +994,8 @@ var leafletRotate = (() => {
951
994
  /**
952
995
  * Return the geographic bounds of the current viewport, accounting for rotation and pitch.
953
996
  *
954
- * The result is the smallest geographic bounds containing all four viewport corners.
997
+ * The result encloses the four corners of the visible ground rectangle. With pitch,
998
+ * the rectangle stops at the configured far plane, excluding the sky.
955
999
  *
956
1000
  * @returns {L.LatLngBounds} Geographic bounds visible in the map viewport.
957
1001
  *
@@ -1170,26 +1214,38 @@ var leafletRotate = (() => {
1170
1214
  this.fire("rotate");
1171
1215
  },
1172
1216
  /**
1173
- * Transform a point with a projective matrix, accepting DOMMatrix or its 2D inverse.
1217
+ * Transform a ground-plane point with a DOMMatrix, or reverse its screen projection.
1218
+ * Both branches divide by the homogeneous coordinate after applying the matrix.
1174
1219
  *
1175
1220
  * @param {L.Point|number[]} point Input pixel coordinate.
1176
- * @param {DOMMatrix|Object} matrix Forward or inverse transformation matrix.
1221
+ * @param {DOMMatrix|Object} matrix Forward CSS matrix or inverse homography with
1222
+ * row-major coefficients `a` through `i`.
1223
+ * @param {L.Point} [anchor] Keep inverse pan coordinates on this anchor's side of the horizon.
1177
1224
  *
1178
1225
  * @returns {L.Point} Transformed pixel coordinate.
1179
1226
  *
1180
1227
  * @since leaflet-rotate (0.5.0)
1181
1228
  */
1182
- _transformPitchPoint(point, matrix) {
1229
+ _transformPitchPoint(point, matrix, anchor) {
1183
1230
  point = toPoint(point);
1184
1231
  if (!(matrix instanceof DOMMatrix)) {
1185
- const denominator = matrix.g * point.x + matrix.h * point.y + matrix.i;
1232
+ if (anchor) {
1233
+ const anchorW = matrix.g * anchor.x + matrix.h * anchor.y + matrix.i, pointW = matrix.g * point.x + matrix.h * point.y + matrix.i;
1234
+ if (pointW * anchorW <= 0) {
1235
+ point = anchor.add(point.subtract(anchor).multiplyBy(anchorW / (anchorW - pointW) * (1 - 1e-6)));
1236
+ }
1237
+ }
1238
+ const denominator2 = matrix.g * point.x + matrix.h * point.y + matrix.i;
1186
1239
  return new L.Point(
1187
- (matrix.a * point.x + matrix.b * point.y + matrix.c) / denominator,
1188
- (matrix.d * point.x + matrix.e * point.y + matrix.f) / denominator
1240
+ (matrix.a * point.x + matrix.b * point.y + matrix.c) / denominator2,
1241
+ (matrix.d * point.x + matrix.e * point.y + matrix.f) / denominator2
1189
1242
  );
1190
1243
  }
1191
- const transformed = new DOMPoint(point.x, point.y).matrixTransform(matrix);
1192
- return new L.Point(transformed.x / transformed.w, transformed.y / transformed.w);
1244
+ const denominator = matrix.m14 * point.x + matrix.m24 * point.y + matrix.m44;
1245
+ return new L.Point(
1246
+ (matrix.m11 * point.x + matrix.m21 * point.y + matrix.m41) / denominator,
1247
+ (matrix.m12 * point.x + matrix.m22 * point.y + matrix.m42) / denominator
1248
+ );
1193
1249
  },
1194
1250
  /**
1195
1251
  * Apply the current pitch/bearing CSS transform and cache its forward and inverse matrices.
@@ -1201,9 +1257,25 @@ var leafletRotate = (() => {
1201
1257
  return;
1202
1258
  }
1203
1259
  const pivot = this._getPixelCenter(), size = this.getSize(), perspective = Math.max(1, size.y * 1.5), position = this._rotatePane._leaflet_pos || new L.Point(0, 0), 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)`;
1260
+ this._setPitchTransform(transform);
1261
+ },
1262
+ /**
1263
+ * Apply the CSS transform and cache matching forward/inverse point conversions.
1264
+ *
1265
+ * Ground points have z = 0, so the 4x4 CSS matrix reduces to the homography
1266
+ * [a b c; d e f; g h i]. Its inverse maps screen points back onto that plane;
1267
+ * inverting the full 4x4 matrix at screen z = 0 would not undo perspective.
1268
+ *
1269
+ * @param {string} transform CSS transform list, including a serialized DOMMatrix.
1270
+ *
1271
+ * @since leaflet-rotate (0.5.2)
1272
+ */
1273
+ _setPitchTransform(transform) {
1204
1274
  this._rotatePane.style.transformOrigin = "0 0";
1205
- this._rotatePane.style.transform = transform;
1206
1275
  const matrix = this._pitchTransform = new DOMMatrix(transform), a = matrix.m11, b = matrix.m21, c = matrix.m41, d = matrix.m12, e = matrix.m22, f = matrix.m42, g = matrix.m14, h = matrix.m24, i = matrix.m44, determinant = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g);
1276
+ const ground = DOMMatrix.fromMatrix(matrix);
1277
+ ground.m13 = ground.m23 = ground.m43 = 0;
1278
+ this._rotatePane.style.transform = `matrix3d(${Array.from(ground.toFloat64Array(), (value) => value * 1e3).join(",")})`;
1207
1279
  this._pitchInverseTransform = {
1208
1280
  a: (e * i - f * h) / determinant,
1209
1281
  b: (c * h - b * i) / determinant,
@@ -1217,9 +1289,76 @@ var leafletRotate = (() => {
1217
1289
  };
1218
1290
  this._updatePitchSky();
1219
1291
  },
1292
+ /**
1293
+ * Capture a fixed projection baseline for a pitched drag or programmatic pan.
1294
+ *
1295
+ * `movestart` uses the viewport center; `dragstart` replaces it with the original
1296
+ * pointer position. Store both the screen anchor and its ground-plane coordinate
1297
+ * so subsequent frames can keep the grabbed geographic point under the pointer.
1298
+ * Pane positions are in screen pixels and the baseline matrices stay unchanged.
1299
+ *
1300
+ * @param {L.Event} e Leaflet `movestart` or `dragstart` event.
1301
+ *
1302
+ * @since leaflet-rotate (0.5.2)
1303
+ */
1304
+ _startPitchPan(e) {
1305
+ if (!this._pitch || !this._loaded) {
1306
+ return;
1307
+ }
1308
+ const draggable = this.dragging && this.dragging._draggable, anchor = e.type === "dragstart" && draggable ? (this.pointerEventToContainerPoint || this.mouseEventToContainerPoint).call(this, {
1309
+ clientX: draggable._startPoint.x,
1310
+ clientY: draggable._startPoint.y
1311
+ }) : this.getSize().divideBy(2), panePos = this._getMapPanePos().clone();
1312
+ this._pitchPan = {
1313
+ anchor,
1314
+ panePos,
1315
+ lastPanePos: panePos,
1316
+ matrix: this._pitchTransform,
1317
+ inverse: this._pitchInverseTransform,
1318
+ layerAnchor: this.containerPointToLayerPoint(anchor),
1319
+ zoom: this._zoom
1320
+ };
1321
+ },
1322
+ /**
1323
+ * Translate the ground plane without moving the camera's vanishing point.
1324
+ *
1325
+ * For pane displacement delta, invert the displaced anchor with the baseline
1326
+ * projection to find the required layer-space shift. Compose
1327
+ * T(-delta) * baseline * T(shift): the left translation cancels Leaflet's outer
1328
+ * pane movement, while the right translation moves geography before perspective.
1329
+ * Always derive this from the baseline, not the previous frame, to avoid drift.
1330
+ * Zoom frames are handled by Leaflet's zoom/origin updates instead.
1331
+ *
1332
+ * @since leaflet-rotate (0.5.2)
1333
+ */
1334
+ _updatePitchPan() {
1335
+ const pan = this._pitchPan;
1336
+ if (!this._pitch || !pan || this._animatingZoom || this._zoom !== pan.zoom) {
1337
+ return;
1338
+ }
1339
+ const panePos = this._getMapPanePos();
1340
+ if (panePos.equals(pan.lastPanePos)) {
1341
+ return;
1342
+ }
1343
+ const delta = panePos.subtract(pan.panePos), target = this._transformPitchPoint(pan.anchor.add(delta).subtract(pan.panePos), pan.inverse), shift = target.subtract(pan.layerAnchor);
1344
+ const matrix = new DOMMatrix().translate(-delta.x, -delta.y).multiply(pan.matrix).translate(shift.x, shift.y);
1345
+ this._setPitchTransform(matrix.toString());
1346
+ pan.lastPanePos = panePos.clone();
1347
+ delete this._lastCenter;
1348
+ },
1349
+ /**
1350
+ * Discard the baseline only at `moveend`, after any drag inertia has completed.
1351
+ * Keep the final transform and pixel origin: resetting the view here would
1352
+ * reproject layers abruptly and cause a visible jump on release.
1353
+ *
1354
+ * @since leaflet-rotate (0.5.2)
1355
+ */
1356
+ _endPitchPan() {
1357
+ delete this._pitchPan;
1358
+ },
1220
1359
  /**
1221
1360
  * Fill the area above the far plane (where no tiles are loaded) with a sky gradient.
1222
- * Style it through the `.leaflet-pitch-sky` class.
1361
+ * Style it through `.leaflet-pitch-sky` or the `--leaflet-sky-background` CSS property.
1223
1362
  *
1224
1363
  * @since leaflet-rotate (0.5.1)
1225
1364
  */
@@ -1240,7 +1379,7 @@ var leafletRotate = (() => {
1240
1379
  width: "100%",
1241
1380
  zIndex: 0,
1242
1381
  pointerEvents: "none",
1243
- background: "linear-gradient(#9ec5e8, #dfe9f0)"
1382
+ background: "var(--leaflet-sky-background, linear-gradient(#9ec5e8, #dfe9f0))"
1244
1383
  });
1245
1384
  }
1246
1385
  this._pitchSky.style.display = "";
@@ -1398,6 +1537,7 @@ var leafletRotate = (() => {
1398
1537
  L.DomUtil.setPosition(this._mapPane, new L.Point(0, 0));
1399
1538
  if (this._rotate) {
1400
1539
  this._rotatePane = this.createPane("rotatePane", this._mapPane);
1540
+ this._mapPane.style.transformStyle = this._rotatePane.style.transformStyle = "preserve-3d";
1401
1541
  this._norotatePane = this.createPane("norotatePane", this._mapPane);
1402
1542
  if (L.version.startsWith("2.")) {
1403
1543
  this._rotatePane.style.transformOrigin = "0 0";
@@ -1454,53 +1594,87 @@ var leafletRotate = (() => {
1454
1594
  return this;
1455
1595
  },
1456
1596
  /**
1457
- * Pans the map to the closest view that would lie inside the given bounds
1458
- * (if it's not already), controlling the animation using the options specific,
1459
- * if any.
1460
- *
1461
- * @TODO check if map bounds calculated by `L.Map::panInsideBounds()`
1462
- * function should match the `rotatePane` or `norotatePane` bounds
1597
+ * Constrain the visible ground footprint, including its asymmetric pitched far side.
1598
+ * When it is larger than maxBounds, Leaflet's rebound centers the excess on each axis.
1599
+ */
1600
+ _limitCenter(center, zoom, bounds) {
1601
+ if (!this._rotate || !bounds) {
1602
+ return mapProto._limitCenter.apply(this, arguments);
1603
+ }
1604
+ const centerPoint = this.project(center, zoom), layerCenter = this.containerPointToLayerPoint(this.getSize().divideBy(2)), footprint = this._getPaddedPixelBounds(0, false), viewBounds = new L.Bounds(
1605
+ centerPoint.add(footprint.min.subtract(layerCenter)),
1606
+ centerPoint.add(footprint.max.subtract(layerCenter))
1607
+ ), offset = this._getBoundsOffset(viewBounds, bounds, zoom);
1608
+ if (Math.abs(offset.x) <= 1 && Math.abs(offset.y) <= 1) {
1609
+ return center;
1610
+ }
1611
+ return this.unproject(centerPoint.add(offset), zoom);
1612
+ },
1613
+ /**
1614
+ * Reapply configured maximum bounds after a map transform.
1463
1615
  *
1464
- * @see https://github.com/fnicollet/Leaflet/issues/7
1465
- *
1466
- * @param {L.LatLngBounds} bounds Geographic bounds to fit inside the viewport.
1467
- * @param {Object} [options] Pan animation options.
1468
- *
1616
+ * @since leaflet-rotate (0.5.3)
1617
+ */
1618
+ _panInsideMaxBounds() {
1619
+ if (this.options.maxBounds) {
1620
+ return mapProto._panInsideMaxBounds.apply(this, arguments);
1621
+ }
1622
+ },
1623
+ /**
1624
+ * Keep the visible ground footprint inside the given bounds.
1625
+ *
1626
+ * @param {L.LatLngBounds|Array} bounds Geographic bounds.
1627
+ * @param {Object} [options] Pan options passed to Leaflet.
1469
1628
  * @returns {L.Map} This map instance.
1470
- *
1471
- * @since leaflet-rotate (v0.1.0)
1629
+ *
1630
+ * @since leaflet-rotate (0.5.3)
1472
1631
  */
1473
- // panInsideBounds(bounds, options) {
1474
- // this._enforcingBounds = true;
1475
- // const center = this.getCenter(),
1476
- // newCenter = this._limitCenter(center, this._zoom, L.latLngBounds(bounds));
1477
- //
1478
- // if (!center.equals(newCenter)) {
1479
- // this.panTo(newCenter, options);
1480
- // }
1481
- //
1482
- // this._enforcingBounds = false;
1483
- // return this;
1484
- // },
1485
- // adjust center for view to get inside bounds
1486
- // _limitCenter(center, zoom, bounds) {
1487
- //
1488
- // if (!bounds) { return center; }
1489
- //
1490
- // const centerPoint = this.project(center, zoom),
1491
- // viewHalf = this.getSize().divideBy(2),
1492
- // viewBounds = new Bounds(centerPoint.subtract(viewHalf), centerPoint.add(viewHalf)),
1493
- // offset = this._getBoundsOffset(viewBounds, bounds, zoom);
1494
- //
1495
- // // If offset is less than a pixel, ignore.
1496
- // // This prevents unstable projections from getting into
1497
- // // an infinite loop of tiny offsets.
1498
- // if (Math.abs(offset.x) <= 1 && Math.abs(offset.y) <= 1) {
1499
- // return center;
1500
- // }
1501
- //
1502
- // return this.unproject(centerPoint.add(offset), zoom);
1503
- // },
1632
+ panInsideBounds(bounds, options) {
1633
+ if (this._rotate) {
1634
+ bounds = bounds instanceof L.LatLngBounds ? bounds : new L.LatLngBounds(bounds);
1635
+ const center = this.getCenter(), limited = this._limitCenter(center, this._zoom, bounds);
1636
+ const offset = this._getCenterOffset(limited);
1637
+ if (!center.equals(limited) && Math.abs(offset.x) <= 1 && Math.abs(offset.y) <= 1) {
1638
+ this._enforcingBounds = true;
1639
+ this.setView(limited, this._zoom, { reset: true });
1640
+ this._enforcingBounds = false;
1641
+ return this;
1642
+ }
1643
+ }
1644
+ return mapProto.panInsideBounds.apply(this, arguments);
1645
+ },
1646
+ /**
1647
+ * Leaflet supplies inertia offsets in screen pixels, not projected CRS pixels.
1648
+ * Keep the drag's moving anchor when inertia continues a pitched pan.
1649
+ */
1650
+ _limitOffset(offset, bounds) {
1651
+ if (!this._rotate || !bounds) {
1652
+ return mapProto._limitOffset.apply(this, arguments);
1653
+ }
1654
+ const pan = this._pitchPan, anchor = pan ? pan.anchor.add(this._getMapPanePos().subtract(pan.panePos)) : this.getSize().divideBy(2), layerAnchor = this.containerPointToLayerPoint(anchor), panePos = this._getMapPanePos(), target = anchor.subtract(offset).subtract(panePos), shift = (this._pitch ? this._transformPitchPoint(target, this._pitchInverseTransform, anchor.subtract(panePos)) : this.containerPointToLayerPoint(anchor.subtract(offset))).subtract(layerAnchor), footprint = this._getPaddedPixelBounds(0, false), origin = this.getPixelOrigin().subtract(shift), correction = this._getBoundsOffset(new L.Bounds(
1655
+ footprint.min.add(origin),
1656
+ footprint.max.add(origin)
1657
+ ), bounds);
1658
+ return anchor.subtract(this.layerPointToContainerPoint(layerAnchor.add(shift).subtract(correction)));
1659
+ },
1660
+ /**
1661
+ * Pan by a screen-pixel offset, constraining large immediate pans to maxBounds.
1662
+ *
1663
+ * @param {L.Point|number[]} offset Pan offset in pixels.
1664
+ * @param {Object} [options] Pan options passed to Leaflet.
1665
+ * @returns {L.Map} This map instance.
1666
+ *
1667
+ * @since leaflet-rotate (0.5.3)
1668
+ */
1669
+ panBy(offset, options) {
1670
+ offset = toPoint(offset);
1671
+ if (this._rotate && this._loaded && !this.getSize().contains(offset) && !(options && options.animate)) {
1672
+ const anchor = this.getSize().divideBy(2), panePos = this._getMapPanePos(), layerAnchor = this.containerPointToLayerPoint(anchor), target = anchor.subtract(offset).subtract(panePos), shift = (this._pitch ? this._transformPitchPoint(target, this._pitchInverseTransform, anchor.subtract(panePos)) : this.containerPointToLayerPoint(anchor.subtract(offset))).subtract(layerAnchor), center = this.unproject(this.project(this.getCenter()).subtract(shift));
1673
+ this._resetView(this._limitCenter(center, this._zoom, this.options.maxBounds), this._zoom);
1674
+ return this;
1675
+ }
1676
+ return mapProto.panBy.call(this, offset, options);
1677
+ },
1504
1678
  // @method flyToBounds(bounds: LatLngBounds, options?: fitBounds options): this
1505
1679
  // Sets the view of the map with a smooth animation like [`flyTo`](#map-flyto),
1506
1680
  // but takes a bounds parameter like [`fitBounds`](#map-fitbounds).
@@ -1565,7 +1739,10 @@ var leafletRotate = (() => {
1565
1739
  // return this.containerPointToLayerPoint(this.getSize()._divideBy(2));
1566
1740
  // },
1567
1741
  /**
1568
- * Return the pixel offset from the current center to the given geographic point.
1742
+ * Return the screen-space pan offset needed to center the given geographic point.
1743
+ * Under perspective, projecting a negated layer offset is not equivalent to
1744
+ * negating its screen projection. Reflect the target about the layer center
1745
+ * to match the opposite anchor displacement performed by Leaflet's `panBy`.
1569
1746
  *
1570
1747
  * @param {L.LatLng} latlng Geographic point.
1571
1748
  *
@@ -1577,6 +1754,10 @@ var leafletRotate = (() => {
1577
1754
  if (!this._rotate) {
1578
1755
  return mapProto._getCenterOffset.apply(this, arguments);
1579
1756
  }
1757
+ if (this._pitch) {
1758
+ const center = this.getSize().divideBy(2), layerCenter = this.containerPointToLayerPoint(center), opposite = layerCenter.multiplyBy(2).subtract(this.project(latlng).subtract(this._pixelOrigin));
1759
+ return center.subtract(this.layerPointToContainerPoint(opposite));
1760
+ }
1580
1761
  return this.latLngToContainerPoint(latlng)._subtract(this.getSize()._divideBy(2));
1581
1762
  },
1582
1763
  /**
@@ -1636,9 +1817,12 @@ var leafletRotate = (() => {
1636
1817
  return new L.Bounds(pixelCenter.subtract(halfSize), pixelCenter.add(halfSize));
1637
1818
  },
1638
1819
  /**
1820
+ * Return the viewport center before the outer map-pane translation.
1821
+ * Used as the bearing/pitch pivot in map-pane coordinates, not layer coordinates.
1822
+ *
1639
1823
  * @since leaflet-rotate (v0.2.0)
1640
1824
  *
1641
- * @returns {L.Point} Map pivot point in pixel coordinates.
1825
+ * @returns {L.Point} Map pivot in map-pane pixels.
1642
1826
  */
1643
1827
  _getPixelCenter() {
1644
1828
  if (!this._rotate && mapProto._getPixelCenter) {
@@ -1647,25 +1831,32 @@ var leafletRotate = (() => {
1647
1831
  return this.getSize()._divideBy(2)._subtract(this._getMapPanePos());
1648
1832
  },
1649
1833
  /**
1834
+ * Convert the padded visible ground rectangle to layer bounds for vector renderers.
1835
+ * Clip its far edge before reversing perspective to avoid unbounded render areas.
1836
+ *
1650
1837
  * @see src\layer\vector\Renderer::_update()
1651
1838
  *
1652
1839
  * @param {number} padding Bounds padding as a fraction of map size.
1840
+ * @param {boolean} [round=true] Round renderer bounds; navigation needs exact ground coordinates.
1653
1841
  *
1654
1842
  * @returns {L.Bounds} Padded bounds in layer-point coordinates.
1655
1843
  *
1656
1844
  * @since leaflet-rotate (v0.2.0)
1657
1845
  */
1658
- _getPaddedPixelBounds(padding) {
1846
+ _getPaddedPixelBounds(padding, round = true) {
1659
1847
  if (!this._rotate && mapProto._getPaddedPixelBounds) {
1660
1848
  return mapProto._getPaddedPixelBounds.apply(this, arguments);
1661
1849
  }
1662
1850
  const [padMin, padMax] = this._getPitchedContainerBounds(padding);
1663
1851
  return new L.Bounds([
1664
- this.containerPointToLayerPoint([padMin.x, padMin.y]).floor(),
1665
- this.containerPointToLayerPoint([padMin.x, padMax.y]).floor(),
1666
- this.containerPointToLayerPoint([padMax.x, padMin.y]).floor(),
1667
- this.containerPointToLayerPoint([padMax.x, padMax.y]).floor()
1668
- ]);
1852
+ [padMin.x, padMin.y],
1853
+ [padMin.x, padMax.y],
1854
+ [padMax.x, padMin.y],
1855
+ [padMax.x, padMax.y]
1856
+ ].map((point) => {
1857
+ const projected = this.containerPointToLayerPoint(point);
1858
+ return round ? projected.floor() : projected;
1859
+ }));
1669
1860
  },
1670
1861
  /**
1671
1862
  * Build and fire Leaflet's location-found event, including the device heading.
@@ -1846,6 +2037,55 @@ var leafletRotate = (() => {
1846
2037
  });
1847
2038
  L.Map.addInitHook("addHandler", "trackContainerMutation", L.Map.ContainerMutation);
1848
2039
 
2040
+ // src/map/handler/Drag.js
2041
+ var dragProto = Object.defineProperties({}, Object.getOwnPropertyDescriptors(L.Map.Drag.prototype));
2042
+ L.Map.Drag.include({
2043
+ /**
2044
+ * Capture the ground footprint and pointer anchor before a constrained drag.
2045
+ */
2046
+ _onDragStart() {
2047
+ dragProto._onDragStart.apply(this, arguments);
2048
+ const map = this._map;
2049
+ this._rotateLimit = null;
2050
+ if (!map._rotate || !map.options.maxBounds || !map.options.maxBoundsViscosity) {
2051
+ return;
2052
+ }
2053
+ const anchor = (map.pointerEventToContainerPoint || map.mouseEventToContainerPoint).call(map, {
2054
+ clientX: this._draggable._startPoint.x,
2055
+ clientY: this._draggable._startPoint.y
2056
+ }), footprint = map._getPaddedPixelBounds(0, false), origin = map.getPixelOrigin();
2057
+ this._rotateLimit = {
2058
+ anchor,
2059
+ layerAnchor: map.containerPointToLayerPoint(anchor),
2060
+ panePos: map._getMapPanePos().clone(),
2061
+ footprint: new L.Bounds(footprint.min.add(origin), footprint.max.add(origin)),
2062
+ matrix: map._pitch ? map._pitchTransform : null,
2063
+ inverse: map._pitchInverseTransform,
2064
+ bearing: map._bearing
2065
+ };
2066
+ },
2067
+ /**
2068
+ * Apply max-bounds viscosity in ground coordinates during a rotated drag.
2069
+ */
2070
+ _onPreDragLimit() {
2071
+ const limit = this._rotateLimit;
2072
+ if (!this._map._rotate || !limit) {
2073
+ return dragProto._onPreDragLimit.apply(this, arguments);
2074
+ }
2075
+ const map = this._map, draggable = this._draggable, offset = draggable._newPos.subtract(draggable._startPos), shift = limit.matrix ? map._transformPitchPoint(
2076
+ limit.anchor.add(offset).subtract(limit.panePos),
2077
+ limit.inverse,
2078
+ limit.anchor.subtract(limit.panePos)
2079
+ ).subtract(limit.layerAnchor) : offset.rotate(-limit.bearing), correction = map._getBoundsOffset(new L.Bounds(
2080
+ limit.footprint.min.subtract(shift),
2081
+ limit.footprint.max.subtract(shift)
2082
+ ), map.options.maxBounds);
2083
+ shift._subtract(correction.multiplyBy(this._viscosity));
2084
+ const limitedOffset = limit.matrix ? map._transformPitchPoint(limit.layerAnchor.add(shift), limit.matrix).add(limit.panePos).subtract(limit.anchor) : shift.rotate(limit.bearing);
2085
+ draggable._newPos = draggable._startPos.add(limitedOffset);
2086
+ }
2087
+ });
2088
+
1849
2089
  // src/map/handler/TouchGestures.js
1850
2090
  function touchToContainerPoint(map, touch) {
1851
2091
  const container = map.getContainer(), rect = container.getBoundingClientRect();