UNPKG

s2-tools

Version:

A collection of geospatial tools primarily designed for WGS84, Web Mercator, and S2.

643 lines 22.8 kB
// References: // - Wikipedia: https://en.wikipedia.org/wiki/Military_Grid_Reference_System // - GEOTRANS: https://earth-info.nga.mil/#geotrans import { degToRad, radToDeg } from '../geometry'; /** UTM zones are grouped, and assigned to one of a group of 6 sets. */ const NUM_100K_SETS = 6; /** The column letters (for easting) of the lower left value, per set. */ const SET_ORIGIN_COLUMN_LETTERS = 'AJSAJS'; /** The row letters (for northing) of the lower left value, per set. */ const SET_ORIGIN_ROW_LETTERS = 'AFAFAF'; /** First eccentricity squared */ const ECC_SQUARED = 0.00669438; /** Scale factor along the central meridian */ const SCALE_FACTOR = 0.9996; /** Semimajor axis (half the width of the earth) in meters */ const SEMI_MAJOR_AXIS = 6378137; /** The easting of the central meridian of each UTM zone */ const EASTING_OFFSET = 500000; /** The northing of the equator for southern hemisphere locations (in UTM) */ const NORTHING_OFFFSET = 10000000; /** UTM zone width in degrees */ const UTM_ZONE_WIDTH = 6; /** Half the width of a UTM zone in degrees */ const HALF_UTM_ZONE_WIDTH = 3; // UTM_ZONE_WIDTH / 2; const A = 65; // A const I = 73; // I const O = 79; // O const V = 86; // V const Z = 90; // Z /** * Convert lat/lon to MGRS. * @param ll - Array with longitude and latitude on a * WGS84 ellipsoid. * @param accuracy - Accuracy in digits (5 for 1 m, 4 for 10 m, 3 for * 100 m, 2 for 1 km, 1 for 10 km or 0 for 100 km). Optional, default is 5. * @returns the MGRS string for the given location and accuracy. */ export function mgrsForward(ll, accuracy) { accuracy = accuracy !== undefined ? accuracy : 5; // default accuracy 1m const { x, y } = ll; if (x < -180 || x > 180) { throw new TypeError(`forward received an invalid longitude of ${x}`); } if (y < -90 || y > 90) { throw new TypeError(`forward received an invalid latitude of ${y}`); } if (y < -80 || y > 84) { throw new TypeError(`forward received a latitude of ${y}, but this library does not support conversions of points in polar regions below 80°S and above 84°N`); } return encode(LLtoUTM(ll), accuracy); } /** * Convert MGRS to lat/lon bounding box. * @param mgrs - MGRS string. * @returns An array with left (longitude), * bottom (latitude), right * (longitude) and top (latitude) values in WGS84, representing the * bounding box for the provided MGRS reference. */ export function mgrsInverse(mgrs) { const bbox = UTMtoLL(decode(mgrs.toUpperCase())); if (bbox === undefined) return undefined; if (bbox.lat !== undefined && bbox.lon !== undefined) { return [bbox.lon, bbox.lat, bbox.lon, bbox.lat]; } return [bbox.left, bbox.bottom, bbox.right, bbox.top]; } /** * Convert MGRS to lat/lon. * @param mgrs - MGRS string. * @returns The center of the MGRS bounding box */ export function mgrsToPoint(mgrs) { const bbox = UTMtoLL(decode(mgrs.toUpperCase())); if (bbox === undefined) return undefined; if (bbox.lat !== undefined && bbox.lon !== undefined) return { x: bbox.lon, y: bbox.lat }; return { x: (bbox.left + bbox.right) / 2, y: (bbox.top + bbox.bottom) / 2 }; } /** * Converts a set of Longitude and Latitude co-ordinates to UTM * using the WGS84 ellipsoid. * @param ll Object literal with lat and lon properties * representing the WGS84 coordinate to be converted. * @returns Object literal containing the UTM value with easting, * northing, zoneNumber and zoneLetter properties, and an optional * accuracy property in digits. Returns null if the conversion failed. */ function LLtoUTM(ll) { const { x: lon, y: lat } = ll; const a = SEMI_MAJOR_AXIS; const latRad = degToRad(lat); const longRad = degToRad(lon); let ZoneNumber; // (int) ZoneNumber = Math.floor((lon + 180) / 6) + 1; //Make sure the longitude 180 is in Zone 60 if (lon === 180) ZoneNumber = 60; // Special zone for Norway if (lat >= 56 && lat < 64 && lon >= 3 && lon < 12) ZoneNumber = 32; // Special zones for Svalbard if (lat >= 72 && lat < 84) { if (lon >= 0 && lon < 9) { ZoneNumber = 31; } else if (lon >= 9 && lon < 21) { ZoneNumber = 33; } else if (lon >= 21 && lon < 33) { ZoneNumber = 35; } else if (lon >= 33 && lon < 42) { ZoneNumber = 37; } } // +HALF_UTM_ZONE_WIDTH puts origin in middle of zone const LongOrigin = (ZoneNumber - 1) * UTM_ZONE_WIDTH - 180 + HALF_UTM_ZONE_WIDTH; const LongOriginRad = degToRad(LongOrigin); const eccPrimeSquared = ECC_SQUARED / (1 - ECC_SQUARED); const N = a / Math.sqrt(1 - ECC_SQUARED * Math.sin(latRad) * Math.sin(latRad)); const T = Math.tan(latRad) * Math.tan(latRad); const C = eccPrimeSquared * Math.cos(latRad) * Math.cos(latRad); const A = Math.cos(latRad) * (longRad - LongOriginRad); const M = a * ((1 - ECC_SQUARED / 4 - (3 * ECC_SQUARED * ECC_SQUARED) / 64 - (5 * ECC_SQUARED * ECC_SQUARED * ECC_SQUARED) / 256) * latRad - ((3 * ECC_SQUARED) / 8 + (3 * ECC_SQUARED * ECC_SQUARED) / 32 + (45 * ECC_SQUARED * ECC_SQUARED * ECC_SQUARED) / 1024) * Math.sin(2 * latRad) + ((15 * ECC_SQUARED * ECC_SQUARED) / 256 + (45 * ECC_SQUARED * ECC_SQUARED * ECC_SQUARED) / 1024) * Math.sin(4 * latRad) - ((35 * ECC_SQUARED * ECC_SQUARED * ECC_SQUARED) / 3072) * Math.sin(6 * latRad)); const UTMEasting = SCALE_FACTOR * N * (A + ((1 - T + C) * A * A * A) / 6 + ((5 - 18 * T + T * T + 72 * C - 58 * eccPrimeSquared) * A * A * A * A * A) / 120) + EASTING_OFFSET; let UTMNorthing = SCALE_FACTOR * (M + N * Math.tan(latRad) * ((A * A) / 2 + ((5 - T + 9 * C + 4 * C * C) * A * A * A * A) / 24 + ((61 - 58 * T + T * T + 600 * C - 330 * eccPrimeSquared) * A * A * A * A * A * A) / 720)); if (lat < 0) UTMNorthing += NORTHING_OFFFSET; return { northing: Math.trunc(UTMNorthing), easting: Math.trunc(UTMEasting), zoneNumber: ZoneNumber, zoneLetter: mgrsGetLetterDesignator(lat), }; } /** * Converts UTM coords to lat/long, using the WGS84 ellipsoid. This is a convenience * class where the Zone can be specified as a single string eg."60N" which * is then broken down into the ZoneNumber and ZoneLetter. * @param utm An object literal with northing, easting, zoneNumber * and zoneLetter properties. If an optional accuracy property is * provided (in meters), a bounding box will be returned instead of * latitude and longitude. * @returns An object literal containing either lat and lon values * (if no accuracy was provided), or top, right, bottom and left values * for the bounding box calculated according to the provided accuracy. * Returns null if the conversion failed. */ function UTMtoLL(utm) { const UTMNorthing = utm.northing; const UTMEasting = utm.easting; const { zoneLetter, zoneNumber } = utm; // check the ZoneNummber is valid if (zoneNumber < 0 || zoneNumber > 60) return; const a = SEMI_MAJOR_AXIS; const e1 = (1 - Math.sqrt(1 - ECC_SQUARED)) / (1 + Math.sqrt(1 - ECC_SQUARED)); // remove 500,000 meter offset for longitude const x = UTMEasting - EASTING_OFFSET; let y = UTMNorthing; // We must know somehow if we are in the Northern or Southern // hemisphere, this is the only time we use the letter So even // if the Zone letter isn't exactly correct it should indicate // the hemisphere correctly if (zoneLetter < 'N') { y -= NORTHING_OFFFSET; // remove offset used for southern hemisphere } // +HALF_UTM_ZONE_WIDTH puts origin in middle of zone const LongOrigin = (zoneNumber - 1) * UTM_ZONE_WIDTH - 180 + HALF_UTM_ZONE_WIDTH; const eccPrimeSquared = ECC_SQUARED / (1 - ECC_SQUARED); const M = y / SCALE_FACTOR; const mu = M / (a * (1 - ECC_SQUARED / 4 - (3 * ECC_SQUARED * ECC_SQUARED) / 64 - (5 * ECC_SQUARED * ECC_SQUARED * ECC_SQUARED) / 256)); const phi1Rad = mu + ((3 * e1) / 2 - (27 * e1 * e1 * e1) / 32) * Math.sin(2 * mu) + ((21 * e1 * e1) / 16 - (55 * e1 * e1 * e1 * e1) / 32) * Math.sin(4 * mu) + ((151 * e1 * e1 * e1) / 96) * Math.sin(6 * mu); // double phi1 = ProjMath.radToDeg(phi1Rad); const N1 = a / Math.sqrt(1 - ECC_SQUARED * Math.sin(phi1Rad) * Math.sin(phi1Rad)); const T1 = Math.tan(phi1Rad) * Math.tan(phi1Rad); const C1 = eccPrimeSquared * Math.cos(phi1Rad) * Math.cos(phi1Rad); const R1 = (a * (1 - ECC_SQUARED)) / Math.pow(1 - ECC_SQUARED * Math.sin(phi1Rad) * Math.sin(phi1Rad), 1.5); const D = x / (N1 * SCALE_FACTOR); let lat = phi1Rad - ((N1 * Math.tan(phi1Rad)) / R1) * ((D * D) / 2 - ((5 + 3 * T1 + 10 * C1 - 4 * C1 * C1 - 9 * eccPrimeSquared) * D * D * D * D) / 24 + ((61 + 90 * T1 + 298 * C1 + 45 * T1 * T1 - 252 * eccPrimeSquared - 3 * C1 * C1) * D * D * D * D * D * D) / 720); lat = radToDeg(lat); let lon = (D - ((1 + 2 * T1 + C1) * D * D * D) / 6 + ((5 - 2 * C1 + 28 * T1 - 3 * C1 * C1 + 8 * eccPrimeSquared + 24 * T1 * T1) * D * D * D * D * D) / 120) / Math.cos(phi1Rad); lon = LongOrigin + radToDeg(lon); let result; if (typeof utm.accuracy === 'number') { const topRight = UTMtoLL({ northing: utm.northing + utm.accuracy, easting: utm.easting + utm.accuracy, zoneLetter: utm.zoneLetter, zoneNumber: utm.zoneNumber, }); result = { top: topRight?.lat ?? lat, right: topRight?.lon ?? lon, bottom: lat, left: lon, }; } else { result = { top: lat, right: lon, bottom: lat, left: lon, lat, lon, }; } return result; } /** * Calculates the MGRS letter designator for the given latitude. * (Not intended for public API, only exported for testing.) * @param latitude - The latitude in WGS84 to get the letter designator * for. * @returns The letter designator. */ export function mgrsGetLetterDesignator(latitude) { if (latitude <= 84 && latitude >= 72) { // the X band is 12 degrees high return 'X'; } else if (latitude < 72 && latitude >= -80) { // Latitude bands are lettered C through X, excluding I and O const bandLetters = 'CDEFGHJKLMNPQRSTUVWX'; const bandHeight = 8; const minLatitude = -80; const index = Math.floor((latitude - minLatitude) / bandHeight); return bandLetters[index]; } //This is here as an error flag to show that the Latitude is //outside MGRS limits return 'Z'; } /** * Encodes a UTM location as MGRS string. * @param utm - An object literal with easting, northing, * zoneLetter, zoneNumber * @param accuracy - Accuracy in digits (0-5). * @returns MGRS string for the given UTM location. */ function encode(utm, accuracy) { // prepend with leading zeroes const seasting = '00000' + utm.easting; const snorthing = '00000' + utm.northing; const seLen = seasting.length - 5; const snLen = snorthing.length - 5; return (utm.zoneNumber + utm.zoneLetter + get100kID(utm.easting, utm.northing, utm.zoneNumber) + seasting.slice(seLen, seLen + accuracy) + snorthing.slice(snLen, snLen + accuracy)); } /** * Get the two letter 100k designator for a given UTM easting, * northing and zone number value. * @param easting - UTM easting * @param northing - UTM northing * @param zoneNumber - UTM zone number * @returns the two letter 100k designator for the given UTM location. */ function get100kID(easting, northing, zoneNumber) { const setParm = get100kSetForZone(zoneNumber); const setColumn = Math.floor(easting / 100000); const setRow = Math.floor(northing / 100000) % 20; return getLetter100kID(setColumn, setRow, setParm); } /** * Given a UTM zone number, figure out the MGRS 100K set it is in. * @param i - An UTM zone number. * @returns the 100k set the UTM zone is in. */ function get100kSetForZone(i) { let setParm = i % NUM_100K_SETS; if (setParm === 0) { setParm = NUM_100K_SETS; } return setParm; } /** * Get the two-letter MGRS 100k designator given information * translated from the UTM northing, easting and zone number. * @param column - the column index as it relates to the MGRS * 100k set spreadsheet, created from the UTM easting. * Values are 1-8. * @param row - the row index as it relates to the MGRS 100k set * spreadsheet, created from the UTM northing value. Values * are from 0-19. * @param parm - the set block, as it relates to the MGRS 100k set * spreadsheet, created from the UTM zone. Values are from * 1-60. * @returns two letter MGRS 100k code. */ function getLetter100kID(column, row, parm) { // colOrigin and rowOrigin are the letters at the origin of the set const index = parm - 1; const colOrigin = SET_ORIGIN_COLUMN_LETTERS.charCodeAt(index); const rowOrigin = SET_ORIGIN_ROW_LETTERS.charCodeAt(index); // colInt and rowInt are the letters to build to return let colInt = colOrigin + column - 1; let rowInt = rowOrigin + row; let rollover = false; if (colInt > Z) { colInt = colInt - Z + A - 1; rollover = true; } if (colInt === I || (colOrigin < I && colInt > I) || ((colInt > I || colOrigin < I) && rollover)) { colInt++; } if (colInt === O || (colOrigin < O && colInt > O) || ((colInt > O || colOrigin < O) && rollover)) { colInt++; if (colInt === I) { colInt++; } } if (colInt > Z) { colInt = colInt - Z + A - 1; } if (rowInt > V) { rowInt = rowInt - V + A - 1; rollover = true; } else { rollover = false; } if (rowInt === I || (rowOrigin < I && rowInt > I) || ((rowInt > I || rowOrigin < I) && rollover)) { rowInt++; } if (rowInt === O || (rowOrigin < O && rowInt > O) || ((rowInt > O || rowOrigin < O) && rollover)) { rowInt++; if (rowInt === I) { rowInt++; } } if (rowInt > V) { rowInt = rowInt - V + A - 1; } const twoLetter = String.fromCharCode(colInt) + String.fromCharCode(rowInt); return twoLetter; } /** * Decode the UTM parameters from a MGRS string. * @param mgrsString - an UPPERCASE coordinate string is expected. * @returns An object literal with easting, northing, zoneLetter, * zoneNumber and accuracy (in meters) properties. */ function decode(mgrsString) { if (mgrsString.length === 0) { throw new TypeError('MGRSPoint coverting from nothing'); } //remove any spaces in MGRS String mgrsString = mgrsString.replace(/ /g, ''); const { length } = mgrsString; let hunK = null; let sb = ''; let testChar; let i = 0; // get Zone number while (!/[A-Z]/.test((testChar = mgrsString.charAt(i)))) { if (i >= 2) { throw new Error(`MGRSPoint bad conversion from: ${mgrsString}`); } sb += testChar; i++; } const zoneNumber = parseInt(sb, 10); if (i === 0 || i + 3 > length) { // A good MGRS string has to be 4-5 digits long, // ##AAA/#AAA at least. throw new Error(`MGRSPoint bad conversion from ${mgrsString}`); } const zoneLetter = mgrsString.charAt(i++); // Should we check the zone letter here? Why not. if (zoneLetter <= 'A' || zoneLetter === 'B' || zoneLetter === 'Y' || zoneLetter >= 'Z' || zoneLetter === 'I' || zoneLetter === 'O') { throw new Error(`MGRSPoint zone letter ${zoneLetter} not handled: ${mgrsString}`); } hunK = mgrsString.substring(i, (i += 2)); const set = get100kSetForZone(zoneNumber); const east100k = getEastingFromChar(hunK.charAt(0), set); let north100k = getNorthingFromChar(hunK.charAt(1), set); // We have a bug where the northing may be 2000000 too low. // How // do we know when to roll over? while (north100k < getMinNorthing(zoneLetter)) { north100k += 2000000; } // calculate the char index for easting/northing separator const remainder = length - i; if (remainder % 2 !== 0) { throw new Error(`MGRSPoint has to have an even number of digits after the zone letter and two 100km letters - front half for easting meters, second half for northing meters ${mgrsString}`); } const sep = remainder / 2; let sepEasting = 0; let sepNorthing = 0; let accuracy = 0; let sepEastingString, sepNorthingString; if (sep > 0) { accuracy = 100000 / Math.pow(10, sep); sepEastingString = mgrsString.substring(i, i + sep); sepEasting = parseFloat(sepEastingString) * accuracy; sepNorthingString = mgrsString.substring(i + sep); sepNorthing = parseFloat(sepNorthingString) * accuracy; } const easting = sepEasting + east100k; const northing = sepNorthing + north100k; return { easting, northing, zoneLetter, zoneNumber, accuracy, }; } /** * Given the first letter from a two-letter MGRS 100k zone, and given the * MGRS table set for the zone number, figure out the easting value that * should be added to the other, secondary easting value. * @param e - The first letter from a two-letter MGRS 100´k zone. * @param set - The MGRS table set for the zone number. * @returns The easting value for the given letter and set. */ function getEastingFromChar(e, set) { // colOrigin is the letter at the origin of the set for the // column let curCol = SET_ORIGIN_COLUMN_LETTERS.charCodeAt(set - 1); let eastingValue = 100000; let rewindMarker = false; while (curCol !== e.charCodeAt(0)) { curCol++; if (curCol === I) curCol++; if (curCol === O) curCol++; if (curCol > Z) { if (rewindMarker) { throw new Error(`Bad character: ${e}`); } curCol = A; rewindMarker = true; } eastingValue += 100000; } return eastingValue; } /** * Given the second letter from a two-letter MGRS 100k zone, and given the * MGRS table set for the zone number, figure out the northing value that * should be added to the other, secondary northing value. You have to * remember that Northings are determined from the equator, and the vertical * cycle of letters mean a 2000000 additional northing meters. This happens * approx. every 18 degrees of latitude. This method does *NOT* count any * additional northings. You have to figure out how many 2000000 meters need * to be added for the zone letter of the MGRS coordinate. * @param n - Second letter of the MGRS 100k zone * @param set - The MGRS table set number, which is dependent on the * UTM zone number. * @returns The northing value for the given letter and set. */ function getNorthingFromChar(n, set) { if (n > 'V') throw new TypeError(`MGRSPoint given invalid Northing ${n}`); // rowOrigin is the letter at the origin of the set for the // column let curRow = SET_ORIGIN_ROW_LETTERS.charCodeAt(set - 1); let northingValue = 0; let rewindMarker = false; while (curRow !== n.charCodeAt(0)) { curRow++; if (curRow === I) { curRow++; } if (curRow === O) { curRow++; } // fixing a bug making whole application hang in this loop // when 'n' is a wrong character if (curRow > V) { if (rewindMarker) { // making sure that this loop ends throw new Error(`Bad character: ${n}`); } curRow = A; rewindMarker = true; } northingValue += 100000; } return northingValue; } /** * The function getMinNorthing returns the minimum northing value of a MGRS * zone. * * Ported from Geotrans' c Lattitude_Band_Value structure table. * @param zoneLetter The MGRS zone to get the min northing for. * @returns - The minimum northing value of the MGRS zone. */ function getMinNorthing(zoneLetter) { let northing; switch (zoneLetter) { case 'C': northing = 1100000; break; case 'D': northing = 2000000; break; case 'E': northing = 2800000; break; case 'F': northing = 3700000; break; case 'G': northing = 4600000; break; case 'H': northing = 5500000; break; case 'J': northing = 6400000; break; case 'K': northing = 7300000; break; case 'L': northing = 8200000; break; case 'M': northing = 9100000; break; case 'N': northing = 0; break; case 'P': northing = 800000; break; case 'Q': northing = 1700000; break; case 'R': northing = 2600000; break; case 'S': northing = 3500000; break; case 'T': northing = 4400000; break; case 'U': northing = 5300000; break; case 'V': northing = 6200000; break; case 'W': northing = 7000000; break; case 'X': northing = 7900000; break; default: northing = -1; } if (northing >= 0) { return northing; } else { throw new TypeError(`Invalid zone letter: ${zoneLetter}`); } } //# sourceMappingURL=mgrs.js.map