Skip to content

Commit

Permalink
fix incorrect bounds with fitBounds when crossing antimeridian (mapli…
Browse files Browse the repository at this point in the history
…bre#4620)

* added failing test for antimeridian fitbounds

* fixed by adding _adjustAntiMeridian internal function for bounds that cross the antimeridian

* moved adjustAntiMeridian method to LngLatBounds

* added lots of tests

* moved camera tests to lng_lat_bounds.test and fixed linting problems

* updated changlog and added spec to fitBounds and cameraForBounds api

* moved changelog to bug fixes
  • Loading branch information
YoelRidgway authored Aug 30, 2024
1 parent 33a0796 commit 24600d7
Show file tree
Hide file tree
Showing 5 changed files with 172 additions and 1 deletion.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ using `transformCameraUpdate` caused the `maxBounds` to stop working just for ea
- Fix Map#off to not remove listener with layer(s) registered with Map#once ([#4592](https://github.com/maplibre/maplibre-gl-js/pull/4592))
- Improve types a bit for `addSource` and `getSource` ([#4616](https://github.com/maplibre/maplibre-gl-js/pull/4616))
- Fix the color near the horizon when terrain is enabled without any sky ([#4607](https://github.com/maplibre/maplibre-gl-js/pull/4607))
- Fix bug where `fitBounds` and `cameraForBounds` would not display accross the 180th meridian (antimeridian)
- _...Add new stuff here..._

## 4.6.0
Expand Down
122 changes: 122 additions & 0 deletions src/geo/lng_lat_bounds.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,128 @@ describe('LngLatBounds', () => {
expect(center1Radius0.toArray()).toEqual([[-73.9749, 40.7736], [-73.9749, 40.7736]]);
});

describe('LngLatBounds adjustAntiMeridian tests', () => {
test('kenya', () => {
const sw = new LngLat(32.958984, -5.353521);
const ne = new LngLat(43.50585, 5.615985);
const bounds = new LngLatBounds(sw, ne).adjustAntiMeridian();
expect(bounds.getSouth()).toBe(-5.353521);
expect(bounds.getWest()).toBe(32.958984);
expect(bounds.getNorth()).toBe(5.615985);
expect(bounds.getEast()).toBe(43.50585);
});

test('normal cross (crossing antimeridian)', () => {
const sw = new LngLat(170, 0);
const ne = new LngLat(-170, 10);
const bounds = new LngLatBounds(sw, ne).adjustAntiMeridian();
expect(bounds.getSouth()).toBe(0);
expect(bounds.getWest()).toBe(170);
expect(bounds.getNorth()).toBe(10);
expect(bounds.getEast()).toBe(190);
});

test('exactly meridian (crossing antimeridian)', () => {
const sw = new LngLat(180, -20);
const ne = new LngLat(-180, 20);
const bounds = new LngLatBounds(sw, ne).adjustAntiMeridian();
expect(bounds.getSouth()).toBe(-20);
expect(bounds.getWest()).toBe(180);
expect(bounds.getNorth()).toBe(20);
expect(bounds.getEast()).toBe(180);
});

test('small cross (crossing antimeridian)', () => {
const sw = new LngLat(179, -5);
const ne = new LngLat(-179, 5);
const bounds = new LngLatBounds(sw, ne).adjustAntiMeridian();
expect(bounds.getSouth()).toBe(-5);
expect(bounds.getWest()).toBe(179);
expect(bounds.getNorth()).toBe(5);
expect(bounds.getEast()).toBe(181);
});

test('large cross (crossing antimeridian)', () => {
const sw = new LngLat(100, -30);
const ne = new LngLat(-100, 30);
const bounds = new LngLatBounds(sw, ne).adjustAntiMeridian();
expect(bounds.getSouth()).toBe(-30);
expect(bounds.getWest()).toBe(100);
expect(bounds.getNorth()).toBe(30);
expect(bounds.getEast()).toBe(260);
});

test('reverse cross (crossing antimeridian)', () => {
const sw = new LngLat(-170, 0);
const ne = new LngLat(170, 10);
const bounds = new LngLatBounds(sw, ne).adjustAntiMeridian();
expect(bounds.getSouth()).toBe(0);
expect(bounds.getWest()).toBe(-170);
expect(bounds.getNorth()).toBe(10);
expect(bounds.getEast()).toBe(170);
});

test('reverse not cross', () => {
const sw = new LngLat(150, 0);
const ne = new LngLat(170, 10);
const bounds = new LngLatBounds(sw, ne).adjustAntiMeridian();
expect(bounds.getSouth()).toBe(0);
expect(bounds.getWest()).toBe(150);
expect(bounds.getNorth()).toBe(10);
expect(bounds.getEast()).toBe(170);
});

test('same longitude', () => {
const sw = new LngLat(175, -10);
const ne = new LngLat(175, 10);
const bounds = new LngLatBounds(sw, ne).adjustAntiMeridian();
expect(bounds.getSouth()).toBe(-10);
expect(bounds.getWest()).toBe(175);
expect(bounds.getNorth()).toBe(10);
expect(bounds.getEast()).toBe(175);
});

test('full world', () => {
const sw = new LngLat(-180, -90);
const ne = new LngLat(180, 90);
const bounds = new LngLatBounds(sw, ne).adjustAntiMeridian();
expect(bounds.getSouth()).toBe(-90);
expect(bounds.getWest()).toBe(-180);
expect(bounds.getNorth()).toBe(90);
expect(bounds.getEast()).toBe(180);
});

test('across pole', () => {
const sw = new LngLat(0, 85);
const ne = new LngLat(-10, -85);
const bounds = new LngLatBounds(sw, ne).adjustAntiMeridian();
expect(bounds.getSouth()).toBe(85);
expect(bounds.getWest()).toBe(0);
expect(bounds.getNorth()).toBe(-85);
expect(bounds.getEast()).toBe(350);
});

test('across pole reverse', () => {
const sw = new LngLat(-10, -85);
const ne = new LngLat(0, 85);
const bounds = new LngLatBounds(sw, ne).adjustAntiMeridian();
expect(bounds.getSouth()).toBe(-85);
expect(bounds.getWest()).toBe(-10);
expect(bounds.getNorth()).toBe(85);
expect(bounds.getEast()).toBe(0);
});

test('across dateline', () => {
const sw = new LngLat(170, 0);
const ne = new LngLat(-170, 10);
const bounds = new LngLatBounds(sw, ne).adjustAntiMeridian();
expect(bounds.getSouth()).toBe(0);
expect(bounds.getWest()).toBe(170);
expect(bounds.getNorth()).toBe(10);
expect(bounds.getEast()).toBe(190);
});
});

describe('contains', () => {
describe('point', () => {
test('point is in bounds', () => {
Expand Down
26 changes: 26 additions & 0 deletions src/geo/lng_lat_bounds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,4 +327,30 @@ export class LngLatBounds {
return new LngLatBounds(new LngLat(center.lng - lngAccuracy, center.lat - latAccuracy),
new LngLat(center.lng + lngAccuracy, center.lat + latAccuracy));
}

/**
* Adjusts the given bounds to handle the case where the bounds cross the 180th meridian (antimeridian).
*
* @returns The adjusted LngLatBounds
* @example
* ```ts
* let bounds = new LngLatBounds([175.813127, -20.157768], [-178. 340903, -15.449124]);
* let adjustedBounds = bounds.adjustAntiMeridian();
* // adjustedBounds will be: [[175.813127, -20.157768], [181.659097, -15.449124]]
* ```
*/
adjustAntiMeridian(): LngLatBounds {
const sw = new LngLat(this._sw.lng, this._sw.lat);
const ne = new LngLat(this._ne.lng, this._ne.lat);

if (sw.lng > ne.lng) {
return new LngLatBounds(
sw,
new LngLat(ne.lng + 360, ne.lat)
);
}

return new LngLatBounds(sw, ne);
}

}
19 changes: 19 additions & 0 deletions src/ui/camera.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2099,6 +2099,25 @@ describe('#fitBounds', () => {
bottom: 0
});
});

test('fiji (crossing antimeridian)', () => {
const camera = createCamera();
const bb = [[175.813127, -20.157768], [-178.340903, -15.449124]];
camera.fitBounds(bb, {duration: 0});

expect(fixedLngLat(camera.getCenter(), 4)).toEqual({lng: 178.7361, lat: -17.819});
expect(fixedNum(camera.getZoom(), 3)).toBe(5.944);
});

test('not crossing antimeridian', () => {
const camera = createCamera();
const bb = [[-10, -10], [10, 10]];
camera.fitBounds(bb, {duration: 0});

expect(fixedLngLat(camera.getCenter(), 4)).toEqual({lng: 0, lat: 0});
expect(fixedNum(camera.getZoom(), 3)).toBe(4.163);
});

});

describe('#fitScreenCoordinates', () => {
Expand Down
5 changes: 4 additions & 1 deletion src/ui/camera.ts
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,7 @@ export abstract class Camera extends Evented {
* @param bounds - Calculate the center for these bounds in the viewport and use
* the highest zoom level up to and including `Map#getMaxZoom()` that fits
* in the viewport. LngLatBounds represent a box that is always axis-aligned with bearing 0.
* Bounds will be taken in [sw, ne] order. Southwest point will always be to the left of the northeast point.
* @param options - Options object
* @returns If map is able to fit to provided bounds, returns `center`, `zoom`, and `bearing`.
* If map is unable to fit, method will warn and return undefined.
Expand All @@ -626,8 +627,9 @@ export abstract class Camera extends Evented {
* ```
*/
cameraForBounds(bounds: LngLatBoundsLike, options?: CameraForBoundsOptions): CenterZoomBearing | undefined {
bounds = LngLatBounds.convert(bounds);
bounds = LngLatBounds.convert(bounds).adjustAntiMeridian();
const bearing = options && options.bearing || 0;

return this._cameraForBoxAndBearing(bounds.getNorthWest(), bounds.getSouthEast(), bearing, options);
}

Expand Down Expand Up @@ -747,6 +749,7 @@ export abstract class Camera extends Evented {
*
* @param bounds - Center these bounds in the viewport and use the highest
* zoom level up to and including `Map#getMaxZoom()` that fits them in the viewport.
* Bounds will be taken in [sw, ne] order. Southwest point will always be to the left of the northeast point.
* @param options - Options supports all properties from {@link AnimationOptions} and {@link CameraOptions} in addition to the fields below.
* @param eventData - Additional properties to be added to event objects of events triggered by this method.
* @example
Expand Down

0 comments on commit 24600d7

Please sign in to comment.