UNPKG

maplibre-gl

Version:

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

629 lines (556 loc) 23.8 kB
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 ]; } }