UNPKG

mercator-proj

Version:

[![Build Status](https://img.shields.io/travis/com/sakitam-gis/mercator-proj)](https://travis-ci.com/sakitam-gis/mercator-proj) [![GZIP size](http://img.badgesize.io/https://unpkg.com/mercator-proj/dist/mercator-proj.min.js?compression=gzip&label=gzip%20s

318 lines (273 loc) 10.1 kB
// @ts-ignore import * as vec2 from 'gl-matrix/vec2'; // @ts-ignore import * as vec3 from 'gl-matrix/vec3'; // @ts-ignore import * as mat4 from 'gl-matrix/mat4'; import assert from './assert'; import { createMat4, transformVector } from './math-utils'; import { IDistanceScales } from './viewport'; // CONSTANTS const PI = Math.PI; const PI_4 = PI / 4; const DEGREES_TO_RADIANS = PI / 180; const RADIANS_TO_DEGREES = 180 / PI; const TILE_SIZE = 512; // Average circumference (40075 km equatorial, 40007 km meridional) const EARTH_CIRCUMFERENCE = 40.03e6; // Mapbox default altitude const DEFAULT_ALTITUDE = 1.5; /** Util functions **/ export function zoomToScale(zoom: number) { return Math.pow(2, zoom); } export function scaleToZoom(scale: number) { return Math.log2(scale); } /** * 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. */ export function lngLatToWorld([lng, lat]: number[]) { assert(Number.isFinite(lng)); assert(Number.isFinite(lat) && lat >= -90 && lat <= 90, 'invalid latitude'); const lambda2 = lng * DEGREES_TO_RADIANS; const phi2 = lat * DEGREES_TO_RADIANS; const x = (TILE_SIZE * (lambda2 + PI)) / (2 * PI); const y = (TILE_SIZE * (PI + Math.log(Math.tan(PI_4 + phi2 * 0.5)))) / (2 * PI); return [x, y]; } // Unproject world point [x,y] on map onto {lat, lon} on sphere export function worldToLngLat([x, y]: number[]) { const lambda2 = (x / TILE_SIZE) * (2 * PI) - PI; const phi2 = 2 * (Math.atan(Math.exp((y / TILE_SIZE) * (2 * PI) - PI)) - PI_4); return [lambda2 * RADIANS_TO_DEGREES, phi2 * RADIANS_TO_DEGREES]; } // Returns the zoom level that gives a 1 meter pixel at a certain latitude // 1 = C*cos(y)/2^z/TILE_SIZE = C*cos(y)/2^(z+9) export function getMeterZoom({latitude}: { latitude: number; }) { assert(Number.isFinite(latitude)); const latCosine = Math.cos(latitude * DEGREES_TO_RADIANS); return scaleToZoom(EARTH_CIRCUMFERENCE * latCosine) - 9; } /** * Calculate distance scales in meters around current lat/lon, both for * degrees and pixels. * In mercator projection mode, the distance scales vary significantly * with latitude. */ export function getDistanceScales({latitude, longitude, highPrecision = false}: { latitude: number; longitude: number; highPrecision?: boolean; }) { assert(Number.isFinite(latitude) && Number.isFinite(longitude)); const result: IDistanceScales = { degreesPerUnit: [], metersPerUnit: [], unitsPerDegree: [], unitsPerMeter: [] }; const worldSize = TILE_SIZE; const latCosine = Math.cos(latitude * DEGREES_TO_RADIANS); /** * Number of pixels occupied by one degree longitude around current lat/lon: unitsPerDegreeX = d(lngLatToWorld([lng, lat])[0])/d(lng) = scale * TILE_SIZE * DEGREES_TO_RADIANS / (2 * PI) unitsPerDegreeY = d(lngLatToWorld([lng, lat])[1])/d(lat) = -scale * TILE_SIZE * DEGREES_TO_RADIANS / cos(lat * DEGREES_TO_RADIANS) / (2 * PI) */ const unitsPerDegreeX = worldSize / 360; const unitsPerDegreeY = unitsPerDegreeX / latCosine; /** * Number of pixels occupied by one meter around current lat/lon: */ const altUnitsPerMeter = worldSize / EARTH_CIRCUMFERENCE / latCosine; /** * LngLat: longitude -> east and latitude -> north (bottom left) * UTM meter offset: x -> east and y -> north (bottom left) * World space: x -> east and y -> south (top left) * * Y needs to be flipped when converting delta degree/meter to delta pixels */ result.unitsPerMeter = [altUnitsPerMeter, altUnitsPerMeter, altUnitsPerMeter]; result.metersPerUnit = [1 / altUnitsPerMeter, 1 / altUnitsPerMeter, 1 / altUnitsPerMeter]; result.unitsPerDegree = [unitsPerDegreeX, unitsPerDegreeY, altUnitsPerMeter]; result.degreesPerUnit = [1 / unitsPerDegreeX, 1 / unitsPerDegreeY, 1 / altUnitsPerMeter]; /** * Taylor series 2nd order for 1/latCosine f'(a) * (x - a) = d(1/cos(lat * DEGREES_TO_RADIANS))/d(lat) * dLat = DEGREES_TO_RADIANS * tan(lat * DEGREES_TO_RADIANS) / cos(lat * DEGREES_TO_RADIANS) * dLat */ if (highPrecision) { const latCosine2 = (DEGREES_TO_RADIANS * Math.tan(latitude * DEGREES_TO_RADIANS)) / latCosine; const unitsPerDegreeY2 = (unitsPerDegreeX * latCosine2) / 2; const altUnitsPerDegree2 = (worldSize / EARTH_CIRCUMFERENCE) * latCosine2; const altUnitsPerMeter2 = (altUnitsPerDegree2 / unitsPerDegreeY) * altUnitsPerMeter; result.unitsPerDegree2 = [0, unitsPerDegreeY2, altUnitsPerDegree2]; result.unitsPerMeter2 = [altUnitsPerMeter2, 0, altUnitsPerMeter2]; } // Main results, used for converting meters to latlng deltas and scaling offsets return result; } /** * Offset a lng/lat position by meterOffset (northing, easting) */ export function addMetersToLngLat(lngLatZ: number[], xyz: number[]) { const [longitude, latitude, z0] = lngLatZ; const [x, y, z] = xyz; const {unitsPerMeter, unitsPerMeter2} = getDistanceScales({ longitude, latitude, highPrecision: true }); const worldspace = lngLatToWorld(lngLatZ); if (unitsPerMeter2) { worldspace[0] += x * (unitsPerMeter[0] + unitsPerMeter2[0] * y); } if (unitsPerMeter2) { worldspace[1] += y * (unitsPerMeter[1] + unitsPerMeter2[1] * y); } // @ts-ignore const newLngLat = worldToLngLat(worldspace); const newZ = (z0 || 0) + (z || 0); return Number.isFinite(z0) || Number.isFinite(z) ? [newLngLat[0], newLngLat[1], newZ] : newLngLat; } // ATTRIBUTION: // view and projection matrix creation is intentionally kept compatible with // mapbox-gl's implementation to ensure that seamless interoperation // with mapbox and react-map-gl. See: https://github.com/mapbox/mapbox-gl-js export function getViewMatrix({ // Viewport props height, pitch, bearing, altitude, // Pre-calculated parameters scale, center }: { height: number; pitch: number; bearing: number; altitude: number; scale: number; center?: number[]; }) { // VIEW MATRIX: PROJECTS MERCATOR WORLD COORDINATES // Note that mercator world coordinates typically need to be flipped // // Note: As usual, matrix operation orders should be read in reverse // since vectors will be multiplied from the right during transformation const vm = createMat4(); // Move camera to altitude (along the pitch & bearing direction) mat4.translate(vm as mat4, vm as mat4, [0, 0, -altitude]); // Rotate by bearing, and then by pitch (which tilts the view) mat4.rotateX(vm as mat4, vm as mat4, -pitch * DEGREES_TO_RADIANS); mat4.rotateZ(vm as mat4, vm as mat4, bearing * DEGREES_TO_RADIANS); scale /= height; mat4.scale(vm as mat4, vm as mat4, [scale, scale, scale]); if (center) { mat4.translate(vm as mat4, vm as mat4, vec3.negate([] as unknown as vec3, center)); } return vm; } // PROJECTION MATRIX PARAMETERS // Variable fov (in radians) export function getProjectionParameters({ width, height, altitude = DEFAULT_ALTITUDE, pitch = 0, nearZMultiplier = 1, farZMultiplier = 1 }: { width: number; height: number; pitch: number; altitude: number; nearZMultiplier: number; farZMultiplier: number; }) { // Find the distance from the center point to the center top // in altitude units using law of sines. const pitchRadians = pitch * DEGREES_TO_RADIANS; const halfFov = Math.atan(0.5 / altitude); const topHalfSurfaceDistance = (Math.sin(halfFov) * altitude) / Math.sin(Math.min(Math.max(Math.PI / 2 - pitchRadians - halfFov, 0.01), Math.PI - 0.01)); // Calculate z value of the farthest fragment that should be rendered. const farZ = Math.sin(pitchRadians) * topHalfSurfaceDistance + altitude; return { fov: 2 * halfFov, aspect: width / height, focalDistance: altitude, near: nearZMultiplier, far: farZ * farZMultiplier }; } // PROJECTION MATRIX: PROJECTS FROM CAMERA (VIEW) SPACE TO CLIPSPACE // To match mapbox's z buffer: // <= 0.28 - nearZMultiplier: 0.1, farZmultiplier: 1 // >= 0.29 - nearZMultiplier: 1 / height, farZMultiplier: 1.01 export function getProjectionMatrix({ width, height, pitch, altitude, nearZMultiplier, farZMultiplier }: { width: number; height: number; pitch: number; altitude: number; nearZMultiplier: number; farZMultiplier: number; }) { const {fov, aspect, near, far} = getProjectionParameters({ width, height, altitude, pitch, nearZMultiplier, farZMultiplier }); const projectionMatrix = mat4.perspective( [], fov, // fov in radians aspect, // aspect ratio near, // near plane far // far plane ); return projectionMatrix; } // Project flat coordinates to pixels on screen. export function worldToPixels(xyz: number[], pixelProjectionMatrix: number[]) { const [x, y, z = 0] = xyz; assert(Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z)); return transformVector(pixelProjectionMatrix, [x, y, z, 1]); } // Unproject pixels on screen to flat coordinates. export function pixelsToWorld(xyz: number[], pixelUnprojectionMatrix: number[], targetZ = 0) { const [x, y, z] = xyz; assert(Number.isFinite(x) && Number.isFinite(y), 'invalid pixel coordinate'); if (Number.isFinite(z)) { // Has depth component const coord = transformVector(pixelUnprojectionMatrix, [x, y, z, 1]); return coord; } // since we don't know the correct projected z value for the point, // unproject two points to get a line and then find the point on that line with z=0 const coord0 = transformVector(pixelUnprojectionMatrix, [x, y, 0, 1]); const coord1 = transformVector(pixelUnprojectionMatrix, [x, y, 1, 1]); const z0 = coord0[2]; const z1 = coord1[2]; const t = z0 === z1 ? 0 : ((targetZ || 0) - z0) / (z1 - z0); return vec2.lerp([], coord0, coord1, t); } export { default as getBounds } from './get-bounds';