maplibre-gl
Version:
BSD licensed community fork of mapbox-gl, a WebGL interactive maps library
244 lines (210 loc) • 9.75 kB
text/typescript
import Point from '@mapbox/point-geometry';
import {clamp, degreesToRadians, getRollPitchBearing, MAX_VALID_LATITUDE, type RollPitchBearing, rollPitchBearingToQuat, scaleZoom, warnOnce, zoomScale} from '../../util/util.ts';
import {quat} from 'gl-matrix';
import {interpolates} from '@maplibre/maplibre-gl-style-spec';
import {lngLatBoxToWorldSamples, projectToWorldCoordinates, unprojectFromWorldCoordinates} from './mercator_utils.ts';
import type {IReadonlyTransform, ITransform} from '../transform_interface.ts';
import type {LngLat, LngLatLike} from '../lng_lat.ts';
import type {CameraForBoundsOptions, PointLike} from '../../ui/camera.ts';
import type {PaddingOptions} from '../edge_insets.ts';
import type {LngLatBounds} from '../lng_lat_bounds.ts';
export type MapControlsDeltas = {
panDelta: Point;
zoomDelta: number;
bearingDelta: number;
pitchDelta: number;
rollDelta: number;
around: Point;
/**
* Elevation in meters of the terrain under `around` at gesture start; when set,
* pan and zoom keep the terrain at this elevation under `around`.
*/
aroundElevation?: number;
};
export type CameraForBoxAndBearingHandlerResult = {
center: LngLat;
zoom: number;
bearing: number;
};
export type EaseToHandlerOptions = {
bearing: number;
pitch: number;
roll: number;
padding: PaddingOptions;
offsetAsPoint: Point;
around?: LngLat;
aroundPoint?: Point;
center?: LngLatLike;
zoom?: number;
offset?: PointLike;
};
export type EaseToHandlerResult = {
easeFunc: (k: number) => void;
/**
* The map center when the animation ends.
*/
elevationCenter: LngLat;
isZooming: boolean;
};
export type FlyToHandlerOptions = {
bearing: number;
pitch: number;
roll: number;
padding: PaddingOptions;
offsetAsPoint: Point;
center?: LngLatLike;
locationAtOffset: LngLat;
zoom?: number;
minZoom?: number;
};
export type FlyToHandlerResult = {
easeFunc: (k: number, scale: number, centerFactor: number, pointAtOffset: Point) => void;
scaleOfZoom: number;
scaleOfMinZoom: number;
/**
* The map center when the animation ends.
*/
targetCenter: LngLat;
pixelPathLength: number;
};
export type UpdateRotationArgs = {
/**
* The starting Euler angles.
*/
startEulerAngles: RollPitchBearing;
/**
* The end Euler angles.
*/
endEulerAngles: RollPitchBearing;
/**
* The transform to be updated
*/
tr: ITransform;
/**
* The interpolation fraction, between 0 and 1.
*/
k: number;
/**
* If true, use spherical linear interpolation. If false, use linear interpolation of Euler angles.
*/
useSlerp: boolean;
};
/**
* @internal
*/
export function cameraBoundsWarning(): void {
warnOnce(
'Map cannot fit within canvas with the given bounds, padding, and/or offset.'
);
}
/**
* @internal
* Contains projection-specific functions related to camera controls, easeTo, flyTo, inertia, etc.
*/
export interface ICameraHelper {
get useGlobeControls(): boolean;
handlePanInertia(pan: Point, transform: IReadonlyTransform): {
easingCenter: LngLat;
easingOffset: Point;
};
handleMapControlsRollPitchBearingZoom(deltas: MapControlsDeltas, tr: ITransform): void;
handleMapControlsPan(deltas: MapControlsDeltas, tr: ITransform, preZoomAroundLoc: LngLat): void;
/**
* @param fitPadding - The `padding` option of `cameraForBounds`: the space wanted around the bounds.
* @param mapPadding - The map's own padding, as in `map.getPadding()`, which the bounds must also stay clear of.
*/
cameraForBoxAndBearing(options: CameraForBoundsOptions, fitPadding: PaddingOptions, mapPadding: PaddingOptions, bounds: LngLatBounds, bearing: number, tr: IReadonlyTransform): CameraForBoxAndBearingHandlerResult;
handleJumpToCenterZoom(tr: ITransform, options: { zoom?: number; center?: LngLatLike }): void;
handleEaseTo(tr: ITransform, options: EaseToHandlerOptions): EaseToHandlerResult;
handleFlyTo(tr: ITransform, options: FlyToHandlerOptions): FlyToHandlerResult;
}
/**
* @internal
* Set a transform's rotation to a value interpolated between startEulerAngles and endEulerAngles
*/
export function updateRotation(args: UpdateRotationArgs): void {
if (args.useSlerp) {
// At pitch ==0, the Euler angle representation is ambiguous. In this case, set the Euler angles
// to the representation requested by the caller
if (args.k < 1) {
const startRotation = rollPitchBearingToQuat(args.startEulerAngles.roll, args.startEulerAngles.pitch, args.startEulerAngles.bearing);
const endRotation = rollPitchBearingToQuat(args.endEulerAngles.roll, args.endEulerAngles.pitch, args.endEulerAngles.bearing);
const rotation: quat = new Float64Array(4);
quat.slerp(rotation, startRotation, endRotation, args.k);
const eulerAngles = getRollPitchBearing(rotation);
args.tr.setRoll(eulerAngles.roll);
args.tr.setPitch(eulerAngles.pitch);
args.tr.setBearing(eulerAngles.bearing);
} else {
args.tr.setRoll(args.endEulerAngles.roll);
args.tr.setPitch(args.endEulerAngles.pitch);
args.tr.setBearing(args.endEulerAngles.bearing);
}
} else {
args.tr.setRoll(interpolates.number(args.startEulerAngles.roll, args.endEulerAngles.roll, args.k));
args.tr.setPitch(interpolates.number(args.startEulerAngles.pitch, args.endEulerAngles.pitch, args.k));
args.tr.setBearing(interpolates.number(args.startEulerAngles.bearing, args.endEulerAngles.bearing, args.k));
}
}
export function cameraForBoxAndBearing(options: CameraForBoundsOptions, fitPadding: PaddingOptions, mapPadding: PaddingOptions, bounds: LngLatBounds, bearing: number, tr: IReadonlyTransform): CameraForBoxAndBearingHandlerResult {
// Fit the whole box, not only its corners: rotate the points sampled over it by the bearing and take
// the rectangle around them in the rotated frame, grown by how far the edges bulge past the points.
const worldCoordinateHelper = tr.worldCoordinateHelper;
const nwWorld = projectToWorldCoordinates(tr.worldSize, bounds.getNorthWest(), worldCoordinateHelper);
const neWorld = projectToWorldCoordinates(tr.worldSize, bounds.getNorthEast(), worldCoordinateHelper);
const seWorld = projectToWorldCoordinates(tr.worldSize, bounds.getSouthEast(), worldCoordinateHelper);
const swWorld = projectToWorldCoordinates(tr.worldSize, bounds.getSouthWest(), worldCoordinateHelper);
const [south, north] = worldCoordinateHelper.wraps ?
[clamp(bounds.getSouth(), -MAX_VALID_LATITUDE, MAX_VALID_LATITUDE), clamp(bounds.getNorth(), -MAX_VALID_LATITUDE, MAX_VALID_LATITUDE)] :
[bounds.getSouth(), bounds.getNorth()];
const {points, bulge} = lngLatBoxToWorldSamples(worldCoordinateHelper, bounds.getWest(), south, bounds.getEast(), north);
const bearingRadians = degreesToRadians(-bearing);
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const point of points) {
const {x, y} = point.mult(tr.worldSize).rotate(bearingRadians);
if (x < minX) minX = x;
if (y < minY) minY = y;
if (x > maxX) maxX = x;
if (y > maxY) maxY = y;
}
const worldBulge = bulge * tr.worldSize;
const upperRight = new Point(maxX + worldBulge, maxY + worldBulge);
const lowerLeft = new Point(minX - worldBulge, minY - worldBulge);
// Calculate zoom: consider the original bbox and both paddings.
const size = upperRight.sub(lowerLeft);
const availableWidth = (tr.width - (mapPadding.left + mapPadding.right + fitPadding.left + fitPadding.right));
const availableHeight = (tr.height - (mapPadding.top + mapPadding.bottom + fitPadding.top + fitPadding.bottom));
const scaleX = availableWidth / size.x;
const scaleY = availableHeight / size.y;
// A box reaching a point the projection sends to infinity has no finite size to fit.
if (scaleY < 0 || scaleX < 0 || !Number.isFinite(size.x) || !Number.isFinite(size.y)) {
cameraBoundsWarning();
return undefined;
}
const zoom = Math.min(scaleZoom(tr.scale * Math.min(scaleX, scaleY)), options.maxZoom);
// Calculate center: apply the zoom, the configured offset, as well as offset that exists as a result of the fit padding.
const offset = Point.convert(options.offset);
const paddingOffsetX = (fitPadding.left - fitPadding.right) / 2;
const paddingOffsetY = (fitPadding.top - fitPadding.bottom) / 2;
const paddingOffset = new Point(paddingOffsetX, paddingOffsetY);
const rotatedPaddingOffset = paddingOffset.rotate(degreesToRadians(bearing));
const offsetAtInitialZoom = offset.add(rotatedPaddingOffset);
const offsetAtFinalZoom = offsetAtInitialZoom.mult(tr.scale / zoomScale(zoom));
// The middle of the fitted rectangle, rotated back. For a box that projects to a parallelogram, as every box
// does in mercator, that is the midpoint of its diagonal, taken directly to keep rotation rounding out of it.
const projectsToParallelogram = bulge === 0 && nwWorld.add(seWorld).equals(neWorld.add(swWorld));
const middle = projectsToParallelogram ? nwWorld.add(seWorld).div(2) : upperRight.add(lowerLeft).div(2).rotate(-bearingRadians);
const center = unprojectFromWorldCoordinates(
tr.worldSize,
middle.sub(offsetAtFinalZoom),
worldCoordinateHelper
);
return {
center,
zoom,
bearing
};
}