UNPKG

maplibre-gl

Version:

BSD licensed community fork of mapbox-gl, a WebGL interactive maps library

244 lines (210 loc) • 9.75 kB
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 }; }