// View and Projection Matrix calculations for mapbox-js style map view properties import {createMat4} from './math-utils'; import { zoomToScale, pixelsToWorld, lngLatToWorld, worldToLngLat, worldToPixels, altitudeToFovy, fovyToAltitude, DEFAULT_ALTITUDE, getProjectionMatrix, getDistanceScales, getViewMatrix, DistanceScales } from './web-mercator-utils'; import {fitBounds} from './fit-bounds'; import {getBounds} from './get-bounds'; import type {FitBoundsOptions} from './fit-bounds'; import {mat4, vec2, vec3} from '@math.gl/core'; /** * @param width=1 - Width of "viewport" or window * @param height=1 - Height of "viewport" or window * @param scale=1 - Either use scale or zoom * @param pitch=0 - Camera angle in degrees (0 is straight down) * @param bearing=0 - Map rotation in degrees (0 means north is up) * @param fovy= - Field of view of camera in degrees * @param altitude= - Altitude of camera in screen units * * Web mercator projection short-hand parameters * @param latitude - Center of viewport on map * @param longitude - Center of viewport on map * @param zoom - Scale = Math.pow(2,zoom) on map * Notes: * - Only one of center or [latitude, longitude] can be specified * - [latitude, longitude] can only be specified when "mercator" is true * - Altitude has a default value that matches assumptions in mapbox-gl * - Field of view is independent from altitude, provide `altitudeToFovy(1.5)` (default value) to match assumptions in mapbox-gl * - width and height are forced to 1 if supplied as 0, to avoid * division by zero. This is intended to reduce the burden of apps to * to check values before instantiating a Viewport. */ export type WebMercatorViewportProps = { // Map state width: number; height: number; latitude?: number; longitude?: number; position?: number[]; zoom?: number; pitch?: number; bearing?: number; altitude?: number; fovy?: number; nearZMultiplier?: number; farZMultiplier?: number; }; /** * The WebMercatorViewport class creates * - view/projection matrices * - "uniform values" (for shaders) from mercator params * * Note: Instances are immutable in the sense that they only have accessors. * A new viewport instance should be created if any parameters have changed. */ export class WebMercatorViewport { readonly latitude: number; readonly longitude: number; readonly zoom: number; readonly pitch: number; readonly bearing: number; readonly altitude: number; readonly fovy: number; readonly meterOffset: number[]; readonly center: number[]; readonly width: number; readonly height: number; readonly scale: number; readonly distanceScales: DistanceScales; readonly viewMatrix: number[]; readonly projectionMatrix: number[]; viewProjectionMatrix: number[]; pixelProjectionMatrix: number[]; pixelUnprojectionMatrix: number[]; /** * @classdesc * Creates view/projection matrices from mercator params * 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. */ // eslint-disable-next-line max-statements constructor(props: WebMercatorViewportProps = {width: 1, height: 1}) { let { // Map state width, height, altitude = null, fovy = null } = props; const { latitude = 0, longitude = 0, zoom = 0, pitch = 0, bearing = 0, position = null, nearZMultiplier = 0.02, farZMultiplier = 1.01 } = props; // Silently allow apps to send in 0,0 to facilitate isomorphic render etc width = width || 1; height = height || 1; // `fovy` & `altitude` are independent parameters, one for the // projection and the latter for the view matrix. In the past, // the `fovy` was always derived from the `altitude` if (fovy === null && altitude === null) { altitude = DEFAULT_ALTITUDE; fovy = altitudeToFovy(altitude); } else if (fovy === null) { fovy = altitudeToFovy(altitude); } else if (altitude === null) { altitude = fovyToAltitude(fovy); } const scale = zoomToScale(zoom); // Altitude - prevent division by 0 // TODO - just throw an Error instead? altitude = Math.max(0.75, altitude); const distanceScales = getDistanceScales({longitude, latitude}); const center: number[] = lngLatToWorld([longitude, latitude]); center.push(0); if (position) { vec3.add(center, center, vec3.mul([], position, distanceScales.unitsPerMeter)); } this.projectionMatrix = getProjectionMatrix({ width, height, scale, center, pitch, fovy, nearZMultiplier, farZMultiplier }); this.viewMatrix = getViewMatrix({ height, scale, center, pitch, bearing, altitude }); // Save parameters this.width = width; this.height = height; this.scale = scale; this.latitude = latitude; this.longitude = longitude; this.zoom = zoom; this.pitch = pitch; this.bearing = bearing; this.altitude = altitude; this.fovy = fovy; this.center = center; this.meterOffset = position || [0, 0, 0]; this.distanceScales = distanceScales; this._initMatrices(); Object.freeze(this); } _initMatrices(): void { const {width, height, projectionMatrix, viewMatrix} = this; // 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, projectionMatrix); mat4.multiply(vpm, vpm, viewMatrix); this.viewProjectionMatrix = vpm; // Calculate matrices and scales needed for projection /** * 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. */ const m = createMat4(); // matrix for conversion from location to screen coordinates mat4.scale(m, m, [width / 2, -height / 2, 1]); mat4.translate(m, m, [1, -1, 0]); mat4.multiply(m, m, vpm); const mInverse = mat4.invert(createMat4(), m); if (!mInverse) { throw new Error('Pixel project matrix not invertible'); } this.pixelProjectionMatrix = m; this.pixelUnprojectionMatrix = mInverse; } /** Two viewports are equal if width and height are identical, and if * their view and projection matrices are (approximately) equal. */ equals = (viewport: WebMercatorViewport | null): boolean => { if (!(viewport instanceof WebMercatorViewport)) { return false; } return ( viewport.width === this.width && viewport.height === this.height && mat4.equals(viewport.projectionMatrix, this.projectionMatrix) && mat4.equals(viewport.viewMatrix, this.viewMatrix) ); }; /** * 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 lngLatZ - [lng, lat] or [lng, lat, Z] * @param options - options * @param options.topLeft=true - Whether projected coords are top left * @return - screen coordinates [x, y] or [x, y, z], z as pixel depth */ project = (lngLatZ: number[], options: {topLeft?: boolean} = {}): number[] => { const {topLeft = true} = options; const worldPosition = this.projectPosition(lngLatZ); const coord = worldToPixels(worldPosition, this.pixelProjectionMatrix); const [x, y] = coord; const y2 = topLeft ? y : this.height - y; return lngLatZ.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 xyz - screen coordinates, z as pixel depth * @param options - options * @param options.topLeft=true - Whether projected coords are top left * @param options.targetZ=0 - If pixel depth is unknown, targetZ is used as * the elevation plane to unproject onto * @return - [lng, lat, Z] or [X, Y, Z] */ unproject = (xyz: number[], options: {topLeft?: boolean; targetZ?: number} = {}): number[] => { const {topLeft = true, targetZ = undefined} = options; 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] : [X, Y]; }; // NON_LINEAR PROJECTION HOOKS // Used for web meractor projection projectPosition = (xyz: number[]): [number, number, number] => { const [X, Y] = lngLatToWorld(xyz); const Z = (xyz[2] || 0) * this.distanceScales.unitsPerMeter[2]; return [X, Y, Z]; }; unprojectPosition = (xyz: number[]): [number, number, number] => { const [X, Y] = worldToLngLat(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 lngLat - [lng, lat] coordinates * Specifies a point on the sphere to project onto the map. * @return [x,y] coordinates. */ projectFlat(lngLat: number[]): number[] { return lngLatToWorld(lngLat); } /** * Unproject world point [x,y] on map onto {lat, lon} on sphere * * @param xy - array with [x,y] members * representing point on projected map plane * @return - array 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(xy: number[]): number[] { return worldToLngLat(xy); } /** * Get the map center that place a given [lng, lat] coordinate at screen point [x, y] * @param opt * @param opt.lngLat - [lng,lat] coordinates * Specifies a point on the sphere. * @param opt.pos - [x,y] coordinates * Specifies a point on the screen. * @return [lng,lat] new map center. */ getMapCenterByLngLatPosition({lngLat, pos}: {lngLat: number[]; pos: number[]}): number[] { const fromLocation = pixelsToWorld(pos, this.pixelUnprojectionMatrix); const toLocation = lngLatToWorld(lngLat); const translate = vec2.add([], toLocation, vec2.negate([], fromLocation)); const newCenter = vec2.add([], this.center, translate); return worldToLngLat(newCenter); } /** * Returns a new viewport that fit around the given rectangle. * Only supports non-perspective mode. * @param bounds - [[lon, lat], [lon, lat]] * @param [options] * @param [options.padding] - The amount of padding in pixels to add to the given bounds. * @param [options.offset] - The center of the given bounds relative to the map's center, * [x, y] measured in pixels. * @returns {WebMercatorViewport} */ fitBounds( bounds: [[number, number], [number, number]], options: Omit = {} ): WebMercatorViewport { const {width, height} = this; const {longitude, latitude, zoom} = fitBounds(Object.assign({width, height, bounds}, options)); return new WebMercatorViewport({width, height, longitude, latitude, zoom}); } /** * Returns the bounding box of the viewport. * @param [options] * @param [options.z] - The altitude at which the bounds should be calculated. * @returns {Array} bounds - [[lon, lat], [lon, lat]] */ getBounds(options?: {z?: number}): number[][] { const corners = this.getBoundingRegion(options); const west = Math.min(...corners.map((p) => p[0])); const east = Math.max(...corners.map((p) => p[0])); const south = Math.min(...corners.map((p) => p[1])); const north = Math.max(...corners.map((p) => p[1])); return [ [west, south], [east, north] ]; } /** * Returns the bounding box of the viewport. * @param [options] * @param [options.z] - The altitude at which the bounds should be calculated. * @returns {Array} an array of 4 points that define the visible region */ getBoundingRegion(options: {z?: number} = {}): number[][] { return getBounds(this, options.z || 0); } // DEPRECATED /** @deprecated Legacy method name */ getLocationAtPoint({lngLat, pos}: {lngLat: number[]; pos: number[]}): number[] { return this.getMapCenterByLngLatPosition({lngLat, pos}); } }