Skip to content

Commit 203aa8e

Browse files
committed
CameraHelpers: implement cameraForBounds
1 parent 3c50b0f commit 203aa8e

File tree

4 files changed

+231
-198
lines changed

4 files changed

+231
-198
lines changed

src/geo/projection/camera_helper.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import Point from '@mapbox/point-geometry';
22
import {IReadonlyTransform, ITransform} from '../transform_interface';
33
import {LngLat} from '../lng_lat';
4+
import {CameraForBoundsOptions} from '../../ui/camera';
5+
import {PaddingOptions} from '../edge_insets';
6+
import {LngLatBounds} from '../lng_lat_bounds';
7+
import {warnOnce} from '../../util/util';
48

59
export type MapControlsDeltas = {
610
panDelta: Point;
@@ -10,6 +14,15 @@ export type MapControlsDeltas = {
1014
around: Point;
1115
}
1216

17+
/**
18+
* @internal
19+
*/
20+
export function cameraBoundsWarning() {
21+
warnOnce(
22+
'Map cannot fit within canvas with the given bounds, padding, and/or offset.'
23+
);
24+
}
25+
1326
/**
1427
* @internal
1528
* Contains projection-specific functions related to camera controls, easeTo, flyTo, inertia, etc.
@@ -25,4 +38,10 @@ export interface ICameraHelper {
2538
handleMapControlsPitchBearingZoom(deltas: MapControlsDeltas, tr: ITransform): void;
2639

2740
handleMapControlsPan(deltas: MapControlsDeltas, tr: ITransform, preZoomAroundLoc: LngLat): void;
41+
42+
cameraForBoxAndBearing(options: CameraForBoundsOptions, padding: PaddingOptions, bounds: LngLatBounds, bearing: number, tr: IReadonlyTransform): {
43+
center: LngLat;
44+
zoom: number;
45+
bearing: number;
46+
};
2847
}

src/geo/projection/globe_camera_helper.ts

Lines changed: 134 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import Point from '@mapbox/point-geometry';
22
import {IReadonlyTransform, ITransform} from '../transform_interface';
3-
import {ICameraHelper, MapControlsDeltas} from './camera_helper';
3+
import {cameraBoundsWarning, ICameraHelper, MapControlsDeltas} from './camera_helper';
44
import {GlobeProjection} from './globe';
55
import {LngLat} from '../lng_lat';
66
import {MercatorCameraHelper} from './mercator_camera_helper';
7-
import {computeGlobePanCenter, getGlobeRadiusPixels, getZoomAdjustment} from './globe_utils';
7+
import {angularCoordinatesToSurfaceVector, computeGlobePanCenter, getGlobeRadiusPixels, getZoomAdjustment} from './globe_utils';
88
import {clamp, createVec3f64, differenceOfAnglesDegrees, remapSaturate} from '../../util/util';
9-
import {vec3} from 'gl-matrix';
10-
import {MAX_VALID_LATITUDE, zoomScale} from '../transform_helper';
9+
import {mat4, vec3} from 'gl-matrix';
10+
import {MAX_VALID_LATITUDE, scaleZoom, zoomScale} from '../transform_helper';
11+
import {CameraForBoundsOptions} from '../../ui/camera';
12+
import {LngLatBounds} from '../lng_lat_bounds';
13+
import {PaddingOptions} from '../edge_insets';
1114

1215
/**
1316
* @internal
@@ -154,4 +157,131 @@ export class GlobeCameraHelper implements ICameraHelper {
154157
// Setting the center might adjust zoom to keep globe size constant, we need to avoid adding this adjustment a second time
155158
tr.setZoom(oldZoom + getZoomAdjustment(oldLat, tr.center.lat));
156159
}
160+
161+
cameraForBoxAndBearing(options: CameraForBoundsOptions, padding: PaddingOptions, bounds: LngLatBounds, bearing: number, tr: ITransform) {
162+
const result = this._mercatorCameraHelper.cameraForBoxAndBearing(options, padding, bounds, bearing, tr);
163+
164+
if (!this.useGlobeControls) {
165+
return result;
166+
}
167+
168+
// If globe is enabled, we use the parameters computed for mercator, and just update the zoom to fit the bounds.
169+
170+
// Get clip space bounds including padding
171+
const xLeft = (padding.left) / tr.width * 2.0 - 1.0;
172+
const xRight = (tr.width - padding.right) / tr.width * 2.0 - 1.0;
173+
const yTop = (padding.top) / tr.height * -2.0 + 1.0;
174+
const yBottom = (tr.height - padding.bottom) / tr.height * -2.0 + 1.0;
175+
176+
// Get camera bounds
177+
const flipEastWest = differenceOfAnglesDegrees(bounds.getWest(), bounds.getEast()) < 0;
178+
const lngWest = flipEastWest ? bounds.getEast() : bounds.getWest();
179+
const lngEast = flipEastWest ? bounds.getWest() : bounds.getEast();
180+
181+
const latNorth = Math.max(bounds.getNorth(), bounds.getSouth()); // "getNorth" doesn't always return north...
182+
const latSouth = Math.min(bounds.getNorth(), bounds.getSouth());
183+
184+
// Additional vectors will be tested for the rectangle midpoints
185+
const lngMid = lngWest + differenceOfAnglesDegrees(lngWest, lngEast) * 0.5;
186+
const latMid = latNorth + differenceOfAnglesDegrees(latNorth, latSouth) * 0.5;
187+
188+
// Obtain a globe projection matrix that does not include pitch (unsupported)
189+
const clonedTr = tr.clone();
190+
clonedTr.setCenter(result.center);
191+
clonedTr.setBearing(result.bearing);
192+
clonedTr.setPitch(0);
193+
clonedTr.setZoom(result.zoom);
194+
const matrix = clonedTr.modelViewProjectionMatrix;
195+
196+
// Vectors to test - the bounds' corners and edge midpoints
197+
const testVectors = [
198+
angularCoordinatesToSurfaceVector(bounds.getNorthWest()),
199+
angularCoordinatesToSurfaceVector(bounds.getNorthEast()),
200+
angularCoordinatesToSurfaceVector(bounds.getSouthWest()),
201+
angularCoordinatesToSurfaceVector(bounds.getSouthEast()),
202+
// Also test edge midpoints
203+
angularCoordinatesToSurfaceVector(new LngLat(lngEast, latMid)),
204+
angularCoordinatesToSurfaceVector(new LngLat(lngWest, latMid)),
205+
angularCoordinatesToSurfaceVector(new LngLat(lngMid, latNorth)),
206+
angularCoordinatesToSurfaceVector(new LngLat(lngMid, latSouth))
207+
];
208+
const vecToCenter = angularCoordinatesToSurfaceVector(result.center);
209+
210+
// Test each vector, measure how much to scale down the globe to satisfy all tested points that they are inside clip space.
211+
let smallestNeededScale = Number.POSITIVE_INFINITY;
212+
for (const vec of testVectors) {
213+
if (xLeft < 0)
214+
smallestNeededScale = GlobeCameraHelper.getLesserNonNegativeNonNull(smallestNeededScale, GlobeCameraHelper.solveVectorScale(vec, vecToCenter, matrix, 'x', xLeft));
215+
if (xRight > 0)
216+
smallestNeededScale = GlobeCameraHelper.getLesserNonNegativeNonNull(smallestNeededScale, GlobeCameraHelper.solveVectorScale(vec, vecToCenter, matrix, 'x', xRight));
217+
if (yTop > 0)
218+
smallestNeededScale = GlobeCameraHelper.getLesserNonNegativeNonNull(smallestNeededScale, GlobeCameraHelper.solveVectorScale(vec, vecToCenter, matrix, 'y', yTop));
219+
if (yBottom < 0)
220+
smallestNeededScale = GlobeCameraHelper.getLesserNonNegativeNonNull(smallestNeededScale, GlobeCameraHelper.solveVectorScale(vec, vecToCenter, matrix, 'y', yBottom));
221+
}
222+
223+
if (!Number.isFinite(smallestNeededScale) || smallestNeededScale === 0) {
224+
cameraBoundsWarning();
225+
return undefined;
226+
}
227+
228+
// Compute target zoom from the obtained scale.
229+
result.zoom = clonedTr.zoom + scaleZoom(smallestNeededScale);
230+
}
231+
232+
/**
233+
* Computes how much to scale the globe in order for a given point on its surface (a location) to project to a given clip space coordinate in either the X or the Y axis.
234+
* @param vector - Position of the queried location on the surface of the unit sphere globe.
235+
* @param toCenter - Position of current transform center on the surface of the unit sphere globe.
236+
* This is needed because zooming the globe not only changes its scale,
237+
* but also moves the camera closer or further away along this vector (pitch is disregarded).
238+
* @param projection - The globe projection matrix.
239+
* @param targetDimension - The dimension in which the scaled vector must match the target value in clip space.
240+
* @param targetValue - The target clip space value in the specified dimension to which the queried vector must project.
241+
* @returns How much to scale the globe.
242+
*/
243+
private static solveVectorScale(vector: vec3, toCenter: vec3, projection: mat4, targetDimension: 'x' | 'y', targetValue: number): number | null {
244+
// We want to compute how much to scale the sphere in order for the input `vector` to project to `targetValue` in the given `targetDimension` (X or Y).
245+
const k = targetValue;
246+
const columnXorY = targetDimension === 'x' ?
247+
[projection[0], projection[4], projection[8], projection[12]] : // X
248+
[projection[1], projection[5], projection[9], projection[13]]; // Y
249+
const columnZ = [projection[3], projection[7], projection[11], projection[15]];
250+
251+
const vecDotXY = vector[0] * columnXorY[0] + vector[1] * columnXorY[1] + vector[2] * columnXorY[2];
252+
const vecDotZ = vector[0] * columnZ[0] + vector[1] * columnZ[1] + vector[2] * columnZ[2];
253+
const toCenterDotXY = toCenter[0] * columnXorY[0] + toCenter[1] * columnXorY[1] + toCenter[2] * columnXorY[2];
254+
const toCenterDotZ = toCenter[0] * columnZ[0] + toCenter[1] * columnZ[1] + toCenter[2] * columnZ[2];
255+
256+
// The following can be derived from writing down what happens to a vector scaled by a parameter ("V * t") when it is multiplied by a projection matrix, then solving for "t".
257+
// Or rather, we derive it for a vector "V * t + (1-t) * C". Where V is `vector` and C is `toCenter`. The extra addition is needed because zooming out also moves the camera along "C".
258+
259+
const t = (toCenterDotXY + columnXorY[3] - k * toCenterDotZ - k * columnZ[3]) / (toCenterDotXY - vecDotXY - k * toCenterDotZ + k * vecDotZ);
260+
261+
if (
262+
toCenterDotXY + k * vecDotZ === vecDotXY + k * toCenterDotZ ||
263+
columnZ[3] * (vecDotXY - toCenterDotXY) + columnXorY[3] * (toCenterDotZ - vecDotZ) + vecDotXY * toCenterDotZ === toCenterDotXY * vecDotZ
264+
) {
265+
// The computed result is invalid.
266+
return null;
267+
}
268+
return t;
269+
}
270+
271+
/**
272+
* Returns `newValue` if it is:
273+
*
274+
* - not null AND
275+
* - not negative AND
276+
* - smaller than `newValue`,
277+
*
278+
* ...otherwise returns `oldValue`.
279+
*/
280+
private static getLesserNonNegativeNonNull(oldValue: number, newValue: number): number {
281+
if (newValue !== null && newValue >= 0 && newValue < oldValue) {
282+
return newValue;
283+
} else {
284+
return oldValue;
285+
}
286+
}
157287
}

src/geo/projection/mercator_camera_helper.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import Point from '@mapbox/point-geometry';
22
import {LngLat} from '../lng_lat';
33
import {IReadonlyTransform, ITransform} from '../transform_interface';
4-
import {ICameraHelper, MapControlsDeltas} from './camera_helper';
4+
import {cameraBoundsWarning, ICameraHelper, MapControlsDeltas} from './camera_helper';
5+
import {CameraForBoundsOptions} from '../../ui/camera';
6+
import {PaddingOptions} from '../edge_insets';
7+
import {LngLatBounds} from '../lng_lat_bounds';
8+
import {scaleZoom, zoomScale} from '../transform_helper';
9+
import {degreesToRadians} from '../../util/util';
10+
import {projectToWorldCoordinates, unprojectFromWorldCoordinates} from './mercator_utils';
511

612
/**
713
* @internal
@@ -28,4 +34,71 @@ export class MercatorCameraHelper implements ICameraHelper {
2834
handleMapControlsPan(deltas: MapControlsDeltas, tr: ITransform, preZoomAroundLoc: LngLat): void {
2935
tr.setLocationAtPoint(preZoomAroundLoc, deltas.around);
3036
}
37+
38+
cameraForBoxAndBearing(options: CameraForBoundsOptions, padding: PaddingOptions, bounds: LngLatBounds, bearing: number, tr: IReadonlyTransform) {
39+
const edgePadding = tr.padding;
40+
41+
// Consider all corners of the rotated bounding box derived from the given points
42+
// when find the camera position that fits the given points.
43+
44+
const nwWorld = projectToWorldCoordinates(tr.worldSize, bounds.getNorthWest());
45+
const neWorld = projectToWorldCoordinates(tr.worldSize, bounds.getNorthEast());
46+
const seWorld = projectToWorldCoordinates(tr.worldSize, bounds.getSouthEast());
47+
const swWorld = projectToWorldCoordinates(tr.worldSize, bounds.getSouthWest());
48+
49+
const bearingRadians = degreesToRadians(-bearing);
50+
51+
const nwRotatedWorld = nwWorld.rotate(bearingRadians);
52+
const neRotatedWorld = neWorld.rotate(bearingRadians);
53+
const seRotatedWorld = seWorld.rotate(bearingRadians);
54+
const swRotatedWorld = swWorld.rotate(bearingRadians);
55+
56+
const upperRight = new Point(
57+
Math.max(nwRotatedWorld.x, neRotatedWorld.x, swRotatedWorld.x, seRotatedWorld.x),
58+
Math.max(nwRotatedWorld.y, neRotatedWorld.y, swRotatedWorld.y, seRotatedWorld.y)
59+
);
60+
61+
const lowerLeft = new Point(
62+
Math.min(nwRotatedWorld.x, neRotatedWorld.x, swRotatedWorld.x, seRotatedWorld.x),
63+
Math.min(nwRotatedWorld.y, neRotatedWorld.y, swRotatedWorld.y, seRotatedWorld.y)
64+
);
65+
66+
// Calculate zoom: consider the original bbox and padding.
67+
const size = upperRight.sub(lowerLeft);
68+
69+
const availableWidth = (tr.width - (edgePadding.left + edgePadding.right + padding.left + padding.right));
70+
const availableHeight = (tr.height - (edgePadding.top + edgePadding.bottom + padding.top + padding.bottom));
71+
const scaleX = availableWidth / size.x;
72+
const scaleY = availableHeight / size.y;
73+
74+
if (scaleY < 0 || scaleX < 0) {
75+
cameraBoundsWarning();
76+
return undefined;
77+
}
78+
79+
const zoom = Math.min(scaleZoom(tr.scale * Math.min(scaleX, scaleY)), options.maxZoom);
80+
81+
// Calculate center: apply the zoom, the configured offset, as well as offset that exists as a result of padding.
82+
const offset = Point.convert(options.offset);
83+
const paddingOffsetX = (padding.left - padding.right) / 2;
84+
const paddingOffsetY = (padding.top - padding.bottom) / 2;
85+
const paddingOffset = new Point(paddingOffsetX, paddingOffsetY);
86+
const rotatedPaddingOffset = paddingOffset.rotate(degreesToRadians(bearing));
87+
const offsetAtInitialZoom = offset.add(rotatedPaddingOffset);
88+
const offsetAtFinalZoom = offsetAtInitialZoom.mult(tr.scale / zoomScale(zoom));
89+
90+
const center = unprojectFromWorldCoordinates(
91+
tr.worldSize,
92+
// either world diagonal can be used (NW-SE or NE-SW)
93+
nwWorld.add(seWorld).div(2).sub(offsetAtFinalZoom)
94+
);
95+
96+
const result = {
97+
center,
98+
zoom,
99+
bearing
100+
};
101+
102+
return result;
103+
}
31104
}

0 commit comments

Comments
 (0)