// TODO - THE UTILITIES IN THIS FILE SHOULD BE IMPORTED FROM WEB-MERCATOR-VIEWPORT MODULE import { createMat4, transformVector, clamp, log2 } from "./math-utils.js"; import { mat4, vec2, vec3 } from '@math.gl/core'; import { assert } from "./assert.js"; // 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; // Latitude that makes a square world, 2 * atan(E ** PI) - PI / 2 export const MAX_LATITUDE = 85.051129; // Mapbox default altitude export const DEFAULT_ALTITUDE = 1.5; /** Logarithimic zoom to linear scale **/ export function zoomToScale(zoom) { return Math.pow(2, zoom); } /** Linear scale to logarithimic zoom **/ export function scaleToZoom(scale) { return 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(lngLat) { const [lng, lat] = lngLat; 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 * * @param xy - array with [x,y] members * representing point on projected map plane * @return - array with [x,y] of point on sphere. * Has toArray method if you need a GeoJSON Array. * Per cartographic tradition, lat and lon are specified as degrees. */ export function worldToLngLat(xy) { const [x, y] = xy; 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(options) { const { latitude } = options; assert(Number.isFinite(latitude)); const latCosine = Math.cos(latitude * DEGREES_TO_RADIANS); return scaleToZoom(EARTH_CIRCUMFERENCE * latCosine) - 9; } /** * Calculate the conversion from meter to common units at a given latitude * This is a cheaper version of `getDistanceScales` * @param latitude center latitude in degrees * @returns common units per meter */ export function unitsPerMeter(latitude) { const latCosine = Math.cos(latitude * DEGREES_TO_RADIANS); return TILE_SIZE / EARTH_CIRCUMFERENCE / latCosine; } /** * 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(options) { const { latitude, longitude, highPrecision = false } = options; assert(Number.isFinite(latitude) && Number.isFinite(longitude)); 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 */ const result = { unitsPerMeter: [altUnitsPerMeter, altUnitsPerMeter, altUnitsPerMeter], metersPerUnit: [1 / altUnitsPerMeter, 1 / altUnitsPerMeter, 1 / altUnitsPerMeter], unitsPerDegree: [unitsPerDegreeX, unitsPerDegreeY, altUnitsPerMeter], 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, xyz) { const [longitude, latitude, z0] = lngLatZ; const [x, y, z] = xyz; // eslint-disable-next-line no-shadow const { unitsPerMeter, unitsPerMeter2 } = getDistanceScales({ longitude, latitude, highPrecision: true }); const worldspace = lngLatToWorld(lngLatZ); worldspace[0] += x * (unitsPerMeter[0] + unitsPerMeter2[0] * y); worldspace[1] += y * (unitsPerMeter[1] + unitsPerMeter2[1] * y); const newLngLat = worldToLngLat(worldspace); const newZ = (z0 || 0) + (z || 0); return Number.isFinite(z0) || Number.isFinite(z) ? [newLngLat[0], newLngLat[1], newZ] : newLngLat; } /** * * 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(options) { const { // Viewport props height, pitch, bearing, altitude, // Pre-calculated parameters scale, center } = options; // 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, vm, [0, 0, -altitude]); // Rotate by bearing, and then by pitch (which tilts the view) mat4.rotateX(vm, vm, -pitch * DEGREES_TO_RADIANS); mat4.rotateZ(vm, vm, bearing * DEGREES_TO_RADIANS); const relativeScale = scale / height; mat4.scale(vm, vm, [relativeScale, relativeScale, relativeScale]); if (center) { mat4.translate(vm, vm, vec3.negate([], center)); } return vm; } /** * Calculates mapbox compatible projection matrix from parameters * * @param options.width Width of "viewport" or window * @param options.height Height of "viewport" or window * @param options.scale Scale at the current zoom * @param options.center Offset of the target, vec3 in world space * @param options.offset Offset of the focal point, vec2 in screen space * @param options.pitch Camera angle in degrees (0 is straight down) * @param options.fovy field of view in degrees * @param options.altitude if provided, field of view is calculated using `altitudeToFovy()` * @param options.nearZMultiplier control z buffer * @param options.farZMultiplier control z buffer * @returns project parameters object */ export function getProjectionParameters(options) { const { width, height, altitude, pitch = 0, offset, center, scale, nearZMultiplier = 1, farZMultiplier = 1 } = options; let { fovy = altitudeToFovy(DEFAULT_ALTITUDE) } = options; // For back-compatibility allow field of view to be // derived from altitude if (altitude !== undefined) { fovy = altitudeToFovy(altitude); } const fovRadians = fovy * DEGREES_TO_RADIANS; const pitchRadians = pitch * DEGREES_TO_RADIANS; // Distance from camera to the target const focalDistance = fovyToAltitude(fovy); let cameraToSeaLevelDistance = focalDistance; if (center) { cameraToSeaLevelDistance += (center[2] * scale) / Math.cos(pitchRadians) / height; } const fovAboveCenter = fovRadians * (0.5 + (offset ? offset[1] : 0) / height); // Find the distance from the center point to the center top // in focal distance units using law of sines. const topHalfSurfaceDistance = (Math.sin(fovAboveCenter) * cameraToSeaLevelDistance) / Math.sin(clamp(Math.PI / 2 - pitchRadians - fovAboveCenter, 0.01, Math.PI - 0.01)); // Calculate z distance of the farthest fragment that should be rendered. const furthestDistance = Math.sin(pitchRadians) * topHalfSurfaceDistance + cameraToSeaLevelDistance; // Matches mapbox limit const horizonDistance = cameraToSeaLevelDistance * 10; // Calculate z value of the farthest fragment that should be rendered. const farZ = Math.min(furthestDistance * farZMultiplier, horizonDistance); return { fov: fovRadians, aspect: width / height, focalDistance, near: nearZMultiplier, far: farZ }; } /** * CALCULATE 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 * * @param options Viewport options * @param options.width Width of "viewport" or window * @param options.height Height of "viewport" or window * @param options.scale Scale at the current zoom * @param options.center Offset of the target, vec3 in world space * @param options.offset Offset of the focal point, vec2 in screen space * @param options.pitch Camera angle in degrees (0 is straight down) * @param options.fovy field of view in degrees * @param options.altitude if provided, field of view is calculated using `altitudeToFovy()` * @param options.nearZMultiplier control z buffer * @param options.farZMultiplier control z buffer * @returns 4x4 projection matrix */ export function getProjectionMatrix(options) { const { fov, aspect, near, far } = getProjectionParameters(options); const projectionMatrix = mat4.perspective([], fov, // fov in radians aspect, // aspect ratio near, // near plane far // far plane ); return projectionMatrix; } /** * * Convert an altitude to field of view such that the * focal distance is equal to the altitude * * @param altitude - altitude of camera in screen units * @return fovy field of view in degrees */ export function altitudeToFovy(altitude) { return 2 * Math.atan(0.5 / altitude) * RADIANS_TO_DEGREES; } /** * * Convert an field of view such that the * focal distance is equal to the altitude * * @param fovy - field of view in degrees * @return altitude altitude of camera in screen units */ export function fovyToAltitude(fovy) { return 0.5 / Math.tan(0.5 * fovy * DEGREES_TO_RADIANS); } // Project flat coordinates to pixels on screen. export function worldToPixels(xyz, pixelProjectionMatrix) { 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. * * @param xyz - pixel coordinate on screen. * @param pixelUnprojectionMatrix - unprojection matrix 4x4 * @param targetZ - if pixel coordinate does not have a 3rd component (depth), * targetZ is used as the elevation plane to unproject onto * @return [x, y, Z] flat coordinates on 512*512 Mercator Zoom 0 tile. */ export function pixelsToWorld(xyz, pixelUnprojectionMatrix, 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); } //# sourceMappingURL=web-mercator-utils.js.map