UNPKG

maplibre-gl

Version:

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

728 lines (650 loc) • 29.2 kB
import {LngLat, type LngLatLike} from './lng_lat.ts'; import {LngLatBounds} from './lng_lat_bounds.ts'; import Point from '@mapbox/point-geometry'; import {wrap, clamp, degreesToRadians, radiansToDegrees, zoomScale, MAX_VALID_LATITUDE, scaleZoom, warnOnce} from '../util/util.ts'; import {mat4, mat2} from 'gl-matrix'; import {EdgeInsets} from './edge_insets.ts'; import {MercatorCoordinate, mercatorWorldCoordinateHelper} from './mercator_coordinate.ts'; import {cameraDirectionFromPitchBearing, maxMercatorHorizonAngle} from './projection/mercator_utils.ts'; import {EXTENT} from '../data/extent.ts'; import {Bounds} from './bounds.ts'; import type {WorldCoordinateHelper} from './transform_interface.ts'; import type {PaddingOptions} from './edge_insets.ts'; import type {IReadonlyTransform, ITransformGetters, TransformConstrainFunction} from './transform_interface.ts'; /** * If a path crossing the antimeridian would be shorter, extend the final coordinate so that * interpolating between the two endpoints will cross it. A world that does not wrap has no antimeridian to cross. * @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.worldCoordinateHelper.wraps || 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 = { /** * The transform's default getter of 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. */ defaultConstrain: TransformConstrainFunction; /** * Updates the underlying transform's internal matrices. */ calcMatrices: () => void; }; export type TransformOptions = { /** * The minimum zoom level of the map. Users cannot zoom out beyond this level. (0–24) * @defaultValue 0 */ minZoom?: number; /** * The maximum zoom level of the map. Users cannot zoom in beyond this level. (0–24) * @defaultValue 22 */ maxZoom?: number; /** * The minimum pitch of the map. */ minPitch?: number; /** * The maximum pitch of the map. */ maxPitch?: number; /** * Whether to render multiple copies of the world side by side in the map. */ renderWorldCopies?: boolean; /** * An override of the transform's default constraining function for respecting its longitude and latitude bounds. */ constrainOverride?: TransformConstrainFunction | null; }; function getTileZoom(zoom: number): number { return Math.max(0, Math.floor(zoom)); } /** The most passes {@link TransformHelper._centerAlongView} takes to agree on the mercator scale at the center it finds. */ const MAX_CENTER_PASSES = 10; /** * @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; /** * Whether the camera was never deliberately positioned, in which case a loading style may apply its own camera. * Cleared by the setters that move the camera, but not by ones that only snap it into a valid range. */ _unmodified: boolean; _constraining: boolean; _rotationMatrix: mat2; _pixelsToGLUnits: [number, number]; _pixelsToClipSpaceMatrix: mat4; _clipSpaceToPixelsMatrix: mat4; _cameraToCenterDistance: number; _nearZ: number; _farZ: number; _autoCalculateNearFarZ: boolean; _constrainOverride: TransformConstrainFunction; _worldCoordinateHelper: WorldCoordinateHelper; constructor(callbacks: TransformHelperCallbacks, options?: TransformOptions) { this._callbacks = callbacks; this._tileSize = 512; // constant this._worldCoordinateHelper = mercatorWorldCoordinateHelper; this._renderWorldCopies = options?.renderWorldCopies === undefined ? true : !!options?.renderWorldCopies; this._minZoom = options?.minZoom || 0; this._maxZoom = options?.maxZoom || 22; this._minPitch = (options?.minPitch === undefined || options?.minPitch === null) ? 0 : options?.minPitch; this._maxPitch = (options?.maxPitch === undefined || options?.maxPitch === null) ? 60 : options?.maxPitch; this._constrainOverride = options?.constrainOverride ?? null; 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): void { this._constrainOverride = thatI.constrainOverride; 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 = thatI.autoCalculateNearFarZ; if (constrain) { this.constrainInternal(); } this._calcMatrices(); } get pixelsToClipSpaceMatrix(): mat4 { return this._pixelsToClipSpaceMatrix; } get clipSpaceToPixelsMatrix(): mat4 { return this._clipSpaceToPixelsMatrix; } get minElevationForCurrentTile(): number { return this._minElevationForCurrentTile; } setMinElevationForCurrentTile(ele: number): void { if (ele === this._minElevationForCurrentTile) return; this._minElevationForCurrentTile = ele; this._calcMatrices(); } 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 worldCoordinateHelper(): WorldCoordinateHelper { return this._worldCoordinateHelper; } get pixelsToGLUnits(): [number, number] { return this._pixelsToGLUnits; } get minZoom(): number { return this._minZoom; } setMinZoom(zoom: number): void { if (this._minZoom === zoom) return; this._minZoom = zoom; const unmodified = this._unmodified; this.setZoom(this.applyConstrain(this._center, this.zoom).zoom); this._unmodified = unmodified; } get maxZoom(): number { return this._maxZoom; } setMaxZoom(zoom: number): void { if (this._maxZoom === zoom) return; this._maxZoom = zoom; const unmodified = this._unmodified; this.setZoom(this.applyConstrain(this._center, this.zoom).zoom); this._unmodified = unmodified; } get minPitch(): number { return this._minPitch; } setMinPitch(pitch: number): void { if (this._minPitch === pitch) return; this._minPitch = pitch; const unmodified = this._unmodified; this.setPitch(Math.max(this.pitch, pitch)); this._unmodified = unmodified; } get maxPitch(): number { return this._maxPitch; } setMaxPitch(pitch: number): void { if (this._maxPitch === pitch) return; this._maxPitch = pitch; const unmodified = this._unmodified; this.setPitch(Math.min(this.pitch, pitch)); this._unmodified = unmodified; } get renderWorldCopies(): boolean { return this._renderWorldCopies; } setRenderWorldCopies(renderWorldCopies: boolean): void { if (renderWorldCopies === undefined) { renderWorldCopies = true; } else if (renderWorldCopies === null) { renderWorldCopies = false; } if (renderWorldCopies && !this._worldCoordinateHelper.wraps) { warnOnce('renderWorldCopies has no effect in a projection whose world does not wrap.'); } this._renderWorldCopies = renderWorldCopies; } get constrainOverride(): TransformConstrainFunction { return this._constrainOverride; } setConstrainOverride(constrain?: TransformConstrainFunction | null): void { if (constrain === undefined) constrain = null; if (this._constrainOverride === constrain) return; this._constrainOverride = constrain; this.constrainInternal(); this._calcMatrices(); } 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): void { 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): void { 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): void { 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): void { 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): void { const constrainedZoom = this.applyConstrain(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.constrainInternal(); this._calcMatrices(); } get center(): LngLat { return this._center; } setCenter(center: LngLat): void { if (center.lat === this._center.lat && center.lng === this._center.lng) return; this._unmodified = false; this._center = center; this.constrainInternal(); this._calcMatrices(); } /** * Elevation at current center point, meters above sea level */ get elevation(): number { return this._elevation; } setElevation(elevation: number): void { if (elevation === this._elevation) return; this._elevation = elevation; this.constrainInternal(); this._calcMatrices(); } get padding(): PaddingOptions { return this._edgeInsets.toJSON(); } setPadding(padding: PaddingOptions): void { if (this._edgeInsets.equals(padding)) return; this._unmodified = false; // Update edge-insets in-place this._edgeInsets.interpolate(this._edgeInsets, padding, 1); this.constrainInternal(); 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.constrainInternal(); this._calcMatrices(); } resize(width: number, height: number, constrain: boolean = true): void { this._width = width; this._height = height; if (constrain) this.constrainInternal(); 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?.length !== 2 || this._lngRange?.length !== 2) return null; return new LngLatBounds([this._lngRange[0], this._latRange[0]], [this._lngRange[1], this._latRange[1]]); } /** * Replaces the lng/lat to world coordinate mapping, mercator by default. The projection factory calls this on a * transform it just built for a registered CRS, and `clone` calls it to keep the mapping. */ setWorldCoordinateHelper(worldCoordinateHelper: WorldCoordinateHelper): void { this._worldCoordinateHelper = worldCoordinateHelper; } /** * 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.constrainInternal(); } else { this._lngRange = null; this._latRange = [-MAX_VALID_LATITUDE, MAX_VALID_LATITUDE]; } } /** * 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: Point[]): 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) ]; } } applyConstrain: TransformConstrainFunction = (lngLat, zoom) => { if (this._constrainOverride !== null) { return this._constrainOverride(lngLat, zoom); } else { return this._callbacks.defaultConstrain(lngLat, zoom); } }; /** * @internal * Snaps the transform's center, zoom, etc. into the valid range. */ private constrainInternal(): void { if (!this.center || !this._width || !this._height || this._constraining) return; this._constraining = true; const unmodified = this._unmodified; const {center, zoom} = this.applyConstrain(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. * While either dimension is zero there is no view to build, so the derived function is not called at all. */ private _calcMatrices(): void { this._pixelPerMeter = this.worldCoordinateHelper.worldZFromAltitude(1, this.center) * this.worldSize; if (!this._width || !this._height) { return; } this._pixelsToGLUnits = [2 / this._width, -2 / this._height]; let m = mat4.identity(new Float64Array(16)); 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)); 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 {distanceToCenter, clampedElevation} = this._distanceToCenterFromAltElevationPitch(alt, this.elevation, cameraPitch); const {x, y} = cameraDirectionFromPitchBearing(cameraPitch, cameraBearing); const worldCoordinateHelper = this.worldCoordinateHelper; const cameraLngLat = LngLat.convert(lnglat); const camMercator = worldCoordinateHelper.worldFromLngLat(cameraLngLat.lng, cameraLngLat.lat, alt); const {centerMercator, dMercator} = this._centerAlongView(camMercator, x, y, distanceToCenter, worldCoordinateHelper.metersPerWorldUnit(camMercator.x, camMercator.y)); const center = worldCoordinateHelper.lngLatFromWorld(centerMercator.x, centerMercator.y); const zoom = scaleZoom(this.height / 2 / Math.tan(this.fovInRadians / 2) / dMercator / this.tileSize); return {center, elevation: clampedElevation, zoom}; } /** * Moves the center along the view to the given elevation with the camera where it is, and sets the zoom to match. * The center is found along the view from the camera's position, which is kept, by {@link _centerAlongView}, * starting from the old center's mercator scale. The matrices are recomputed even where `setZoom` leaves the zoom * as it was, as at a zoom bound, since the center and its elevation have moved. * @param elevation - the elevation in meters for the center */ recalculateZoomAndCenter(elevation: number): void { if (this.elevation - elevation === 0) return; const cameraPitch = this.pitch; const cameraBearing = this.bearing; const {x, y, z} = cameraDirectionFromPitchBearing(cameraPitch, cameraBearing); const worldCoordinateHelper = this.worldCoordinateHelper; const originalCenterMercator = worldCoordinateHelper.worldFromLngLat(this.center.lng, this.center.lat, this.elevation); const originalMetersPerMercUnit = 1 / worldCoordinateHelper.worldZFromAltitude(1, this.center); const dCamMercator = this.cameraToCenterDistance / this.worldSize; const camMercator = new MercatorCoordinate(originalCenterMercator.x - x * dCamMercator, originalCenterMercator.y - y * dCamMercator, originalCenterMercator.z + z * dCamMercator); const {distanceToCenter, clampedElevation} = this._distanceToCenterFromAltElevationPitch(camMercator.z * originalMetersPerMercUnit, elevation, cameraPitch); const {centerMercator, dMercator} = this._centerAlongView(camMercator, x, y, distanceToCenter, originalMetersPerMercUnit); const center = worldCoordinateHelper.lngLatFromWorld(centerMercator.x, centerMercator.y); const zoom = scaleZoom(this.cameraToCenterDistance / dMercator / this.tileSize); // Update matrices this._elevation = clampedElevation; this._center = center; const previousZoom = this._zoom; this.setZoom(zoom); if (this._zoom === previousZoom) { this._unmodified = false; this.constrainInternal(); this._calcMatrices(); } } /** * The center `distanceToCenter` meters from the camera along the view, in world units, and that distance in * world units. The mercator scale changes with latitude and the center's latitude is unknown until the center * is: each pass places the center with the scale at the center the last pass found, from `metersPerMercUnit`, * and stops once two passes agree. Zoomed far out at a high latitude the scale changes faster along the view than * a pass follows; a pass that would move the center further than the last one did ends the search at the last * stable center instead of oscillating (#6775). * @param cameraMercator - the camera's position * @param x - the view direction's x component, see {@link cameraDirectionFromPitchBearing} * @param y - the view direction's y component * @param distanceToCenter - the camera's distance to the center in meters * @param metersPerMercUnit - the mercator scale to start from */ private _centerAlongView(cameraMercator: MercatorCoordinate, x: number, y: number, distanceToCenter: number, metersPerMercUnit: number): {centerMercator: MercatorCoordinate; dMercator: number} { let dMercator = distanceToCenter / metersPerMercUnit; let centerMercator = new MercatorCoordinate(cameraMercator.x + x * dMercator, cameraMercator.y + y * dMercator); let step = Infinity; for (let pass = 1; pass < MAX_CENTER_PASSES; pass++) { const centerMetersPerMercUnit = this.worldCoordinateHelper.metersPerWorldUnit(centerMercator.x, centerMercator.y); const nextStep = Math.abs(centerMetersPerMercUnit - metersPerMercUnit); if (nextStep <= 1e-12 * metersPerMercUnit || nextStep >= step) break; step = nextStep; metersPerMercUnit = centerMetersPerMercUnit; dMercator = distanceToCenter / metersPerMercUnit; centerMercator = new MercatorCoordinate(cameraMercator.x + x * dMercator, cameraMercator.y + y * dMercator); } return {centerMercator, dMercator}; } _distanceToCenterFromAltElevationPitch(alt: number, elevation: number, pitch: number): {distanceToCenter: number; clampedElevation: number} { const dzNormalized = -Math.cos(degreesToRadians(pitch)); const altitudeAGL = alt - elevation; let distanceToCenter: number; let clampedElevation = elevation; if (dzNormalized * altitudeAGL >= 0.0 || Math.abs(dzNormalized) < Math.cos(degreesToRadians(maxMercatorHorizonAngle))) { distanceToCenter = 10000; clampedElevation = alt + distanceToCenter * dzNormalized; } else { distanceToCenter = -altitudeAGL / dzNormalized; } return {distanceToCenter, clampedElevation}; } 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))); } getMercatorTileCoordinates(overscaledTileID?: { canonical: {x: number; y: number; z: number}} | null): [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 ]; } }