123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655 |
- /**
- * Co-ordinate conversion resources.
- *
- * @author j433866 [j433866@gmail.com]
- * @copyright Crown Copyright 2019
- * @license Apache-2.0
- */
- import geohash from "ngeohash";
- import geodesy from "geodesy";
- import OperationError from "../errors/OperationError";
- /**
- * Co-ordinate formats
- */
- export const FORMATS = [
- "Degrees Minutes Seconds",
- "Degrees Decimal Minutes",
- "Decimal Degrees",
- "Geohash",
- "Military Grid Reference System",
- "Ordnance Survey National Grid",
- "Universal Transverse Mercator"
- ];
- /**
- * Formats that should be passed to the conversion module as-is
- */
- const NO_CHANGE = [
- "Geohash",
- "Military Grid Reference System",
- "Ordnance Survey National Grid",
- "Universal Transverse Mercator",
- ];
- /**
- * Convert a given latitude and longitude into a different format.
- *
- * @param {string} input - Input string to be converted
- * @param {string} inFormat - Format of the input coordinates
- * @param {string} inDelim - The delimiter splitting the lat/long of the input
- * @param {string} outFormat - Format to convert to
- * @param {string} outDelim - The delimiter to separate the output with
- * @param {string} includeDir - Whether or not to include the compass direction in the output
- * @param {number} precision - Precision of the result
- * @returns {string} A formatted string of the converted co-ordinates
- */
- export function convertCoordinates (input, inFormat, inDelim, outFormat, outDelim, includeDir, precision) {
- let isPair = false,
- split,
- latlon,
- convLat,
- convLon,
- conv,
- hash,
- utm,
- mgrs,
- osng,
- splitLat,
- splitLong,
- lat,
- lon;
- // Can't have a precision less than 0!
- if (precision < 0) {
- precision = 0;
- }
- if (inDelim === "Auto") {
- // Try to detect a delimiter in the input.
- inDelim = findDelim(input);
- if (inDelim === null) {
- throw new OperationError("Unable to detect the input delimiter automatically.");
- }
- } else if (!inDelim.includes("Direction")) {
- // Convert the delimiter argument value to the actual character
- inDelim = realDelim(inDelim);
- }
- if (inFormat === "Auto") {
- // Try to detect the format of the input data
- inFormat = findFormat(input, inDelim);
- if (inFormat === null) {
- throw new OperationError("Unable to detect the input format automatically.");
- }
- }
- // Convert the output delimiter argument to the real character
- outDelim = realDelim(outDelim);
- if (!NO_CHANGE.includes(inFormat)) {
- if (inDelim.includes("Direction")) {
- // Split on directions
- split = input.split(/[NnEeSsWw]/g);
- if (split[0] === "") {
- // Remove first element if direction preceding
- split = split.slice(1);
- }
- } else {
- split = input.split(inDelim);
- }
- // Replace any co-ordinate symbols with spaces so we can split on them later
- for (let i = 0; i < split.length; i++) {
- split[i] = split[i].replace(/[°˝´'"]/g, " ");
- }
- if (split.length > 1) {
- isPair = true;
- }
- } else {
- // Remove any delimiters from the input
- input = input.replace(inDelim, "");
- isPair = true;
- }
- // Conversions from the input format into a geodesy latlon object
- switch (inFormat) {
- case "Geohash":
- hash = geohash.decode(input.replace(/[^A-Za-z0-9]/g, ""));
- latlon = new geodesy.LatLonEllipsoidal(hash.latitude, hash.longitude);
- break;
- case "Military Grid Reference System":
- utm = geodesy.Mgrs.parse(input.replace(/[^A-Za-z0-9]/g, "")).toUtm();
- latlon = utm.toLatLonE();
- break;
- case "Ordnance Survey National Grid":
- osng = geodesy.OsGridRef.parse(input.replace(/[^A-Za-z0-9]/g, ""));
- latlon = geodesy.OsGridRef.osGridToLatLon(osng);
- break;
- case "Universal Transverse Mercator":
- // Geodesy needs a space between the first 2 digits and the next letter
- if (/^[\d]{2}[A-Za-z]/.test(input)) {
- input = input.slice(0, 2) + " " + input.slice(2);
- }
- utm = geodesy.Utm.parse(input);
- latlon = utm.toLatLonE();
- break;
- case "Degrees Minutes Seconds":
- if (isPair) {
- // Split up the lat/long into degrees / minutes / seconds values
- splitLat = splitInput(split[0]);
- splitLong = splitInput(split[1]);
- if (splitLat.length >= 3 && splitLong.length >= 3) {
- lat = convDMSToDD(splitLat[0], splitLat[1], splitLat[2], 10);
- lon = convDMSToDD(splitLong[0], splitLong[1], splitLong[2], 10);
- latlon = new geodesy.LatLonEllipsoidal(lat.degrees, lon.degrees);
- } else {
- throw new OperationError("Invalid co-ordinate format for Degrees Minutes Seconds");
- }
- } else {
- // Not a pair, so only try to convert one set of co-ordinates
- splitLat = splitInput(split[0]);
- if (splitLat.length >= 3) {
- lat = convDMSToDD(splitLat[0], splitLat[1], splitLat[2]);
- latlon = new geodesy.LatLonEllipsoidal(lat.degrees, lat.degrees);
- } else {
- throw new OperationError("Invalid co-ordinate format for Degrees Minutes Seconds");
- }
- }
- break;
- case "Degrees Decimal Minutes":
- if (isPair) {
- splitLat = splitInput(split[0]);
- splitLong = splitInput(split[1]);
- if (splitLat.length !== 2 || splitLong.length !== 2) {
- throw new OperationError("Invalid co-ordinate format for Degrees Decimal Minutes.");
- }
- // Convert to decimal degrees, and then convert to a geodesy object
- lat = convDDMToDD(splitLat[0], splitLat[1], 10);
- lon = convDDMToDD(splitLong[0], splitLong[1], 10);
- latlon = new geodesy.LatLonEllipsoidal(lat.degrees, lon.degrees);
- } else {
- // Not a pair, so only try to convert one set of co-ordinates
- splitLat = splitInput(input);
- if (splitLat.length !== 2) {
- throw new OperationError("Invalid co-ordinate format for Degrees Decimal Minutes.");
- }
- lat = convDDMToDD(splitLat[0], splitLat[1], 10);
- latlon = new geodesy.LatLonEllipsoidal(lat.degrees, lat.degrees);
- }
- break;
- case "Decimal Degrees":
- if (isPair) {
- splitLat = splitInput(split[0]);
- splitLong = splitInput(split[1]);
- if (splitLat.length !== 1 || splitLong.length !== 1) {
- throw new OperationError("Invalid co-ordinate format for Decimal Degrees.");
- }
- latlon = new geodesy.LatLonEllipsoidal(splitLat[0], splitLong[0]);
- } else {
- // Not a pair, so only try to convert one set of co-ordinates
- splitLat = splitInput(split[0]);
- if (splitLat.length !== 1) {
- throw new OperationError("Invalid co-ordinate format for Decimal Degrees.");
- }
- latlon = new geodesy.LatLonEllipsoidal(splitLat[0], splitLat[0]);
- }
- break;
- default:
- throw new OperationError(`Unknown input format '${inFormat}'`);
- }
- // Everything is now a geodesy latlon object
- // These store the latitude and longitude as decimal
- if (inFormat.includes("Degrees")) {
- // If the input string contains directions, we need to check if they're S or W.
- // If either of the directions are, we should make the decimal value negative
- const dirs = input.toUpperCase().match(/[NESW]/g);
- if (dirs && dirs.length >= 1) {
- // Make positive lat/lon values with S/W directions into negative values
- if (dirs[0] === "S" || dirs[0] === "W" && latlon.lat > 0) {
- latlon.lat = -latlon.lat;
- }
- if (dirs.length >= 2) {
- if (dirs[1] === "S" || dirs[1] === "W" && latlon.lon > 0) {
- latlon.lon = -latlon.lon;
- }
- }
- }
- }
- // Try to find the compass directions of the lat and long
- const [latDir, longDir] = findDirs(latlon.lat + "," + latlon.lon, ",");
- // Output conversions for each output format
- switch (outFormat) {
- case "Decimal Degrees":
- // We could use the built in latlon.toString(),
- // but this makes adjusting the output harder
- lat = convDDToDD(latlon.lat, precision);
- lon = convDDToDD(latlon.lon, precision);
- convLat = lat.string;
- convLon = lon.string;
- break;
- case "Degrees Decimal Minutes":
- lat = convDDToDDM(latlon.lat, precision);
- lon = convDDToDDM(latlon.lon, precision);
- convLat = lat.string;
- convLon = lon.string;
- break;
- case "Degrees Minutes Seconds":
- lat = convDDToDMS(latlon.lat, precision);
- lon = convDDToDMS(latlon.lon, precision);
- convLat = lat.string;
- convLon = lon.string;
- break;
- case "Geohash":
- convLat = geohash.encode(latlon.lat, latlon.lon, precision);
- break;
- case "Military Grid Reference System":
- utm = latlon.toUtm();
- mgrs = utm.toMgrs();
- // MGRS wants a precision that's an even number between 2 and 10
- if (precision % 2 !== 0) {
- precision = precision + 1;
- }
- if (precision > 10) {
- precision = 10;
- }
- convLat = mgrs.toString(precision);
- break;
- case "Ordnance Survey National Grid":
- osng = geodesy.OsGridRef.latLonToOsGrid(latlon);
- if (osng.toString() === "") {
- throw new OperationError("Could not convert co-ordinates to OS National Grid. Are the co-ordinates in range?");
- }
- // OSNG wants a precision that's an even number between 2 and 10
- if (precision % 2 !== 0) {
- precision = precision + 1;
- }
- if (precision > 10) {
- precision = 10;
- }
- convLat = osng.toString(precision);
- break;
- case "Universal Transverse Mercator":
- utm = latlon.toUtm();
- convLat = utm.toString(precision);
- break;
- }
- if (convLat === undefined) {
- throw new OperationError("Error converting co-ordinates.");
- }
- if (outFormat.includes("Degrees")) {
- // Format DD/DDM/DMS for output
- // If we're outputting a compass direction, remove the negative sign
- if (latDir === "S" && includeDir !== "None") {
- convLat = convLat.replace("-", "");
- }
- if (longDir === "W" && includeDir !== "None") {
- convLon = convLon.replace("-", "");
- }
- let outConv = "";
- if (includeDir === "Before") {
- outConv += latDir + " ";
- }
- outConv += convLat;
- if (includeDir === "After") {
- outConv += " " + latDir;
- }
- outConv += outDelim;
- if (isPair) {
- if (includeDir === "Before") {
- outConv += longDir + " ";
- }
- outConv += convLon;
- if (includeDir === "After") {
- outConv += " " + longDir;
- }
- outConv += outDelim;
- }
- conv = outConv;
- } else {
- conv = convLat + outDelim;
- }
- return conv;
- }
- /**
- * Split up the input using a space or degrees signs, and sanitise the result
- *
- * @param {string} input - The input data to be split
- * @returns {number[]} An array of the different items in the string, stored as floats
- */
- function splitInput (input){
- const split = [];
- input.split(/\s+/).forEach(item => {
- // Remove any character that isn't a digit, decimal point or negative sign
- item = item.replace(/[^0-9.-]/g, "");
- if (item.length > 0){
- // Turn the item into a float
- split.push(parseFloat(item));
- }
- });
- return split;
- }
- /**
- * Convert Degrees Minutes Seconds to Decimal Degrees
- *
- * @param {number} degrees - The degrees of the input co-ordinates
- * @param {number} minutes - The minutes of the input co-ordinates
- * @param {number} seconds - The seconds of the input co-ordinates
- * @param {number} precision - The precision the result should be rounded to
- * @returns {{string: string, degrees: number}} An object containing the raw converted value (obj.degrees), and a formatted string version (obj.string)
- */
- function convDMSToDD (degrees, minutes, seconds, precision){
- const absDegrees = Math.abs(degrees);
- let conv = absDegrees + (minutes / 60) + (seconds / 3600);
- let outString = round(conv, precision) + "°";
- if (isNegativeZero(degrees) || degrees < 0) {
- conv = -conv;
- outString = "-" + outString;
- }
- return {
- "degrees": conv,
- "string": outString
- };
- }
- /**
- * Convert Decimal Degrees Minutes to Decimal Degrees
- *
- * @param {number} degrees - The input degrees to be converted
- * @param {number} minutes - The input minutes to be converted
- * @param {number} precision - The precision which the result should be rounded to
- * @returns {{string: string, degrees: number}} An object containing the raw converted value (obj.degrees), and a formatted string version (obj.string)
- */
- function convDDMToDD (degrees, minutes, precision) {
- const absDegrees = Math.abs(degrees);
- let conv = absDegrees + minutes / 60;
- let outString = round(conv, precision) + "°";
- if (isNegativeZero(degrees) || degrees < 0) {
- conv = -conv;
- outString = "-" + outString;
- }
- return {
- "degrees": conv,
- "string": outString
- };
- }
- /**
- * Convert Decimal Degrees to Decimal Degrees
- *
- * Doesn't affect the input, just puts it into an object
- * @param {number} degrees - The input degrees to be converted
- * @param {number} precision - The precision which the result should be rounded to
- * @returns {{string: string, degrees: number}} An object containing the raw converted value (obj.degrees), and a formatted string version (obj.string)
- */
- function convDDToDD (degrees, precision) {
- return {
- "degrees": degrees,
- "string": round(degrees, precision) + "°"
- };
- }
- /**
- * Convert Decimal Degrees to Degrees Minutes Seconds
- *
- * @param {number} decDegrees - The input data to be converted
- * @param {number} precision - The precision which the result should be rounded to
- * @returns {{string: string, degrees: number, minutes: number, seconds: number}} An object containing the raw converted value as separate numbers (.degrees, .minutes, .seconds), and a formatted string version (obj.string)
- */
- function convDDToDMS (decDegrees, precision) {
- const absDegrees = Math.abs(decDegrees);
- let degrees = Math.floor(absDegrees);
- const minutes = Math.floor(60 * (absDegrees - degrees)),
- seconds = round(3600 * (absDegrees - degrees) - 60 * minutes, precision);
- let outString = degrees + "° " + minutes + "' " + seconds + "\"";
- if (isNegativeZero(decDegrees) || decDegrees < 0) {
- degrees = -degrees;
- outString = "-" + outString;
- }
- return {
- "degrees": degrees,
- "minutes": minutes,
- "seconds": seconds,
- "string": outString
- };
- }
- /**
- * Convert Decimal Degrees to Degrees Decimal Minutes
- *
- * @param {number} decDegrees - The input degrees to be converted
- * @param {number} precision - The precision the input data should be rounded to
- * @returns {{string: string, degrees: number, minutes: number}} An object containing the raw converted value as separate numbers (.degrees, .minutes), and a formatted string version (obj.string)
- */
- function convDDToDDM (decDegrees, precision) {
- const absDegrees = Math.abs(decDegrees);
- let degrees = Math.floor(absDegrees);
- const minutes = absDegrees - degrees,
- decMinutes = round(minutes * 60, precision);
- let outString = degrees + "° " + decMinutes + "'";
- if (decDegrees < 0 || isNegativeZero(decDegrees)) {
- degrees = -degrees;
- outString = "-" + outString;
- }
- return {
- "degrees": degrees,
- "minutes": decMinutes,
- "string": outString,
- };
- }
- /**
- * Finds and returns the compass directions in an input string
- *
- * @param {string} input - The input co-ordinates containing the direction
- * @param {string} delim - The delimiter separating latitide and longitude
- * @returns {string[]} String array containing the latitude and longitude directions
- */
- export function findDirs(input, delim) {
- const upperInput = input.toUpperCase();
- const dirExp = new RegExp(/[NESW]/g);
- const dirs = upperInput.match(dirExp);
- if (dirs) {
- // If there's actually compass directions
- // in the input, use these to work out the direction
- if (dirs.length <= 2 && dirs.length >= 1) {
- return dirs.length === 2 ? [dirs[0], dirs[1]] : [dirs[0], ""];
- }
- }
- // Nothing was returned, so guess the directions
- let lat = upperInput,
- long,
- latDir = "",
- longDir = "";
- if (!delim.includes("Direction")) {
- if (upperInput.includes(delim)) {
- const split = upperInput.split(delim);
- if (split.length >= 1) {
- if (split[0] !== "") {
- lat = split[0];
- }
- if (split.length >= 2 && split[1] !== "") {
- long = split[1];
- }
- }
- }
- } else {
- const split = upperInput.split(dirExp);
- if (split.length > 1) {
- lat = split[0] === "" ? split[1] : split[0];
- if (split.length > 2 && split[2] !== "") {
- long = split[2];
- }
- }
- }
- if (lat) {
- lat = parseFloat(lat);
- latDir = lat < 0 ? "S" : "N";
- }
- if (long) {
- long = parseFloat(long);
- longDir = long < 0 ? "W" : "E";
- }
- return [latDir, longDir];
- }
- /**
- * Detects the co-ordinate format of the input data
- *
- * @param {string} input - The input data whose format we need to detect
- * @param {string} delim - The delimiter separating the data in input
- * @returns {string} The input format
- */
- export function findFormat (input, delim) {
- let testData;
- const mgrsPattern = new RegExp(/^[0-9]{2}\s?[C-HJ-NP-X]{1}\s?[A-HJ-NP-Z][A-HJ-NP-V]\s?[0-9\s]+/),
- osngPattern = new RegExp(/^[A-HJ-Z]{2}\s+[0-9\s]+$/),
- geohashPattern = new RegExp(/^[0123456789BCDEFGHJKMNPQRSTUVWXYZ]+$/),
- utmPattern = new RegExp(/^[0-9]{2}\s?[C-HJ-NP-X]\s[0-9.]+\s?[0-9.]+$/),
- degPattern = new RegExp(/[°'"]/g);
- input = input.trim();
- if (delim !== null && delim.includes("Direction")) {
- const split = input.split(/[NnEeSsWw]/);
- if (split.length > 1) {
- testData = split[0] === "" ? split[1] : split[0];
- }
- } else if (delim !== null && delim !== "") {
- if (input.includes(delim)) {
- const split = input.split(delim);
- if (split.length > 1) {
- testData = split[0] === "" ? split[1] : split[0];
- }
- } else {
- testData = input;
- }
- }
- // Test non-degrees formats
- if (!degPattern.test(input)) {
- const filteredInput = input.toUpperCase().replace(delim, "");
- if (utmPattern.test(filteredInput)) {
- return "Universal Transverse Mercator";
- }
- if (mgrsPattern.test(filteredInput)) {
- return "Military Grid Reference System";
- }
- if (osngPattern.test(filteredInput)) {
- return "Ordnance Survey National Grid";
- }
- if (geohashPattern.test(filteredInput)) {
- return "Geohash";
- }
- }
- // Test DMS/DDM/DD formats
- if (testData !== undefined) {
- const split = splitInput(testData);
- switch (split.length){
- case 3:
- return "Degrees Minutes Seconds";
- case 2:
- return "Degrees Decimal Minutes";
- case 1:
- return "Decimal Degrees";
- }
- }
- return null;
- }
- /**
- * Automatically find the delimeter type from the given input
- *
- * @param {string} input
- * @returns {string} Delimiter type
- */
- export function findDelim (input) {
- input = input.trim();
- const delims = [",", ";", ":"];
- const testDir = input.match(/[NnEeSsWw]/g);
- if (testDir !== null && testDir.length > 0 && testDir.length < 3) {
- // Possibly contains a direction
- const splitInput = input.split(/[NnEeSsWw]/);
- if (splitInput.length <= 3 && splitInput.length > 0) {
- // If there's 3 splits (one should be empty), then assume we have directions
- if (splitInput[0] === "") {
- return "Direction Preceding";
- } else if (splitInput[splitInput.length - 1] === "") {
- return "Direction Following";
- }
- }
- }
- // Loop through the standard delimiters, and try to find them in the input
- for (let i = 0; i < delims.length; i++) {
- const delim = delims[i];
- if (input.includes(delim)) {
- const splitInput = input.split(delim);
- if (splitInput.length <= 3 && splitInput.length > 0) {
- // Don't want to try and convert more than 2 co-ordinates
- return delim;
- }
- }
- }
- return null;
- }
- /**
- * Gets the real string for a delimiter name.
- *
- * @param {string} delim The delimiter to be matched
- * @returns {string}
- */
- export function realDelim (delim) {
- return {
- "Auto": "Auto",
- "Space": " ",
- "\\n": "\n",
- "Comma": ",",
- "Semi-colon": ";",
- "Colon": ":"
- }[delim];
- }
- /**
- * Returns true if a zero is negative
- *
- * @param {number} zero
- * @returns {boolean}
- */
- function isNegativeZero(zero) {
- return zero === 0 && (1/zero < 0);
- }
- /**
- * Rounds a number to a specified number of decimal places
- *
- * @param {number} input - The number to be rounded
- * @param {precision} precision - The number of decimal places the number should be rounded to
- * @returns {number}
- */
- function round(input, precision) {
- precision = Math.pow(10, precision);
- return Math.round(input * precision) / precision;
- }
|