Skip to content

Commit c776150

Browse files
stepankuzminmourner
authored andcommitted
Propagate padding set by fitBounds to the global map padding (internal-1352)
1 parent 3cf1f61 commit c776150

File tree

2 files changed

+114
-79
lines changed

2 files changed

+114
-79
lines changed

src/ui/camera.js

Lines changed: 93 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ type Required<T> = ObjMap<T, <V>(v: V) => $NonMaybeType<V>>;
6666
* This is useful for drawing attention to a location that is not in the screen center.
6767
* `center` is ignored if `around` is included.
6868
* @property {PaddingOptions} padding Dimensions in pixels applied on each side of the viewport for shifting the vanishing point.
69+
* Note that when `padding` is used with `jumpTo`, `easeTo`, and `flyTo`, it also sets the global map padding as a side effect,
70+
* affecting all subsequent camera movements until the padding is reset.
6971
* @example
7072
* // set the map's initial perspective with CameraOptions
7173
* const map = new mapboxgl.Map({
@@ -152,8 +154,12 @@ export type ElevationBoxRaycast = {
152154
const freeCameraNotSupportedWarning = 'map.setFreeCameraOptions(...) and map.getFreeCameraOptions() are not yet supported for non-mercator projections.';
153155

154156
/**
155-
* Options for setting padding on calls to methods such as {@link Map#fitBounds}, {@link Map#fitScreenCoordinates}, and {@link Map#setPadding}. Adjust these options to set the amount of padding in pixels added to the edges of the canvas. Set a uniform padding on all edges or individual values for each edge. All properties of this object must be
156-
* non-negative integers.
157+
* Options for setting padding on calls to methods such as {@link Map#jumpTo}, {@link Map#easeTo}, {@link Map#flyTo},
158+
* {@link Map#fitBounds}, {@link Map#fitScreenCoordinates}, and {@link Map#setPadding}. Adjust these options to set
159+
* the amount of padding in pixels added to the edges of the canvas. Set a uniform padding on all edges or individual
160+
* values for each edge. All properties of this object must be non-negative integers. Note that when `padding` is used with
161+
* `fitBounds`, `flyTo`, or similar methods, it also sets the global map padding as a side effect, affecting all
162+
* subsequent camera movements until the padding is reset.
157163
*
158164
* @typedef {Object} PaddingOptions
159165
* @property {number} top Padding in pixels from the top of the map canvas.
@@ -182,6 +188,7 @@ class Camera extends Evented {
182188
_zooming: boolean;
183189
_rotating: boolean;
184190
_pitching: boolean;
191+
_padding: boolean;
185192

186193
_bearingSnap: number;
187194
_easeStart: number;
@@ -605,29 +612,25 @@ class Camera extends Evented {
605612
return this._cameraForBounds(this.transform, lnglat0, lnglat1, bearing, pitch, options);
606613
}
607614

615+
_extendPadding(padding: ?PaddingOptions | ?number): Required<PaddingOptions> {
616+
const defaultPadding = {top: 0, right: 0, bottom: 0, left: 0};
617+
if (padding == null) return extend({}, defaultPadding, this.transform.padding);
618+
619+
if (typeof padding === 'number') {
620+
return {top: padding, bottom: padding, right: padding, left: padding};
621+
}
622+
623+
return extend({}, defaultPadding, padding);
624+
}
625+
608626
_extendCameraOptions(options?: CameraOptions): FullCameraOptions {
609-
const defaultPadding = {
610-
top: 0,
611-
bottom: 0,
612-
right: 0,
613-
left: 0
614-
};
615627
options = extend({
616-
padding: defaultPadding,
617628
offset: [0, 0],
618629
maxZoom: this.transform.maxZoom
619630
}, options);
620631

621-
if (typeof options.padding === 'number') {
622-
const p = options.padding;
623-
options.padding = {
624-
top: p,
625-
bottom: p,
626-
right: p,
627-
left: p
628-
};
629-
}
630-
options.padding = extend(defaultPadding, options.padding);
632+
options.padding = this._extendPadding(options.padding);
633+
631634
return options;
632635
}
633636

@@ -700,9 +703,13 @@ class Camera extends Evented {
700703
const cameraToWorld = mat4.invert(new Float64Array(16), worldToCamera);
701704

702705
aabb = Aabb.applyTransform(aabb, mat4.multiply([], worldToCamera, aabbOrientation));
706+
const extendedAabb = this._extendAABB(aabb, tr, eOptions, bearing);
707+
if (!extendedAabb) {
708+
warnOnce('Map cannot fit within canvas with the given bounds, padding, and/or offset.');
709+
return;
710+
}
703711

704-
aabb = this._extendAABBWithPaddings(aabb, eOptions, tr, bearing);
705-
712+
aabb = extendedAabb;
706713
vec3.transformMat4(center, center, worldToCamera);
707714

708715
const aabbHalfExtentZ = (aabb.max[2] - aabb.min[2]) * 0.5;
@@ -738,40 +745,62 @@ class Camera extends Evented {
738745
return {center: tr.center, zoom, bearing, pitch};
739746
}
740747

741-
_extendAABBWithPaddings(aabb: Aabb, eOptions: FullCameraOptions, tr: Transform, bearing: number): Aabb {
742-
const size = vec3.sub([], aabb.max, aabb.min);
748+
/**
749+
* Extends the AABB with padding, offset, and bearing.
750+
*
751+
* @param {Aabb} aabb The AABB.
752+
* @param {Transform} tr The transform.
753+
* @param {FullCameraOptions} options Camera options.
754+
* @param {number} bearing The bearing.
755+
* @returns {Aabb | null} The extended AABB or null if couldn't scale.
756+
* @private
757+
*/
758+
_extendAABB(aabb: Aabb, tr: Transform, options: FullCameraOptions, bearing: number): Aabb | null {
759+
const padL = options.padding.left || 0;
760+
const padR = options.padding.right || 0;
761+
const padB = options.padding.bottom || 0;
762+
const padT = options.padding.top || 0;
743763

744-
const screenPadL = tr.padding.left || 0;
745-
const screenPadR = tr.padding.right || 0;
746-
const screenPadB = tr.padding.bottom || 0;
747-
const screenPadT = tr.padding.top || 0;
764+
const halfScreenPadX = (padL + padR) * 0.5;
765+
const halfScreenPadY = (padT + padB) * 0.5;
748766

749-
const {left: padL, right: padR, top: padT, bottom: padB} = eOptions.padding;
767+
const top = halfScreenPadY;
768+
const left = halfScreenPadX;
769+
const right = halfScreenPadX;
770+
const bottom = halfScreenPadY;
750771

751-
const halfScreenPadX = (screenPadL + screenPadR) * 0.5;
752-
const halfScreenPadY = (screenPadT + screenPadB) * 0.5;
772+
const width = tr.width - (left + right);
773+
const height = tr.height - (top + bottom);
753774

754-
const scaleX = (tr.width - (screenPadL + screenPadR + padL + padR)) / size[0];
755-
const scaleY = (tr.height - (screenPadB + screenPadT + padB + padT)) / size[1];
775+
const aabbSize: [number, number, number] = vec3.sub(([]: any), aabb.max, aabb.min);
756776

757-
const zoomRef = Math.min(tr.scaleZoom(tr.scale * Math.min(scaleX, scaleY)), eOptions.maxZoom);
777+
const scaleX = width / aabbSize[0];
778+
const scaleY = height / aabbSize[1];
779+
780+
const scale = Math.min(scaleX, scaleY);
781+
782+
const zoomRef = Math.min(tr.scaleZoom(tr.scale * scale), options.maxZoom);
783+
if (isNaN(zoomRef)) {
784+
return null;
785+
}
758786

759787
const scaleRatio = tr.scale / tr.zoomScale(zoomRef);
760788

761-
aabb = new Aabb(
762-
[aabb.min[0] - (padL + halfScreenPadX) * scaleRatio, aabb.min[1] - (padB + halfScreenPadY) * scaleRatio, aabb.min[2]],
763-
[aabb.max[0] + (padR + halfScreenPadX) * scaleRatio, aabb.max[1] + (padT + halfScreenPadY) * scaleRatio, aabb.max[2]]);
789+
const extendedAABB = new Aabb(
790+
[aabb.min[0] - left * scaleRatio, aabb.min[1] - bottom * scaleRatio, aabb.min[2]],
791+
[aabb.max[0] + right * scaleRatio, aabb.max[1] + top * scaleRatio, aabb.max[2]]
792+
);
764793

765-
const centerOffset = (typeof eOptions.offset.x === 'number' && typeof eOptions.offset.y === 'number') ?
766-
new Point(eOptions.offset.x, eOptions.offset.y) :
767-
Point.convert(eOptions.offset);
794+
const centerOffset = (typeof options.offset.x === 'number' && typeof options.offset.y === 'number') ?
795+
new Point(options.offset.x, options.offset.y) :
796+
Point.convert(options.offset);
768797

769798
const rotatedOffset = centerOffset.rotate(-degToRad(bearing));
770799

771-
aabb.center[0] -= rotatedOffset.x * scaleRatio;
772-
aabb.center[1] += rotatedOffset.y * scaleRatio;
800+
extendedAABB.center[0] -= rotatedOffset.x * scaleRatio;
801+
extendedAABB.center[1] += rotatedOffset.y * scaleRatio;
773802

774-
return aabb;
803+
return extendedAABB;
775804
}
776805

777806
/** @section {Querying features} */
@@ -862,9 +891,13 @@ class Camera extends Evented {
862891
const cameraToWorld = mat4.invert(new Float64Array(16), worldToCamera);
863892

864893
aabb = Aabb.applyTransform(aabb, worldToCamera);
894+
const extendedAabb = this._extendAABB(aabb, tr, eOptions, bearing);
895+
if (!extendedAabb) {
896+
warnOnce('Map cannot fit within canvas with the given bounds, padding, and/or offset.');
897+
return;
898+
}
865899

866-
aabb = this._extendAABBWithPaddings(aabb, eOptions, tr, bearing);
867-
900+
aabb = extendedAabb;
868901
const size = vec3.sub([], aabb.max, aabb.min);
869902
const aabbHalfExtentZ = size[2] * 0.5;
870903
const frustumDistance = this._minimumAABBFrustumDistance(tr, aabb);
@@ -1000,8 +1033,6 @@ class Camera extends Evented {
10001033
if (!calculatedOptions) return this;
10011034

10021035
options = extend(calculatedOptions, options);
1003-
// Explicitly remove the padding field because, calculatedOptions already accounts for padding by setting zoom and center accordingly.
1004-
delete options.padding;
10051036

10061037
return options.linear ?
10071038
this.easeTo(options, eventData) :
@@ -1267,7 +1298,7 @@ class Camera extends Evented {
12671298
zoom = 'zoom' in options ? +options.zoom : startZoom,
12681299
bearing = 'bearing' in options ? this._normalizeBearing(options.bearing, startBearing) : startBearing,
12691300
pitch = 'pitch' in options ? +options.pitch : startPitch,
1270-
padding = 'padding' in options ? options.padding : tr.padding;
1301+
padding = this._extendPadding(options.padding);
12711302

12721303
const offsetAsPoint = Point.convert(options.offset);
12731304

@@ -1365,6 +1396,7 @@ class Camera extends Evented {
13651396
this._zooming = zoomChanged;
13661397
this._rotating = bearingChanged;
13671398
this._pitching = pitchChanged;
1399+
this._padding = paddingChanged;
13681400

13691401
this._easeId = options.easeId;
13701402
this._prepareEase(eventData, options.noMoveStart, currently);
@@ -1429,6 +1461,7 @@ class Camera extends Evented {
14291461
this._zooming = false;
14301462
this._rotating = false;
14311463
this._pitching = false;
1464+
this._padding = false;
14321465

14331466
if (wasZooming) {
14341467
this.fire(new Event('zoomend', eventData));
@@ -1528,25 +1561,19 @@ class Camera extends Evented {
15281561
const tr = this.transform,
15291562
startZoom = this.getZoom(),
15301563
startBearing = this.getBearing(),
1531-
startPitch = this.getPitch();
1564+
startPitch = this.getPitch(),
1565+
startPadding = this.getPadding();
15321566

15331567
const zoom = 'zoom' in options ? clamp(+options.zoom, tr.minZoom, tr.maxZoom) : startZoom;
15341568
const bearing = 'bearing' in options ? this._normalizeBearing(options.bearing, startBearing) : startBearing;
15351569
const pitch = 'pitch' in options ? +options.pitch : startPitch;
1570+
const padding = this._extendPadding(options.padding);
15361571

15371572
const scale = tr.zoomScale(zoom - startZoom);
15381573
const offsetAsPoint = Point.convert(options.offset);
1539-
const pointAtOffset = tr.centerPoint.add(offsetAsPoint);
1574+
let pointAtOffset = tr.centerPoint.add(offsetAsPoint);
15401575
const locationAtOffset = tr.pointLocation(pointAtOffset);
1541-
1542-
let center = options.center;
1543-
// Calculate center with respect to padding
1544-
if (center && options.padding) {
1545-
const easingOptions = this._cameraForBounds(this.transform, center, center, bearing, pitch, options);
1546-
if (easingOptions) center = easingOptions.center;
1547-
}
1548-
1549-
center = LngLat.convert(center || locationAtOffset);
1576+
const center = LngLat.convert(options.center || locationAtOffset);
15501577
this._normalizeCenter(center);
15511578

15521579
const from = tr.project(locationAtOffset);
@@ -1632,6 +1659,7 @@ class Camera extends Evented {
16321659
const zoomChanged = true;
16331660
const bearingChanged = (startBearing !== bearing);
16341661
const pitchChanged = (pitch !== startPitch);
1662+
const paddingChanged = !tr.isPaddingEqual(padding);
16351663

16361664
const frame = (tr: Transform) => (k: number) => {
16371665
// s: The distance traveled along the flight path, measured in ρ-screenfuls.
@@ -1645,6 +1673,12 @@ class Camera extends Evented {
16451673
if (pitchChanged) {
16461674
tr.pitch = interpolate(startPitch, pitch, k);
16471675
}
1676+
if (paddingChanged) {
1677+
tr.interpolatePadding(startPadding, padding, k);
1678+
// When padding is being applied, Transform#centerPoint is changing continuously,
1679+
// thus we need to recalculate offsetPoint every frame
1680+
pointAtOffset = tr.centerPoint.add(offsetAsPoint);
1681+
}
16481682

16491683
const newCenter = k === 1 ? center : tr.unproject(from.add(delta.mult(u(s))).mult(scale));
16501684
tr.setLocationAtPoint(tr.renderWorldCopies ? newCenter.wrap() : newCenter, pointAtOffset);
@@ -1666,6 +1700,7 @@ class Camera extends Evented {
16661700
this._zooming = zoomChanged;
16671701
this._rotating = bearingChanged;
16681702
this._pitching = pitchChanged;
1703+
this._padding = paddingChanged;
16691704

16701705
this._prepareEase(eventData, false);
16711706
this._ease(frame(tr), () => this._afterEase(eventData), options);

test/unit/ui/camera.test.js

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -775,15 +775,15 @@ describe('camera', () => {
775775
const bb = [[-133, 16], [-68, 50]];
776776

777777
const transform = camera.cameraForBounds(bb, {padding: {top: 10, right: 75, bottom: 50, left: 25}, duration: 0});
778-
expect(fixedLngLat(transform.center, 4)).toEqual({lng: -96.5558, lat: 32.0833});
778+
expect(fixedLngLat(transform.center, 4)).toEqual({lng: -100.5, lat: 34.7171});
779779
});
780780

781781
test('bearing and asymmetrical padding', () => {
782782
const camera = createCamera();
783783
const bb = [[-133, 16], [-68, 50]];
784784

785785
const transform = camera.cameraForBounds(bb, {bearing: 90, padding: {top: 10, right: 75, bottom: 50, left: 25}, duration: 0});
786-
expect(fixedLngLat(transform.center, 4)).toEqual({lng: -103.3761, lat: 31.7099});
786+
expect(fixedLngLat(transform.center, 4)).toEqual({lng: -100.5, lat: 34.7171});
787787
});
788788

789789
test(
@@ -794,7 +794,7 @@ describe('camera', () => {
794794
const bb = [[-133, 16], [-68, 50]];
795795

796796
const transform = camera.cameraForBounds(bb, {bearing: 90, padding: {top: 10, right: 75, bottom: 50, left: 25}, duration: 0});
797-
expect(fixedLngLat(transform.center, 4)).toEqual({lng: -104.1932, lat: 30.837});
797+
expect(fixedLngLat(transform.center, 4)).toEqual({lng: -100.5, lat: 34.7171});
798798
}
799799
);
800800

@@ -819,15 +819,27 @@ describe('camera', () => {
819819
const bb = [[-133, 16], [-68, 50]];
820820

821821
const transform = camera.cameraForBounds(bb, {padding: {top: 10, right: 75, bottom: 50, left: 25}, offset: [0, 100]});
822-
expect(fixedLngLat(transform.center, 4)).toEqual({lng: -96.5558, lat: 44.4189});
822+
expect(fixedLngLat(transform.center, 4)).toEqual({lng: -100.5, lat: 46.6292});
823823
});
824824

825825
test('bearing, asymmetrical padding, and offset', () => {
826826
const camera = createCamera();
827827
const bb = [[-133, 16], [-68, 50]];
828828

829829
const transform = camera.cameraForBounds(bb, {bearing: 90, padding: {top: 10, right: 75, bottom: 50, left: 25}, offset: [0, 100], duration: 0});
830-
expect(fixedLngLat(transform.center, 4)).toEqual({lng: -103.3761, lat: 43.0929});
830+
expect(fixedLngLat(transform.center, 4)).toEqual({lng: -100.5, lat: 45.6619});
831+
});
832+
833+
test('unable to fit', () => {
834+
const camera = createCamera();
835+
const bb = [[-180, 10], [180, 50]];
836+
837+
vi.spyOn(console, 'warn').mockImplementation(() => {});
838+
const transform = camera.cameraForBounds(bb, {padding: 1000});
839+
expect(transform).toEqual(undefined);
840+
841+
expect(console.warn).toHaveBeenCalledTimes(1);
842+
expect(console.warn.mock.calls[0][0]).toMatch(/Map cannot fit/);
831843
});
832844
});
833845

@@ -954,36 +966,24 @@ describe('camera', () => {
954966
const bb = [[-133, 16], [-68, 50]];
955967

956968
camera.fitBounds(bb, {padding: {top: 10, right: 75, bottom: 50, left: 25}, duration:0});
957-
expect(fixedLngLat(camera.getCenter(), 4)).toEqual({lng: -96.5558, lat: 32.0833});
969+
expect(fixedLngLat(camera.getCenter(), 4)).toEqual({lng: -100.5, lat: 34.7171});
958970
});
959971

960972
test('padding object with pitch', () => {
961973
const camera = createCamera();
962974
const bb = [[-133, 16], [-68, 50]];
963975

964976
camera.fitBounds(bb, {padding: {top: 10, right: 75, bottom: 50, left: 25}, duration:0, pitch: 30});
965-
expect(fixedLngLat(camera.getCenter(), 4)).toEqual({lng: -96.5558, lat: 32.4408});
977+
expect(fixedLngLat(camera.getCenter(), 4)).toEqual({lng: -100.5, lat: 34.7171});
966978
expect(camera.getPitch()).toEqual(30);
967979
});
968980

969-
test('padding does not get propagated to transform.padding', () => {
981+
test('padding is propagated to the transform.padding', () => {
970982
const camera = createCamera();
971983
const bb = [[-133, 16], [-68, 50]];
972984

973985
camera.fitBounds(bb, {padding: {top: 10, right: 75, bottom: 50, left: 25}, duration:0});
974-
expect(camera.transform.padding).toEqual({
975-
left: 0,
976-
right: 0,
977-
top: 0,
978-
bottom: 0
979-
});
980-
camera.flyTo({center: [0, 0], zoom: 10, padding: {top: 10, right: 75, bottom: 50, left: 25}, animate: false});
981-
expect(camera.transform.padding).toStrictEqual({
982-
left: 0,
983-
right: 0,
984-
top: 0,
985-
bottom: 0
986-
});
986+
expect(camera.transform.padding).toEqual({top: 10, right: 75, bottom: 50, left: 25});
987987
});
988988

989989
test('#12450', () => {

0 commit comments

Comments
 (0)