UNPKG

@deck.gl/core

Version:

deck.gl core library

533 lines (466 loc) 16.9 kB
// deck.gl // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors import log from '../utils/log'; import {createMat4, getCameraPosition, getFrustumPlanes, FrustumPlane} from '../utils/math-utils'; import {Matrix4, Vector3, equals, clamp, mat4} from '@math.gl/core'; import { getDistanceScales, getMeterZoom, lngLatToWorld, worldToLngLat, worldToPixels, pixelsToWorld } from '@math.gl/web-mercator'; import {PROJECTION_MODE} from '../lib/constants'; export type DistanceScales = { unitsPerMeter: number[]; metersPerUnit: number[]; }; export type Padding = { left?: number; right?: number; top?: number; bottom?: number; }; export type ViewportOptions = { /** Name of the viewport */ id?: string; /** Left offset from the canvas edge, in pixels */ x?: number; /** Top offset from the canvas edge, in pixels */ y?: number; /** Viewport width in pixels */ width?: number; /** Viewport height in pixels */ height?: number; /** Longitude in degrees (geospatial only) */ longitude?: number; /** Latitude in degrees (geospatial only) */ latitude?: number; /** Viewport center in world space. If geospatial, refers to meter offsets from lng, lat, elevation */ position?: number[]; /** Zoom level */ zoom?: number; /** Padding around the viewport, in pixels. */ padding?: Padding | null; distanceScales?: DistanceScales; /** Model matrix of viewport center */ modelMatrix?: number[] | null; /** Custom view matrix */ viewMatrix?: number[]; /** Custom projection matrix */ projectionMatrix?: number[]; /** Modifier of viewport scale. Corresponds to the number of pixels per common unit at zoom 0. */ focalDistance?: number; /** Use orthographic projection */ orthographic?: boolean; /** fovy in radians. If supplied, overrides fovy */ fovyRadians?: number; /** fovy in degrees. */ fovy?: number; /** Near plane of the projection matrix */ near?: number; /** Far plane of the projection matrix */ far?: number; }; const DEGREES_TO_RADIANS = Math.PI / 180; const IDENTITY = createMat4(); const ZERO_VECTOR = [0, 0, 0]; const DEFAULT_DISTANCE_SCALES: DistanceScales = { unitsPerMeter: [1, 1, 1], metersPerUnit: [1, 1, 1] }; // / Helpers function createProjectionMatrix({ width, height, orthographic, fovyRadians, focalDistance, padding, near, far }: { width: number; height: number; orthographic: boolean; fovyRadians: number; focalDistance: number; padding: Padding | null; near: number; far: number; }) { const aspect = width / height; const matrix = orthographic ? new Matrix4().orthographic({fovy: fovyRadians, aspect, focalDistance, near, far}) : new Matrix4().perspective({fovy: fovyRadians, aspect, near, far}); if (padding) { const {left = 0, right = 0, top = 0, bottom = 0} = padding; const offsetX = clamp((left + width - right) / 2, 0, width) - width / 2; const offsetY = clamp((top + height - bottom) / 2, 0, height) - height / 2; // pixels to clip space matrix[8] -= (offsetX * 2) / width; matrix[9] += (offsetY * 2) / height; } return matrix; } /** * Manages coordinate system transformations. * * Note: The Viewport is immutable in the sense that it only has accessors. * A new viewport instance should be created if any parameters have changed. */ export default class Viewport { static displayName = 'Viewport'; /** Init parameters */ id: string; x: number; y: number; width: number; height: number; padding?: Padding | null; isGeospatial: boolean; zoom: number; focalDistance: number; position: number[]; modelMatrix: number[] | null; /** Derived parameters */ // `!` post-fix expression operator asserts that its operand is non-null and non-undefined in contexts // where the type checker is unable to conclude that fact. distanceScales: DistanceScales; /** scale factors between world space and common space */ scale!: number; /** scale factor, equals 2^zoom */ center!: number[]; /** viewport center in common space */ cameraPosition!: number[]; /** Camera position in common space */ projectionMatrix!: number[]; viewMatrix!: number[]; viewMatrixUncentered!: number[]; viewMatrixInverse!: number[]; viewProjectionMatrix!: number[]; pixelProjectionMatrix!: number[]; pixelUnprojectionMatrix!: number[]; resolution?: number; private _frustumPlanes: {[name: string]: FrustumPlane} = {}; // eslint-disable-next-line complexity constructor(opts: ViewportOptions = {}) { // @ts-ignore this.id = opts.id || this.constructor.displayName || 'viewport'; this.x = opts.x || 0; this.y = opts.y || 0; // Silently allow apps to send in w,h = 0,0 this.width = opts.width || 1; this.height = opts.height || 1; this.zoom = opts.zoom || 0; this.padding = opts.padding; this.distanceScales = opts.distanceScales || DEFAULT_DISTANCE_SCALES; this.focalDistance = opts.focalDistance || 1; this.position = opts.position || ZERO_VECTOR; this.modelMatrix = opts.modelMatrix || null; const {longitude, latitude} = opts; this.isGeospatial = Number.isFinite(latitude) && Number.isFinite(longitude); this._initProps(opts); this._initMatrices(opts); // Bind methods for easy access this.equals = this.equals.bind(this); this.project = this.project.bind(this); this.unproject = this.unproject.bind(this); this.projectPosition = this.projectPosition.bind(this); this.unprojectPosition = this.unprojectPosition.bind(this); this.projectFlat = this.projectFlat.bind(this); this.unprojectFlat = this.unprojectFlat.bind(this); } get subViewports(): Viewport[] | null { return null; } get metersPerPixel(): number { return this.distanceScales.metersPerUnit[2] / this.scale; } get projectionMode(): number { if (this.isGeospatial) { return this.zoom < 12 ? PROJECTION_MODE.WEB_MERCATOR : PROJECTION_MODE.WEB_MERCATOR_AUTO_OFFSET; } return PROJECTION_MODE.IDENTITY; } // Two viewports are equal if width and height are identical, and if // their view and projection matrices are (approximately) equal. equals(viewport: Viewport): boolean { if (!(viewport instanceof Viewport)) { return false; } if (this === viewport) { return true; } return ( viewport.width === this.width && viewport.height === this.height && viewport.scale === this.scale && equals(viewport.projectionMatrix, this.projectionMatrix) && equals(viewport.viewMatrix, this.viewMatrix) ); // TODO - check distance scales? } /** * Projects xyz (possibly latitude and longitude) to pixel coordinates in window * using viewport projection parameters * - [longitude, latitude] to [x, y] * - [longitude, latitude, Z] => [x, y, z] * Note: By default, returns top-left coordinates for canvas/SVG type render * * @param {Array} lngLatZ - [lng, lat] or [lng, lat, Z] * @param {Object} opts - options * @param {Object} opts.topLeft=true - Whether projected coords are top left * @return {Array} - [x, y] or [x, y, z] in top left coords */ project(xyz: number[], {topLeft = true}: {topLeft?: boolean} = {}): number[] { const worldPosition = this.projectPosition(xyz); const coord = worldToPixels(worldPosition, this.pixelProjectionMatrix); const [x, y] = coord; const y2 = topLeft ? y : this.height - y; return xyz.length === 2 ? [x, y2] : [x, y2, coord[2]]; } /** * Unproject pixel coordinates on screen onto world coordinates, * (possibly [lon, lat]) on map. * - [x, y] => [lng, lat] * - [x, y, z] => [lng, lat, Z] * @param {Array} xyz - * @param {Object} opts - options * @param {Object} opts.topLeft=true - Whether origin is top left * @return {Array|null} - [lng, lat, Z] or [X, Y, Z] */ unproject( xyz: number[], {topLeft = true, targetZ}: {topLeft?: boolean; targetZ?: number} = {} ): number[] { const [x, y, z] = xyz; const y2 = topLeft ? y : this.height - y; const targetZWorld = targetZ && targetZ * this.distanceScales.unitsPerMeter[2]; const coord = pixelsToWorld([x, y2, z], this.pixelUnprojectionMatrix, targetZWorld); const [X, Y, Z] = this.unprojectPosition(coord); if (Number.isFinite(z)) { return [X, Y, Z]; } return Number.isFinite(targetZ) ? [X, Y, targetZ as number] : [X, Y]; } // NON_LINEAR PROJECTION HOOKS // Used for web meractor projection projectPosition(xyz: number[]): [number, number, number] { const [X, Y] = this.projectFlat(xyz); const Z = (xyz[2] || 0) * this.distanceScales.unitsPerMeter[2]; return [X, Y, Z]; } unprojectPosition(xyz: number[]): [number, number, number] { const [X, Y] = this.unprojectFlat(xyz); const Z = (xyz[2] || 0) * this.distanceScales.metersPerUnit[2]; return [X, Y, Z]; } /** * Project [lng,lat] on sphere onto [x,y] on 512*512 Mercator Zoom 0 tile. * Performs the nonlinear part of the web mercator projection. * Remaining projection is done with 4x4 matrices which also handles * perspective. * @param {Array} lngLat - [lng, lat] coordinates * Specifies a point on the sphere to project onto the map. * @return {Array} [x,y] coordinates. */ projectFlat(xyz: number[]): [number, number] { if (this.isGeospatial) { // Shader clamps latitude to +-89.9, see /shaderlib/project/project.glsl.js // lngLatToWorld([0, -89.9])[1] = -317.9934163758329 // lngLatToWorld([0, 89.9])[1] = 829.9934163758271 const result = lngLatToWorld(xyz); result[1] = clamp(result[1], -318, 830); return result; } return xyz as [number, number]; } /** * Unproject world point [x,y] on map onto {lat, lon} on sphere * @param {object|Vector} xy - object with {x,y} members * representing point on projected map plane * @return {GeoCoordinates} - object with {lat,lon} of point on sphere. * Has toArray method if you need a GeoJSON Array. * Per cartographic tradition, lat and lon are specified as degrees. */ unprojectFlat(xyz: number[]): [number, number] { if (this.isGeospatial) { return worldToLngLat(xyz); } return xyz as [number, number]; } /** * Get bounds of the current viewport * @return {Array} - [minX, minY, maxX, maxY] */ getBounds(options: {z?: number} = {}): [number, number, number, number] { const unprojectOption = {targetZ: options.z || 0}; const topLeft = this.unproject([0, 0], unprojectOption); const topRight = this.unproject([this.width, 0], unprojectOption); const bottomLeft = this.unproject([0, this.height], unprojectOption); const bottomRight = this.unproject([this.width, this.height], unprojectOption); return [ Math.min(topLeft[0], topRight[0], bottomLeft[0], bottomRight[0]), Math.min(topLeft[1], topRight[1], bottomLeft[1], bottomRight[1]), Math.max(topLeft[0], topRight[0], bottomLeft[0], bottomRight[0]), Math.max(topLeft[1], topRight[1], bottomLeft[1], bottomRight[1]) ]; } getDistanceScales(coordinateOrigin?: number[]): DistanceScales { if (coordinateOrigin && this.isGeospatial) { return getDistanceScales({ longitude: coordinateOrigin[0], latitude: coordinateOrigin[1], highPrecision: true }); } return this.distanceScales; } containsPixel({ x, y, width = 1, height = 1 }: { x: number; y: number; width?: number; height?: number; }): boolean { return ( x < this.x + this.width && this.x < x + width && y < this.y + this.height && this.y < y + height ); } // Extract frustum planes in common space getFrustumPlanes(): { left: FrustumPlane; right: FrustumPlane; bottom: FrustumPlane; top: FrustumPlane; near: FrustumPlane; far: FrustumPlane; } { if (this._frustumPlanes.near) { // @ts-ignore return this._frustumPlanes; } Object.assign(this._frustumPlanes, getFrustumPlanes(this.viewProjectionMatrix)); // @ts-ignore return this._frustumPlanes; } // EXPERIMENTAL METHODS /** * Needed by panning and linear transition * Pan the viewport to place a given world coordinate at screen point [x, y] * * @param {Array} coords - world coordinates * @param {Array} pixel - [x,y] coordinates on screen * @return {Object} props of the new viewport */ panByPosition(coords: number[], pixel: number[]): any { return null; } // INTERNAL METHODS /* eslint-disable complexity, max-statements */ private _initProps(opts: ViewportOptions) { const longitude = opts.longitude as number; const latitude = opts.latitude as number; if (this.isGeospatial) { if (!Number.isFinite(opts.zoom)) { this.zoom = getMeterZoom({latitude}) + Math.log2(this.focalDistance); } this.distanceScales = opts.distanceScales || getDistanceScales({latitude, longitude}); } const scale = Math.pow(2, this.zoom); this.scale = scale; const {position, modelMatrix} = opts; let meterOffset: number[] = ZERO_VECTOR; if (position) { meterOffset = modelMatrix ? (new Matrix4(modelMatrix).transformAsVector(position, []) as number[]) : position; } if (this.isGeospatial) { // Determine camera center in common space const center = this.projectPosition([longitude, latitude, 0]); this.center = new Vector3(meterOffset) // Convert to pixels in current zoom .scale(this.distanceScales.unitsPerMeter) .add(center); } else { this.center = this.projectPosition(meterOffset); } } /* eslint-enable complexity, max-statements */ private _initMatrices(opts: ViewportOptions) { const { // View matrix viewMatrix = IDENTITY, // Projection matrix projectionMatrix = null, // Projection matrix parameters, used if projectionMatrix not supplied orthographic = false, fovyRadians, fovy = 75, near = 0.1, // Distance of near clipping plane far = 1000, // Distance of far clipping plane padding = null, // Center offset in pixels focalDistance = 1 } = opts; this.viewMatrixUncentered = viewMatrix; // Make a centered version of the matrix for projection modes without an offset this.viewMatrix = new Matrix4() // Apply the uncentered view matrix .multiplyRight(viewMatrix) // And center it .translate(new Vector3(this.center).negate()); this.projectionMatrix = projectionMatrix || createProjectionMatrix({ width: this.width, height: this.height, orthographic, fovyRadians: fovyRadians || fovy * DEGREES_TO_RADIANS, focalDistance, padding, near, far }); // Note: As usual, matrix operations should be applied in "reverse" order // since vectors will be multiplied in from the right during transformation const vpm = createMat4(); mat4.multiply(vpm, vpm, this.projectionMatrix); mat4.multiply(vpm, vpm, this.viewMatrix); this.viewProjectionMatrix = vpm; // console.log('VPM', this.viewMatrix, this.projectionMatrix, this.viewProjectionMatrix); // Calculate inverse view matrix this.viewMatrixInverse = mat4.invert([], this.viewMatrix) || this.viewMatrix; // Decompose camera parameters this.cameraPosition = getCameraPosition(this.viewMatrixInverse); /* * Builds matrices that converts preprojected lngLats to screen pixels * and vice versa. * Note: Currently returns bottom-left coordinates! * Note: Starts with the GL projection matrix and adds steps to the * scale and translate that matrix onto the window. * Note: WebGL controls clip space to screen projection with gl.viewport * and does not need this step. */ // matrix for conversion from world location to screen (pixel) coordinates const viewportMatrix = createMat4(); // matrix from NDC to viewport. const pixelProjectionMatrix = createMat4(); // matrix from world space to viewport. mat4.scale(viewportMatrix, viewportMatrix, [this.width / 2, -this.height / 2, 1]); mat4.translate(viewportMatrix, viewportMatrix, [1, -1, 0]); mat4.multiply(pixelProjectionMatrix, viewportMatrix, this.viewProjectionMatrix); this.pixelProjectionMatrix = pixelProjectionMatrix; this.pixelUnprojectionMatrix = mat4.invert(createMat4(), this.pixelProjectionMatrix); if (!this.pixelUnprojectionMatrix) { log.warn('Pixel project matrix not invertible')(); // throw new Error('Pixel project matrix not invertible'); } } }