maplibre-gl
Version:
BSD licensed community fork of mapbox-gl, a WebGL interactive maps library
629 lines (556 loc) • 23.8 kB
text/typescript
import {LngLat, type LngLatLike} from './lng_lat';
import {LngLatBounds} from './lng_lat_bounds';
import Point from '@mapbox/point-geometry';
import {wrap, clamp, degreesToRadians, radiansToDegrees, zoomScale, MAX_VALID_LATITUDE, scaleZoom} from '../util/util';
import {mat4, mat2} from 'gl-matrix';
import {EdgeInsets} from './edge_insets';
import {altitudeFromMercatorZ, MercatorCoordinate, mercatorZfromAltitude} from './mercator_coordinate';
import {cameraMercatorCoordinateFromCenterAndRotation} from './projection/mercator_utils';
import {EXTENT} from '../data/extent';
import type {PaddingOptions} from './edge_insets';
import type {IReadonlyTransform, ITransformGetters} from './transform_interface';
import type {OverscaledTileID} from '../source/tile_id';
import {Bounds} from './bounds';
/**
* If a path crossing the antimeridian would be shorter, extend the final coordinate so that
* interpolating between the two endpoints will cross it.
* @param center - The LngLat object of the desired center. This object will be mutated.
*/
export function normalizeCenter(tr: IReadonlyTransform, center: LngLat): void {
if (!tr.renderWorldCopies || tr.lngRange) return;
const delta = center.lng - tr.center.lng;
center.lng +=
delta > 180 ? -360 :
delta < -180 ? 360 : 0;
}
export type UnwrappedTileIDType = {
/**
* Tile wrap: 0 for the "main" world,
* negative values for worlds left of the main,
* positive values for worlds right of the main.
*/
wrap?: number;
canonical: {
/**
* Tile X coordinate, in range 0..(z^2)-1
*/
x: number;
/**
* Tile Y coordinate, in range 0..(z^2)-1
*/
y: number;
/**
* Tile zoom level.
*/
z: number;
};
};
export type TransformHelperCallbacks = {
/**
* Get center lngLat and zoom to ensure that
* 1) everything beyond the bounds is excluded
* 2) a given lngLat is as near the center as possible
* Bounds are those set by maxBounds or North & South "Poles" and, if only 1 globe is displayed, antimeridian.
*/
getConstrained: (center: LngLat, zoom: number) => { center: LngLat; zoom: number };
/**
* Updates the underlying transform's internal matrices.
*/
calcMatrices: () => void;
};
function getTileZoom(zoom: number): number {
return Math.max(0, Math.floor(zoom));
}
/**
* @internal
* This class stores all values that define a transform's state,
* such as center, zoom, minZoom, etc.
* This can be used as a helper for implementing the ITransform interface.
*/
export class TransformHelper implements ITransformGetters {
private _callbacks: TransformHelperCallbacks;
_tileSize: number; // constant
_tileZoom: number; // integer zoom level for tiles
_lngRange: [number, number];
_latRange: [number, number];
_scale: number; // computed based on zoom
_width: number;
_height: number;
/**
* Vertical field of view in radians.
*/
_fovInRadians: number;
/**
* This transform's bearing in radians.
*/
_bearingInRadians: number;
/**
* Pitch in radians.
*/
_pitchInRadians: number;
/**
* Roll in radians.
*/
_rollInRadians: number;
_zoom: number;
_renderWorldCopies: boolean;
_minZoom: number;
_maxZoom: number;
_minPitch: number;
_maxPitch: number;
_center: LngLat;
_elevation: number;
_minElevationForCurrentTile: number;
_pixelPerMeter: number;
_edgeInsets: EdgeInsets;
_unmodified: boolean;
_constraining: boolean;
_rotationMatrix: mat2;
_pixelsToGLUnits: [number, number];
_pixelsToClipSpaceMatrix: mat4;
_clipSpaceToPixelsMatrix: mat4;
_cameraToCenterDistance: number;
_nearZ: number;
_farZ: number;
_autoCalculateNearFarZ: boolean;
constructor(callbacks: TransformHelperCallbacks, minZoom?: number, maxZoom?: number, minPitch?: number, maxPitch?: number, renderWorldCopies?: boolean) {
this._callbacks = callbacks;
this._tileSize = 512; // constant
this._renderWorldCopies = renderWorldCopies === undefined ? true : !!renderWorldCopies;
this._minZoom = minZoom || 0;
this._maxZoom = maxZoom || 22;
this._minPitch = (minPitch === undefined || minPitch === null) ? 0 : minPitch;
this._maxPitch = (maxPitch === undefined || maxPitch === null) ? 60 : maxPitch;
this.setMaxBounds();
this._width = 0;
this._height = 0;
this._center = new LngLat(0, 0);
this._elevation = 0;
this._zoom = 0;
this._tileZoom = getTileZoom(this._zoom);
this._scale = zoomScale(this._zoom);
this._bearingInRadians = 0;
this._fovInRadians = 0.6435011087932844;
this._pitchInRadians = 0;
this._rollInRadians = 0;
this._unmodified = true;
this._edgeInsets = new EdgeInsets();
this._minElevationForCurrentTile = 0;
this._autoCalculateNearFarZ = true;
}
public apply(thatI: ITransformGetters, constrain?: boolean, forceOverrideZ?: boolean): void {
this._latRange = thatI.latRange;
this._lngRange = thatI.lngRange;
this._width = thatI.width;
this._height = thatI.height;
this._center = thatI.center;
this._elevation = thatI.elevation;
this._minElevationForCurrentTile = thatI.minElevationForCurrentTile;
this._zoom = thatI.zoom;
this._tileZoom = getTileZoom(this._zoom);
this._scale = zoomScale(this._zoom);
this._bearingInRadians = thatI.bearingInRadians;
this._fovInRadians = thatI.fovInRadians;
this._pitchInRadians = thatI.pitchInRadians;
this._rollInRadians = thatI.rollInRadians;
this._unmodified = thatI.unmodified;
this._edgeInsets = new EdgeInsets(thatI.padding.top, thatI.padding.bottom, thatI.padding.left, thatI.padding.right);
this._minZoom = thatI.minZoom;
this._maxZoom = thatI.maxZoom;
this._minPitch = thatI.minPitch;
this._maxPitch = thatI.maxPitch;
this._renderWorldCopies = thatI.renderWorldCopies;
this._cameraToCenterDistance = thatI.cameraToCenterDistance;
this._nearZ = thatI.nearZ;
this._farZ = thatI.farZ;
this._autoCalculateNearFarZ = !forceOverrideZ && thatI.autoCalculateNearFarZ;
if (constrain) {
this._constrain();
}
this._calcMatrices();
}
get pixelsToClipSpaceMatrix(): mat4 { return this._pixelsToClipSpaceMatrix; }
get clipSpaceToPixelsMatrix(): mat4 { return this._clipSpaceToPixelsMatrix; }
get minElevationForCurrentTile(): number { return this._minElevationForCurrentTile; }
setMinElevationForCurrentTile(ele: number) {
this._minElevationForCurrentTile = ele;
}
get tileSize(): number { return this._tileSize; }
get tileZoom(): number { return this._tileZoom; }
get scale(): number { return this._scale; }
/**
* Gets the transform's width in pixels. Use {@link resize} to set the transform's size.
*/
get width(): number { return this._width; }
/**
* Gets the transform's height in pixels. Use {@link resize} to set the transform's size.
*/
get height(): number { return this._height; }
/**
* Gets the transform's bearing in radians.
*/
get bearingInRadians(): number { return this._bearingInRadians; }
get lngRange(): [number, number] { return this._lngRange; }
get latRange(): [number, number] { return this._latRange; }
get pixelsToGLUnits(): [number, number] { return this._pixelsToGLUnits; }
get minZoom(): number { return this._minZoom; }
setMinZoom(zoom: number) {
if (this._minZoom === zoom) return;
this._minZoom = zoom;
this.setZoom(this.getConstrained(this._center, this.zoom).zoom);
}
get maxZoom(): number { return this._maxZoom; }
setMaxZoom(zoom: number) {
if (this._maxZoom === zoom) return;
this._maxZoom = zoom;
this.setZoom(this.getConstrained(this._center, this.zoom).zoom);
}
get minPitch(): number { return this._minPitch; }
setMinPitch(pitch: number) {
if (this._minPitch === pitch) return;
this._minPitch = pitch;
this.setPitch(Math.max(this.pitch, pitch));
}
get maxPitch(): number { return this._maxPitch; }
setMaxPitch(pitch: number) {
if (this._maxPitch === pitch) return;
this._maxPitch = pitch;
this.setPitch(Math.min(this.pitch, pitch));
}
get renderWorldCopies(): boolean { return this._renderWorldCopies; }
setRenderWorldCopies(renderWorldCopies: boolean) {
if (renderWorldCopies === undefined) {
renderWorldCopies = true;
} else if (renderWorldCopies === null) {
renderWorldCopies = false;
}
this._renderWorldCopies = renderWorldCopies;
}
get worldSize(): number {
return this._tileSize * this._scale;
}
get centerOffset(): Point {
return this.centerPoint._sub(this.size._div(2));
}
/**
* Gets the transform's dimensions packed into a Point object.
*/
get size(): Point {
return new Point(this._width, this._height);
}
get bearing(): number {
return this._bearingInRadians / Math.PI * 180;
}
setBearing(bearing: number) {
const b = wrap(bearing, -180, 180) * Math.PI / 180;
if (this._bearingInRadians === b) return;
this._unmodified = false;
this._bearingInRadians = b;
this._calcMatrices();
// 2x2 matrix for rotating points
this._rotationMatrix = mat2.create();
mat2.rotate(this._rotationMatrix, this._rotationMatrix, -this._bearingInRadians);
}
get rotationMatrix(): mat2 { return this._rotationMatrix; }
get pitchInRadians(): number {
return this._pitchInRadians;
}
get pitch(): number {
return this._pitchInRadians / Math.PI * 180;
}
setPitch(pitch: number) {
const p = clamp(pitch, this.minPitch, this.maxPitch) / 180 * Math.PI;
if (this._pitchInRadians === p) return;
this._unmodified = false;
this._pitchInRadians = p;
this._calcMatrices();
}
get rollInRadians(): number {
return this._rollInRadians;
}
get roll(): number {
return this._rollInRadians / Math.PI * 180;
}
setRoll(roll: number) {
const r = roll / 180 * Math.PI;
if (this._rollInRadians === r) return;
this._unmodified = false;
this._rollInRadians = r;
this._calcMatrices();
}
get fovInRadians(): number {
return this._fovInRadians;
}
get fov(): number {
return radiansToDegrees(this._fovInRadians);
}
setFov(fov: number) {
fov = clamp(fov, 0.1, 150);
if (this.fov === fov) return;
this._unmodified = false;
this._fovInRadians = degreesToRadians(fov);
this._calcMatrices();
}
get zoom(): number { return this._zoom; }
setZoom(zoom: number) {
const constrainedZoom = this.getConstrained(this._center, zoom).zoom;
if (this._zoom === constrainedZoom) return;
this._unmodified = false;
this._zoom = constrainedZoom;
this._tileZoom = Math.max(0, Math.floor(constrainedZoom));
this._scale = zoomScale(constrainedZoom);
this._constrain();
this._calcMatrices();
}
get center(): LngLat { return this._center; }
setCenter(center: LngLat) {
if (center.lat === this._center.lat && center.lng === this._center.lng) return;
this._unmodified = false;
this._center = center;
this._constrain();
this._calcMatrices();
}
/**
* Elevation at current center point, meters above sea level
*/
get elevation(): number { return this._elevation; }
setElevation(elevation: number) {
if (elevation === this._elevation) return;
this._elevation = elevation;
this._constrain();
this._calcMatrices();
}
get padding(): PaddingOptions { return this._edgeInsets.toJSON(); }
setPadding(padding: PaddingOptions) {
if (this._edgeInsets.equals(padding)) return;
this._unmodified = false;
// Update edge-insets in-place
this._edgeInsets.interpolate(this._edgeInsets, padding, 1);
this._calcMatrices();
}
/**
* The center of the screen in pixels with the top-left corner being (0,0)
* and +y axis pointing downwards. This accounts for padding.
*/
get centerPoint(): Point {
return this._edgeInsets.getCenter(this._width, this._height);
}
/**
* @internal
*/
get pixelsPerMeter(): number { return this._pixelPerMeter; }
get unmodified(): boolean { return this._unmodified; }
get cameraToCenterDistance(): number { return this._cameraToCenterDistance; }
get nearZ(): number { return this._nearZ; }
get farZ(): number { return this._farZ; }
get autoCalculateNearFarZ(): boolean { return this._autoCalculateNearFarZ; }
overrideNearFarZ(nearZ: number, farZ: number): void {
this._autoCalculateNearFarZ = false;
this._nearZ = nearZ;
this._farZ = farZ;
this._calcMatrices();
}
clearNearFarZOverride(): void {
this._autoCalculateNearFarZ = true;
this._calcMatrices();
}
/**
* Returns if the padding params match
*
* @param padding - the padding to check against
* @returns true if they are equal, false otherwise
*/
isPaddingEqual(padding: PaddingOptions): boolean {
return this._edgeInsets.equals(padding);
}
/**
* Helper method to update edge-insets in place
*
* @param start - the starting padding
* @param target - the target padding
* @param t - the step/weight
*/
interpolatePadding(start: PaddingOptions, target: PaddingOptions, t: number): void {
this._unmodified = false;
this._edgeInsets.interpolate(start, target, t);
this._constrain();
this._calcMatrices();
}
resize(width: number, height: number, constrain: boolean = true): void {
this._width = width;
this._height = height;
if (constrain) this._constrain();
this._calcMatrices();
}
/**
* Returns the maximum geographical bounds the map is constrained to, or `null` if none set.
* @returns max bounds
*/
getMaxBounds(): LngLatBounds | null {
if (!this._latRange || this._latRange.length !== 2 ||
!this._lngRange || this._lngRange.length !== 2) return null;
return new LngLatBounds([this._lngRange[0], this._latRange[0]], [this._lngRange[1], this._latRange[1]]);
}
/**
* Sets or clears the map's geographical constraints.
* @param bounds - A {@link LngLatBounds} object describing the new geographic boundaries of the map.
*/
setMaxBounds(bounds?: LngLatBounds | null): void {
if (bounds) {
this._lngRange = [bounds.getWest(), bounds.getEast()];
this._latRange = [bounds.getSouth(), bounds.getNorth()];
this._constrain();
} else {
this._lngRange = null;
this._latRange = [-MAX_VALID_LATITUDE, MAX_VALID_LATITUDE];
}
}
private getConstrained(lngLat: LngLat, zoom: number): {center: LngLat; zoom: number} {
return this._callbacks.getConstrained(lngLat, zoom);
}
/**
* When the map is pitched, some of the 3D features that intersect a query will not intersect
* the query at the surface of the earth. Instead the feature may be closer and only intersect
* the query because it extrudes into the air.
* @param queryGeometry - For point queries, the line from the query point to the "camera point",
* for other geometries, the envelope of the query geometry and the "camera point"
* @returns a geometry that includes all of the original query as well as all possible ares of the
* screen where the *base* of a visible extrusion could be.
*
*/
getCameraQueryGeometry(cameraPoint: Point, queryGeometry: Array<Point>): Array<Point> {
if (queryGeometry.length === 1) {
return [queryGeometry[0], cameraPoint];
} else {
const {minX, minY, maxX, maxY} = Bounds.fromPoints(queryGeometry).extend(cameraPoint);
return [
new Point(minX, minY),
new Point(maxX, minY),
new Point(maxX, maxY),
new Point(minX, maxY),
new Point(minX, minY)
];
}
}
/**
* @internal
* Snaps the transform's center, zoom, etc. into the valid range.
*/
private _constrain(): void {
if (!this.center || !this._width || !this._height || this._constraining) return;
this._constraining = true;
const unmodified = this._unmodified;
const {center, zoom} = this.getConstrained(this.center, this.zoom);
this.setCenter(center);
this.setZoom(zoom);
this._unmodified = unmodified;
this._constraining = false;
}
/**
* This function is called every time one of the transform's defining properties (center, pitch, etc.) changes.
* This function should update the transform's internal data, such as matrices.
* Any derived `_calcMatrices` function should also call the base function first. The base function only depends on the `_width` and `_height` fields.
*/
private _calcMatrices(): void {
if (this._width && this._height) {
this._pixelsToGLUnits = [2 / this._width, -2 / this._height];
let m = mat4.identity(new Float64Array(16) as any);
mat4.scale(m, m, [this._width / 2, -this._height / 2, 1]);
mat4.translate(m, m, [1, -1, 0]);
this._clipSpaceToPixelsMatrix = m;
m = mat4.identity(new Float64Array(16) as any);
mat4.scale(m, m, [1, -1, 1]);
mat4.translate(m, m, [-1, -1, 0]);
mat4.scale(m, m, [2 / this._width, 2 / this._height, 1]);
this._pixelsToClipSpaceMatrix = m;
const halfFov = this.fovInRadians / 2;
this._cameraToCenterDistance = 0.5 / Math.tan(halfFov) * this._height;
}
this._callbacks.calcMatrices();
}
calculateCenterFromCameraLngLatAlt(lnglat: LngLatLike, alt: number, bearing?: number, pitch?: number): {center: LngLat; elevation: number; zoom: number} {
const cameraBearing = bearing !== undefined ? bearing : this.bearing;
const cameraPitch = pitch = pitch !== undefined ? pitch : this.pitch;
const camMercator = MercatorCoordinate.fromLngLat(lnglat, alt);
const dzNormalized = -Math.cos(degreesToRadians(cameraPitch));
const dhNormalized = Math.sin(degreesToRadians(cameraPitch));
const dxNormalized = dhNormalized * Math.sin(degreesToRadians(cameraBearing));
const dyNormalized = -dhNormalized * Math.cos(degreesToRadians(cameraBearing));
let elevation = this.elevation;
const altitudeAGL = alt - elevation;
let distanceToCenterMeters;
if (dzNormalized * altitudeAGL >= 0.0 || Math.abs(dzNormalized) < 0.1) {
distanceToCenterMeters = 10000;
elevation = alt + distanceToCenterMeters * dzNormalized;
} else {
distanceToCenterMeters = -altitudeAGL / dzNormalized;
}
// The mercator transform scale changes with latitude. At high latitudes, there are more "Merc units" per meter
// than at the equator. We treat the center point as our fundamental quantity. This means we want to convert
// elevation to Mercator Z using the scale factor at the center point (not the camera point). Since the center point is
// initially unknown, we compute it using the scale factor at the camera point. This gives us a better estimate of the
// center point scale factor, which we use to recompute the center point. We repeat until the error is very small.
// This typically takes about 5 iterations.
let metersPerMercUnit = altitudeFromMercatorZ(1, camMercator.y);
let centerMercator: MercatorCoordinate;
let dMercator: number;
let iter = 0;
const maxIter = 10;
do {
iter += 1;
if (iter > maxIter) {
break;
}
dMercator = distanceToCenterMeters / metersPerMercUnit;
const dx = dxNormalized * dMercator;
const dy = dyNormalized * dMercator;
centerMercator = new MercatorCoordinate(camMercator.x + dx, camMercator.y + dy);
metersPerMercUnit = 1 / centerMercator.meterInMercatorCoordinateUnits();
} while (Math.abs(distanceToCenterMeters - dMercator * metersPerMercUnit) > 1.0e-12);
const center = centerMercator.toLngLat();
const zoom = scaleZoom(this.height / 2 / Math.tan(this.fovInRadians / 2) / dMercator / this.tileSize);
return {center, elevation, zoom};
}
recalculateZoomAndCenter(elevation: number): void {
if (this.elevation - elevation === 0) return;
// Find the current camera position
const originalPixelPerMeter = mercatorZfromAltitude(1, this.center.lat) * this.worldSize;
const cameraToCenterDistanceMeters = this.cameraToCenterDistance / originalPixelPerMeter;
const origCenterMercator = MercatorCoordinate.fromLngLat(this.center, this.elevation);
const cameraMercator = cameraMercatorCoordinateFromCenterAndRotation(this.center, this.elevation, this.pitch, this.bearing, cameraToCenterDistanceMeters);
// update elevation to the new terrain intercept elevation and recalculate the center point
this._elevation = elevation;
const centerInfo = this.calculateCenterFromCameraLngLatAlt(cameraMercator.toLngLat(), altitudeFromMercatorZ(cameraMercator.z, origCenterMercator.y), this.bearing, this.pitch);
// update matrices
this._elevation = centerInfo.elevation;
this._center = centerInfo.center;
this.setZoom(centerInfo.zoom);
}
getCameraPoint(): Point {
const pitch = this.pitchInRadians;
const offset = Math.tan(pitch) * (this.cameraToCenterDistance || 1);
return this.centerPoint.add(new Point(offset * Math.sin(this.rollInRadians), offset * Math.cos(this.rollInRadians)));
}
getCameraAltitude(): number {
const altitude = Math.cos(this.pitchInRadians) * this._cameraToCenterDistance / this._pixelPerMeter;
return altitude + this.elevation;
}
getCameraLngLat(): LngLat {
const pixelPerMeter = mercatorZfromAltitude(1, this.center.lat) * this.worldSize;
const cameraToCenterDistanceMeters = this.cameraToCenterDistance / pixelPerMeter;
const camMercator = cameraMercatorCoordinateFromCenterAndRotation(this.center, this.elevation, this.pitch, this.bearing, cameraToCenterDistanceMeters);
return camMercator.toLngLat();
}
getMercatorTileCoordinates(overscaledTileID: OverscaledTileID): [number, number, number, number] {
if (!overscaledTileID) {
return [0, 0, 1, 1];
}
const scale = (overscaledTileID.canonical.z >= 0) ? (1 << overscaledTileID.canonical.z) : Math.pow(2.0, overscaledTileID.canonical.z);
return [
overscaledTileID.canonical.x / scale,
overscaledTileID.canonical.y / scale,
1.0 / scale / EXTENT,
1.0 / scale / EXTENT
];
}
}