mercator-proj
Version:
[](https://travis-ci.com/sakitam-gis/mercator-proj) [ • 10.1 kB
text/typescript
// @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';