├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── dms.js ├── latlon-ellipsoidal-datum.js ├── latlon-ellipsoidal-referenceframe-txparams.js ├── latlon-ellipsoidal-referenceframe.js ├── latlon-ellipsoidal-vincenty.js ├── latlon-ellipsoidal.js ├── latlon-nvector-ellipsoidal.js ├── latlon-nvector-spherical.js ├── latlon-spherical.js ├── mgrs.js ├── osgridref.js ├── package.json ├── test ├── dms-tests.js ├── geodesy-test.html ├── latlon-ellipsoidal-datum-tests.js ├── latlon-ellipsoidal-referenceframe-tests.js ├── latlon-ellipsoidal-tests.js ├── latlon-ellipsoidal-vincenty-tests.js ├── latlon-nvector-ellipsoidal-tests.js ├── latlon-nvector-spherical-tests.js ├── latlon-spherical-tests.js ├── os-gridref-tests.js ├── spherical-errors.js ├── utm-mgrs-tests.js └── vector3d-tests.js ├── utm.js └── vector3d.js /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - node 5 | - lts/* 6 | - 14 7 | 8 | os: 9 | - linux 10 | - osx 11 | # windows - removed 'cos travis have made non-backward-compatible move to nvs on windows (travis-ci.community/t/12393) - sigh! 12 | dist: jammy # as of Sep 2022 travis defaults Linux to xenial, which is incompatible with node.js 18.x.x 13 | osx_image: xcode13.2 # as of Sep 2022 travis defaults macOS to 10.13, which is incompatible with node.js 18.x.x 14 | 15 | after_success: 16 | - c8 -r text-lcov npm test | coveralls 17 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Unreleased 4 | 5 | ### Fixed 6 | 7 | - Truncate MGRS easting / northing values to max 1 metre resolution 8 | - Fix UTM constructor northing range check 9 | - Fix Mgrs.toUtm() edge case at zone boundaries (e.g. @ 64°S,0°E) 10 | - Fix rounding error in Utm.toMgrs() which caused UTM for 80°S,0°E to fail 11 | - Allow single-digit zone when parsing MGRS grid reference [#104] 12 | 13 | ## [2.4.0] - 2022-03-16 14 | 15 | ### Fixed 16 | 17 | - Fix check for coincident points (previously < ≈95mm got treated as coincident) 18 | - Add check for null arguments to LatLonEllipsoidal constructor 19 | 20 | ### Added 21 | 22 | - LatLonNvectorSpherical.centreOf() 23 | 24 | ## [2.3.0] - 2021-11-16 25 | 26 | ### Fixed 27 | 28 | - Fix parsing of 'H' 500km squares (Scottish islands) [#96] 29 | - Fix Dms.wrap90(), Dms.wrap180() to work for all -ve degrees 30 | - LatLon_OsGridRef: Override super.convertDatum() 31 | 32 | ### Added 33 | 34 | - LatLonEllipsoidal_Vincenty.intermediatePointTo() 35 | - Extra type-checking (LatLonEllipsoidal_Vincenty.direct, LatLonNvectorSpherical.isEnclosedBy) 36 | 37 | ## [2.2.1] - 2020-04-22 38 | 39 | ### Fixed 40 | 41 | - Coerce textual lat/long to numeric in latlon-spherical 42 | - Return crossTrackDistance / alongTrackDistance of 0 when 'this' point equals start point 43 | - Round UTM to nm rather than (erroneous) μm 44 | - Fix (rare) rounding error issue in intersection() [#71] 45 | - Fix (edgecase) gross error in MGRS -> UTM conversion [#73] 46 | - Return 0 rather than NaN for cross-track / along-track distance of coincident points [#76] 47 | - Remove tests from published package 48 | 49 | ## [2.2.0] - 2019-07-08 50 | 51 | ### Fixed 52 | 53 | - Fix vincenty inverse calculation for antipodal points 54 | - Provide convertDatum() method on a LatLon obtained from Utm.toLatLon() 55 | 56 | ### Added 57 | 58 | - Option to override UTM zone in LatLon.toUtm(), option to suppress UTM easting/northing checks 59 | - ETRS89 datum (≡ WGS84 @ 1m level) 60 | 61 | ## [2.1.0] - 2019-06-03 62 | 63 | ### Added 64 | 65 | - Latlon-ellipsoidal-datum.js:Cartesian_Datum.convertDatum() 66 | 67 | ### Deprecated 68 | 69 | - datum parameter to latlon-ellipsoidal-datum.js:Cartesian_Datum.toLatLon() 70 | 71 | ## [2.0.1] - 2019-04-10 72 | 73 | ### Fixed 74 | 75 | - Add missing n-vector spherical alongTrackDistanceTo() method 76 | - Add missing .toUtm() method to LatLon object returned by Utm.toLatLon() 77 | - Fix n-vector spherical isWithinExtent() for point in different hemisphere 78 | - Fix vector3d angleTo() for case where plane normal n is in the plane 79 | - Rationalise/harmonise exception messages 80 | 81 | ### Added 82 | 83 | - README ‘docs’ badge with link to documentation 84 | 85 | ## [2.0.0] - 2019-02-14 86 | 87 | ### Changed 88 | 89 | - Restructured to use ES modules, ES2015 syntax 90 | - Separated n-vector functions into spherical / ellipsoidal 91 | - General rationalisation of API 92 | 93 | ### Added 94 | 95 | - Modern terrestrial reference frames (TRFs) to complement historical datums 96 | - LatLon.parse() methods 97 | - latlon.toString() numeric format ‘n’ 98 | 99 | ### Breaking 100 | 101 | - LatLon is now a class, so the new operator is no longer optional on the constructor 102 | - latlon.bearingTo() is now latlon.initialBearingTo() 103 | - latlon.toString() defaults to ‘d’ in place of ‘dms’ 104 | - LatLon.ellipse, LatLon.datum are now LatLon.ellipses, LatLon.datums 105 | - Dms.parseDMS() is now simply Dms.parse() 106 | - Dms.toDMS() is now Dms.toDms() 107 | - Dms.defaultSeparator (between degree, minute, second values) defaults to ‘narrow no-break space’ in place of no space 108 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Chris Veness 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Geodesy functions 2 | ================= 3 | 4 | [![Build Status](https://travis-ci.com/chrisveness/geodesy.svg?branch=master)](https://app.travis-ci.com/github/chrisveness/geodesy) 5 | [![Coverage Status](https://coveralls.io/repos/github/chrisveness/geodesy/badge.svg)](https://coveralls.io/github/chrisveness/geodesy) 6 | [![Documentation](https://img.shields.io/badge/docs-www.movable--type.co.uk%2Fscripts%2Fgeodesy--library.html-lightgrey.svg)](https://www.movable-type.co.uk/scripts/geodesy-library.html) 7 | 8 | These libraries started life (a long time ago) as simple ‘latitude/longitude’ code fragments 9 | covering distances and bearings, intended to help people who had little experience of geodesy, and 10 | perhaps limited programming experience. 11 | 12 | The intention was to have clear, simple illustrative code samples which could be adapted and re-used 13 | in other projects (whether those be coded in JavaScript, Java, C++, Excel VBA, or anything else...). 14 | With its untyped C-style syntax, JavaScript reads remarkably close to pseudo-code, exposing the 15 | algorithms with a minimum of syntactic distractions 16 | 17 | While still valid for that purpose, they have grown since then into considerable libraries, based 18 | around: 19 | - simpler **trig**-based functions (distance, bearing, etc) based on a **spherical earth** model 20 | - more sophisticated **trig**-based functions (distance, bearing, etc) based on a 21 | more accurate **ellipsoidal earth** model 22 | - **vector**-based functions mostly based on a **spherical** earth model, with some **ellipsoidal** 23 | functions 24 | 25 | Complementing these are various mapping-related functions covering: 26 | - UTM coordinates & MGRS grid references 27 | - UK Ordnance Survey (OSGB) national grid references 28 | 29 | And also functions for historical datum conversions (such as between NAD83, OSGB36, Irl1975, 30 | etc) and modern reference frame conversions (such as ITRF2014, ETRF2000, GDA94, etc), 31 | and conversions between geodetic (latitude/longitude) coordinates and geocentric cartesian (x/y/z) 32 | coordinates. 33 | 34 | There are also supporting libraries: 35 | - 3d vector manipulation functions (supporting cartesian (x/y/z) coordinates and n-vector geodesy) 36 | - functions for conversion between decimal degrees and (sexagesimal) degrees/minutes/seconds 37 | 38 | The spherical-earth model provides simple formulae covering most ‘everyday’ accuracy requirements; 39 | the ellipsoidal-earth model provides more accurate formulae at the expense of complexity. The 40 | vector-based functions provide an alternative approach to the trig-based functions, with some 41 | overlapping functionality; which one to use may depend on availability of related functions or on 42 | other considerations. 43 | 44 | These functions are as language-agnostic as possible, avoiding excessive use of 45 | JavaScript-specific language features which would not be recognised by users of other languages 46 | (and which might be difficult to translate to other languages). I use Greek letters in variables 47 | representing maths symbols conventionally presented as Greek letters: I value the great benefit in 48 | legibility over the minor inconvenience in typing. 49 | 50 | This version 2 of the library uses JavaScript ES classes and modules to organise the 51 | interdependencies; this makes the code both more immediately readable than previously, and also more 52 | accessible to non-JavaScript readers (always bearing in mind JavaScript uses prototype-based 53 | classes rather than classical inheritance-based classes). For older browsers (or Node.js <8.0.0), 54 | [v1.1.3](https://github.com/chrisveness/geodesy/tree/v1.1.3) is ES5-based. Note that there are 55 | [breaking changes](https://www.movable-type.co.uk/scripts/geodesy-library-migrating-from-v1.html) 56 | in moving from version 1 to version 2. 57 | 58 | While some aspects of the library are quite complex to understand and use, basic usage is simple – 59 | for instance: 60 | 61 | - to find the distance between two points using a simple spherical earth model: 62 | 63 | ```javascript 64 | import LatLon from 'geodesy/latlon-spherical.js'; 65 | const p1 = new LatLon(52.205, 0.119); 66 | const p2 = new LatLon(48.857, 2.351); 67 | const d = p1.distanceTo(p2); // 404.3×10³ m 68 | ``` 69 | 70 | - or to find the destination point for a given distance and initial bearing on an ellipsoidal model 71 | earth: 72 | 73 | ```javascript 74 | import LatLon from 'geodesy/latlon-ellipsoidal-vincenty.js'; 75 | const p1 = new LatLon(-37.95103, 144.42487); 76 | const dist = 54972.271; 77 | const brng = 306.86816; 78 | const p2 = p1.destinationPoint(dist, brng); // 37.6528°S, 143.9265°E 79 | ``` 80 | 81 | Full documentation is available at [www.movable-type.co.uk/scripts/geodesy-library.html](https://www.movable-type.co.uk/scripts/geodesy-library.html), 82 | and tests in the [browser](https://www.movable-type.co.uk/scripts/test/geodesy-test.html) as well as 83 | [Travis CI](https://travis-ci.org/chrisveness/geodesy). 84 | 85 | Usage 86 | ----- 87 | 88 | While originally intended as illustrative code fragments, these functions can be used ‘as-is’; 89 | either client-side in-browser, or with Node.js. 90 | 91 | ### Usage in browser 92 | 93 | The library can be used in the browser by taking a local copy, or loading it from 94 | [jsDelivr](https://www.jsdelivr.com/package/npm/geodesy): for example, 95 | 96 | ```html 97 | geodesy example 98 | 110 | ``` 111 | 112 | ### Usage in Node.js 113 | 114 | The library can be loaded from [npm](https://www.npmjs.com/package/geodesy) to be used in a Node.js app 115 | (in Node.js v13.2.0+, or Node.js v12.0.0+ using --experimental-modules, or v8.0.0–v12.15.0*) using the [esm](https://www.npmjs.com/package/esm) package: 116 | 117 | ```shell 118 | $ npm install geodesy 119 | $ node 120 | > const { default: LatLon } = await import('geodesy/latlon-spherical.js'); 121 | > const p1 = new LatLon(50.06632, -5.71475); 122 | > const p2 = new LatLon(58.64402, -3.07009); 123 | > const d = p1.distanceTo(p2); 124 | > console.assert(d.toFixed(3) == '968874.704'); 125 | > const mid = p1.midpointTo(p2); 126 | > console.assert(mid.toString('dms') == '54° 21′ 44″ N, 004° 31′ 51″ W'); 127 | ``` 128 | 129 | To some extent, mixins can be used to add methods of a class to a different class, e.g.: 130 | 131 | ```javascript 132 | import LatLon from 'geodesy/latlon-nvector-ellipsoidal.js'; 133 | import LatLonV from 'geodesy/latlon-ellipsoidal-vincenty.js'; 134 | 135 | for (const method of Object.getOwnPropertyNames(LatLonV.prototype)) { 136 | LatLon.prototype[method] = LatLonV.prototype[method]; 137 | } 138 | 139 | const d = new LatLon(51, 0).distanceTo(new LatLon(52, 1)); // vincenty 140 | const δ = new LatLon(51, 0).deltaTo(new LatLon(52, 1)); // n-vector 141 | ``` 142 | 143 | More care is of course required if there are conflicting constructors or method names. 144 | 145 | For TypeScript users, type definitions are available from DefinitelyTyped: [www.npmjs.com/package/@types/geodesy](https://www.npmjs.com/package/@types/geodesy). 146 | 147 | ### Other examples 148 | 149 | Some examples of calculations possible with the libraries: 150 | 151 | e.g. for geodesic distance on an ellipsoidal model earth using Vincenty’s algorithm: 152 | 153 | ```javascript 154 | import LatLon from 'geodesy/latlon-ellipsoidal-vincenty.js'; 155 | 156 | const p1 = new LatLon(50.06632, -5.71475); 157 | const p2 = new LatLon(58.64402, -3.07009); 158 | 159 | const d = p1.distanceTo(p2); 160 | console.assert(d.toFixed(3) == '969954.166'); 161 | ``` 162 | 163 | e.g. for UTM conversions: 164 | 165 | ```javascript 166 | import Utm from 'geodesy/utm.js'; 167 | 168 | const utm = Utm.parse('48 N 377298.745 1483034.794'); 169 | const latlon = utm.toLatLon(); 170 | 171 | console.assert(latlon.toString('dms', 2) == '13° 24′ 45.00″ N, 103° 52′ 00.00″ E'); 172 | console.assert(latlon.toUtm().toString() == '48 N 377298.745 1483034.794'; 173 | ``` 174 | 175 | e.g. for MGRS/NATO map references: 176 | 177 | ```javascript 178 | import Mgrs, { LatLon } from 'geodesy/mgrs.js'; 179 | 180 | const mgrs = Mgrs.parse('31U DQ 48251 11932'); 181 | const latlon = mgrs.toUtm().toLatLon(); 182 | console.assert(latlon.toString('dms', 2) == '48° 51′ 29.50″ N, 002° 17′ 40.16″ E'); 183 | 184 | const p = LatLon.parse('51°28′40.37″N, 000°00′05.29″W'); 185 | const ref = p.toUtm().toMgrs(); 186 | console.assert(ref.toString() == '30U YC 08215 07233'); 187 | ``` 188 | 189 | e.g. for OS grid references: 190 | 191 | ```javascript 192 | import OsGridRef, { LatLon } from 'geodesy/osgridref.js'; 193 | 194 | const gridref = new OsGridRef(651409.903, 313177.270); 195 | 196 | const pWgs84 = gridref.toLatLon(); 197 | console.assert(pWgs84.toString('dms', 4) == '52° 39′ 28.7230″ N, 001° 42′ 57.7870″ E'); 198 | 199 | const pOsgb = gridref.toLatLon(LatLon.datums.OSGB36); 200 | console.assert(pOsgb.toString('dms', 4) == '52° 39′ 27.2531″ N, 001° 43′ 04.5177″ E'); 201 | ``` 202 | 203 | e.g. for testing if a point is enclosed within a polygon: 204 | 205 | ```javascript 206 | import LatLon from 'geodesy/latlon-nvector-spherical.js'; 207 | 208 | const polygon = [ new LatLon(48,2), new LatLon(49,2), new LatLon(49,3), new LatLon(48,3) ]; 209 | 210 | const enclosed = new LatLon(48.9,2.4).isEnclosedBy(polygon); 211 | console.assert(enclosed == true); 212 | ``` 213 | 214 | e.g. greater parsing & presentation control: 215 | 216 | ```javascript 217 | import LatLon from 'geodesy/latlon-spherical.js'; 218 | Dms.separator = ' '; // full-space separator between degrees-minutes-seconds 219 | 220 | const p1 = LatLon.parse({ lat: '50:03:59N', lng: '005:42:53W' }); 221 | const p2 = LatLon.parse('58°38′38″N, 003°04′12″W'); 222 | 223 | const mid = p1.midpointTo(p2); 224 | console.assert(mid.toString('dms') == '54° 21′ 44″ N, 004° 31′ 50″ W'); 225 | ``` 226 | 227 | e.g. datum conversions: 228 | 229 | ```javascript 230 | import LatLon from 'geodesy/latlon-ellipsoidal-datum.js'; 231 | 232 | const pWgs84 = new LatLon(53.3444, -6.2577); 233 | 234 | const pIrl1975 = pWgs84.convertDatum(LatLon.datums.Irl1975); 235 | console.assert(pIrl1975.toString() == '53.3442° N, 006.2567° W'); 236 | ``` 237 | 238 | (The format of the import statements will vary according to deployment). 239 | -------------------------------------------------------------------------------- /dms.js: -------------------------------------------------------------------------------- 1 | /* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 2 | /* Geodesy representation conversion functions (c) Chris Veness 2002-2020 */ 3 | /* MIT Licence */ 4 | /* www.movable-type.co.uk/scripts/latlong.html */ 5 | /* www.movable-type.co.uk/scripts/js/geodesy/geodesy-library.html#dms */ 6 | /* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 7 | 8 | /* eslint no-irregular-whitespace: [2, { skipComments: true }] */ 9 | 10 | 11 | /** 12 | * Latitude/longitude points may be represented as decimal degrees, or subdivided into sexagesimal 13 | * minutes and seconds. This module provides methods for parsing and representing degrees / minutes 14 | * / seconds. 15 | * 16 | * @module dms 17 | */ 18 | 19 | 20 | /* Degree-minutes-seconds (& cardinal directions) separator character */ 21 | let dmsSeparator = '\u202f'; // U+202F = 'narrow no-break space' 22 | 23 | 24 | /** 25 | * Functions for parsing and representing degrees / minutes / seconds. 26 | */ 27 | class Dms { 28 | 29 | // note Unicode Degree = U+00B0. Prime = U+2032, Double prime = U+2033 30 | 31 | /** 32 | * Separator character to be used to separate degrees, minutes, seconds, and cardinal directions. 33 | * 34 | * Default separator is U+202F ‘narrow no-break space’. 35 | * 36 | * To change this (e.g. to empty string or full space), set Dms.separator prior to invoking 37 | * formatting. 38 | * 39 | * @example 40 | * import LatLon, { Dms } from '/js/geodesy/latlon-spherical.js'; 41 | * const p = new LatLon(51.2, 0.33).toString('dms'); // 51° 12′ 00″ N, 000° 19′ 48″ E 42 | * Dms.separator = ''; // no separator 43 | * const pʹ = new LatLon(51.2, 0.33).toString('dms'); // 51°12′00″N, 000°19′48″E 44 | */ 45 | static get separator() { return dmsSeparator; } 46 | static set separator(char) { dmsSeparator = char; } 47 | 48 | 49 | /** 50 | * Parses string representing degrees/minutes/seconds into numeric degrees. 51 | * 52 | * This is very flexible on formats, allowing signed decimal degrees, or deg-min-sec optionally 53 | * suffixed by compass direction (NSEW); a variety of separators are accepted. Examples -3.62, 54 | * '3 37 12W', '3°37′12″W'. 55 | * 56 | * Thousands/decimal separators must be comma/dot; use Dms.fromLocale to convert locale-specific 57 | * thousands/decimal separators. 58 | * 59 | * @param {string|number} dms - Degrees or deg/min/sec in variety of formats. 60 | * @returns {number} Degrees as decimal number. 61 | * 62 | * @example 63 | * const lat = Dms.parse('51° 28′ 40.37″ N'); 64 | * const lon = Dms.parse('000° 00′ 05.29″ W'); 65 | * const p1 = new LatLon(lat, lon); // 51.4779°N, 000.0015°W 66 | */ 67 | static parse(dms) { 68 | // check for signed decimal degrees without NSEW, if so return it directly 69 | if (!isNaN(parseFloat(dms)) && isFinite(dms)) return Number(dms); 70 | 71 | // strip off any sign or compass dir'n & split out separate d/m/s 72 | const dmsParts = String(dms).trim().replace(/^-/, '').replace(/[NSEW]$/i, '').split(/[^0-9.,]+/); 73 | if (dmsParts[dmsParts.length-1]=='') dmsParts.splice(dmsParts.length-1); // from trailing symbol 74 | 75 | if (dmsParts == '') return NaN; 76 | 77 | // and convert to decimal degrees... 78 | let deg = null; 79 | switch (dmsParts.length) { 80 | case 3: // interpret 3-part result as d/m/s 81 | deg = dmsParts[0]/1 + dmsParts[1]/60 + dmsParts[2]/3600; 82 | break; 83 | case 2: // interpret 2-part result as d/m 84 | deg = dmsParts[0]/1 + dmsParts[1]/60; 85 | break; 86 | case 1: // just d (possibly decimal) or non-separated dddmmss 87 | deg = dmsParts[0]; 88 | // check for fixed-width unseparated format eg 0033709W 89 | //if (/[NS]/i.test(dmsParts)) deg = '0' + deg; // - normalise N/S to 3-digit degrees 90 | //if (/[0-9]{7}/.test(deg)) deg = deg.slice(0,3)/1 + deg.slice(3,5)/60 + deg.slice(5)/3600; 91 | break; 92 | default: 93 | return NaN; 94 | } 95 | if (/^-|[WS]$/i.test(dms.trim())) deg = -deg; // take '-', west and south as -ve 96 | 97 | return Number(deg); 98 | } 99 | 100 | 101 | /** 102 | * Converts decimal degrees to deg/min/sec format 103 | * - degree, prime, double-prime symbols are added, but sign is discarded, though no compass 104 | * direction is added. 105 | * - degrees are zero-padded to 3 digits; for degrees latitude, use .slice(1) to remove leading 106 | * zero. 107 | * 108 | * @private 109 | * @param {number} deg - Degrees to be formatted as specified. 110 | * @param {string} [format=d] - Return value as 'd', 'dm', 'dms' for deg, deg+min, deg+min+sec. 111 | * @param {number} [dp=4|2|0] - Number of decimal places to use – default 4 for d, 2 for dm, 0 for dms. 112 | * @returns {string} Degrees formatted as deg/min/secs according to specified format. 113 | */ 114 | static toDms(deg, format='d', dp=undefined) { 115 | if (isNaN(deg)) return null; // give up here if we can't make a number from deg 116 | if (typeof deg == 'string' && deg.trim() == '') return null; 117 | if (typeof deg == 'boolean') return null; 118 | if (deg == Infinity) return null; 119 | if (deg == null) return null; 120 | 121 | // default values 122 | if (dp === undefined) { 123 | switch (format) { 124 | case 'd': case 'deg': dp = 4; break; 125 | case 'dm': case 'deg+min': dp = 2; break; 126 | case 'dms': case 'deg+min+sec': dp = 0; break; 127 | default: format = 'd'; dp = 4; break; // be forgiving on invalid format 128 | } 129 | } 130 | 131 | deg = Math.abs(deg); // (unsigned result ready for appending compass dir'n) 132 | 133 | let dms = null, d = null, m = null, s = null; 134 | switch (format) { 135 | default: // invalid format spec! 136 | case 'd': case 'deg': 137 | d = deg.toFixed(dp); // round/right-pad degrees 138 | if (d<100) d = '0' + d; // left-pad with leading zeros (note may include decimals) 139 | if (d<10) d = '0' + d; 140 | dms = d + '°'; 141 | break; 142 | case 'dm': case 'deg+min': 143 | d = Math.floor(deg); // get component deg 144 | m = ((deg*60) % 60).toFixed(dp); // get component min & round/right-pad 145 | if (m == 60) { m = (0).toFixed(dp); d++; } // check for rounding up 146 | d = ('000'+d).slice(-3); // left-pad with leading zeros 147 | if (m<10) m = '0' + m; // left-pad with leading zeros (note may include decimals) 148 | dms = d + '°'+Dms.separator + m + '′'; 149 | break; 150 | case 'dms': case 'deg+min+sec': 151 | d = Math.floor(deg); // get component deg 152 | m = Math.floor((deg*3600)/60) % 60; // get component min 153 | s = (deg*3600 % 60).toFixed(dp); // get component sec & round/right-pad 154 | if (s == 60) { s = (0).toFixed(dp); m++; } // check for rounding up 155 | if (m == 60) { m = 0; d++; } // check for rounding up 156 | d = ('000'+d).slice(-3); // left-pad with leading zeros 157 | m = ('00'+m).slice(-2); // left-pad with leading zeros 158 | if (s<10) s = '0' + s; // left-pad with leading zeros (note may include decimals) 159 | dms = d + '°'+Dms.separator + m + '′'+Dms.separator + s + '″'; 160 | break; 161 | } 162 | 163 | return dms; 164 | } 165 | 166 | 167 | /** 168 | * Converts numeric degrees to deg/min/sec latitude (2-digit degrees, suffixed with N/S). 169 | * 170 | * @param {number} deg - Degrees to be formatted as specified. 171 | * @param {string} [format=d] - Return value as 'd', 'dm', 'dms' for deg, deg+min, deg+min+sec. 172 | * @param {number} [dp=4|2|0] - Number of decimal places to use – default 4 for d, 2 for dm, 0 for dms. 173 | * @returns {string} Degrees formatted as deg/min/secs according to specified format. 174 | * 175 | * @example 176 | * const lat = Dms.toLat(-3.62, 'dms'); // 3°37′12″S 177 | */ 178 | static toLat(deg, format, dp) { 179 | const lat = Dms.toDms(Dms.wrap90(deg), format, dp); 180 | return lat===null ? '–' : lat.slice(1) + Dms.separator + (deg<0 ? 'S' : 'N'); // knock off initial '0' for lat! 181 | } 182 | 183 | 184 | /** 185 | * Convert numeric degrees to deg/min/sec longitude (3-digit degrees, suffixed with E/W). 186 | * 187 | * @param {number} deg - Degrees to be formatted as specified. 188 | * @param {string} [format=d] - Return value as 'd', 'dm', 'dms' for deg, deg+min, deg+min+sec. 189 | * @param {number} [dp=4|2|0] - Number of decimal places to use – default 4 for d, 2 for dm, 0 for dms. 190 | * @returns {string} Degrees formatted as deg/min/secs according to specified format. 191 | * 192 | * @example 193 | * const lon = Dms.toLon(-3.62, 'dms'); // 3°37′12″W 194 | */ 195 | static toLon(deg, format, dp) { 196 | const lon = Dms.toDms(Dms.wrap180(deg), format, dp); 197 | return lon===null ? '–' : lon + Dms.separator + (deg<0 ? 'W' : 'E'); 198 | } 199 | 200 | 201 | /** 202 | * Converts numeric degrees to deg/min/sec as a bearing (0°..360°). 203 | * 204 | * @param {number} deg - Degrees to be formatted as specified. 205 | * @param {string} [format=d] - Return value as 'd', 'dm', 'dms' for deg, deg+min, deg+min+sec. 206 | * @param {number} [dp=4|2|0] - Number of decimal places to use – default 4 for d, 2 for dm, 0 for dms. 207 | * @returns {string} Degrees formatted as deg/min/secs according to specified format. 208 | * 209 | * @example 210 | * const lon = Dms.toBrng(-3.62, 'dms'); // 356°22′48″ 211 | */ 212 | static toBrng(deg, format, dp) { 213 | const brng = Dms.toDms(Dms.wrap360(deg), format, dp); 214 | return brng===null ? '–' : brng.replace('360', '0'); // just in case rounding took us up to 360°! 215 | } 216 | 217 | 218 | /** 219 | * Converts DMS string from locale thousands/decimal separators to JavaScript comma/dot separators 220 | * for subsequent parsing. 221 | * 222 | * Both thousands and decimal separators must be followed by a numeric character, to facilitate 223 | * parsing of single lat/long string (in which whitespace must be left after the comma separator). 224 | * 225 | * @param {string} str - Degrees/minutes/seconds formatted with locale separators. 226 | * @returns {string} Degrees/minutes/seconds formatted with standard Javascript separators. 227 | * 228 | * @example 229 | * const lat = Dms.fromLocale('51°28′40,12″N'); // '51°28′40.12″N' in France 230 | * const p = new LatLon(Dms.fromLocale('51°28′40,37″N, 000°00′05,29″W'); // '51.4779°N, 000.0015°W' in France 231 | */ 232 | static fromLocale(str) { 233 | const locale = (123456.789).toLocaleString(); 234 | const separator = { thousands: locale.slice(3, 4), decimal: locale.slice(7, 8) }; 235 | return str.replace(separator.thousands, '⁜').replace(separator.decimal, '.').replace('⁜', ','); 236 | } 237 | 238 | 239 | /** 240 | * Converts DMS string from JavaScript comma/dot thousands/decimal separators to locale separators. 241 | * 242 | * Can also be used to format standard numbers such as distances. 243 | * 244 | * @param {string} str - Degrees/minutes/seconds formatted with standard Javascript separators. 245 | * @returns {string} Degrees/minutes/seconds formatted with locale separators. 246 | * 247 | * @example 248 | * const Dms.toLocale('123,456.789'); // '123.456,789' in France 249 | * const Dms.toLocale('51°28′40.12″N, 000°00′05.31″W'); // '51°28′40,12″N, 000°00′05,31″W' in France 250 | */ 251 | static toLocale(str) { 252 | const locale = (123456.789).toLocaleString(); 253 | const separator = { thousands: locale.slice(3, 4), decimal: locale.slice(7, 8) }; 254 | return str.replace(/,([0-9])/, '⁜$1').replace('.', separator.decimal).replace('⁜', separator.thousands); 255 | } 256 | 257 | 258 | /** 259 | * Returns compass point (to given precision) for supplied bearing. 260 | * 261 | * @param {number} bearing - Bearing in degrees from north. 262 | * @param {number} [precision=3] - Precision (1:cardinal / 2:intercardinal / 3:secondary-intercardinal). 263 | * @returns {string} Compass point for supplied bearing. 264 | * 265 | * @example 266 | * const point = Dms.compassPoint(24); // point = 'NNE' 267 | * const point = Dms.compassPoint(24, 1); // point = 'N' 268 | */ 269 | static compassPoint(bearing, precision=3) { 270 | if (![ 1, 2, 3 ].includes(Number(precision))) throw new RangeError(`invalid precision ‘${precision}’`); 271 | // note precision could be extended to 4 for quarter-winds (eg NbNW), but I think they are little used 272 | 273 | bearing = Dms.wrap360(bearing); // normalise to range 0..360° 274 | 275 | const cardinals = [ 276 | 'N', 'NNE', 'NE', 'ENE', 277 | 'E', 'ESE', 'SE', 'SSE', 278 | 'S', 'SSW', 'SW', 'WSW', 279 | 'W', 'WNW', 'NW', 'NNW' ]; 280 | const n = 4 * 2**(precision-1); // no of compass points at req’d precision (1=>4, 2=>8, 3=>16) 281 | const cardinal = cardinals[Math.round(bearing*n/360)%n * 16/n]; 282 | 283 | return cardinal; 284 | } 285 | 286 | 287 | /** 288 | * Constrain degrees to range -90..+90 (for latitude); e.g. -91 => -89, 91 => 89. 289 | * 290 | * @private 291 | * @param {number} degrees 292 | * @returns degrees within range -90..+90. 293 | */ 294 | static wrap90(degrees) { 295 | if (-90<=degrees && degrees<=90) return degrees; // avoid rounding due to arithmetic ops if within range 296 | 297 | // latitude wrapping requires a triangle wave function; a general triangle wave is 298 | // f(x) = 4a/p ⋅ | (x-p/4)%p - p/2 | - a 299 | // where a = amplitude, p = period, % = modulo; however, JavaScript '%' is a remainder operator 300 | // not a modulo operator - for modulo, replace 'x%n' with '((x%n)+n)%n' 301 | const x = degrees, a = 90, p = 360; 302 | return 4*a/p * Math.abs((((x-p/4)%p)+p)%p - p/2) - a; 303 | } 304 | 305 | /** 306 | * Constrain degrees to range -180..+180 (for longitude); e.g. -181 => 179, 181 => -179. 307 | * 308 | * @private 309 | * @param {number} degrees 310 | * @returns degrees within range -180..+180. 311 | */ 312 | static wrap180(degrees) { 313 | if (-180<=degrees && degrees<=180) return degrees; // avoid rounding due to arithmetic ops if within range 314 | 315 | // longitude wrapping requires a sawtooth wave function; a general sawtooth wave is 316 | // f(x) = (2ax/p - p/2) % p - a 317 | // where a = amplitude, p = period, % = modulo; however, JavaScript '%' is a remainder operator 318 | // not a modulo operator - for modulo, replace 'x%n' with '((x%n)+n)%n' 319 | const x = degrees, a = 180, p = 360; 320 | return (((2*a*x/p - p/2)%p)+p)%p - a; 321 | } 322 | 323 | /** 324 | * Constrain degrees to range 0..360 (for bearings); e.g. -1 => 359, 361 => 1. 325 | * 326 | * @private 327 | * @param {number} degrees 328 | * @returns degrees within range 0..360. 329 | */ 330 | static wrap360(degrees) { 331 | if (0<=degrees && degrees<360) return degrees; // avoid rounding due to arithmetic ops if within range 332 | 333 | // bearing wrapping requires a sawtooth wave function with a vertical offset equal to the 334 | // amplitude and a corresponding phase shift; this changes the general sawtooth wave function from 335 | // f(x) = (2ax/p - p/2) % p - a 336 | // to 337 | // f(x) = (2ax/p) % p 338 | // where a = amplitude, p = period, % = modulo; however, JavaScript '%' is a remainder operator 339 | // not a modulo operator - for modulo, replace 'x%n' with '((x%n)+n)%n' 340 | const x = degrees, a = 180, p = 360; 341 | return (((2*a*x/p)%p)+p)%p; 342 | } 343 | 344 | } 345 | 346 | 347 | // Extend Number object with methods to convert between degrees & radians 348 | Number.prototype.toRadians = function() { return this * Math.PI / 180; }; 349 | Number.prototype.toDegrees = function() { return this * 180 / Math.PI; }; 350 | 351 | /* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 352 | 353 | export default Dms; 354 | -------------------------------------------------------------------------------- /latlon-ellipsoidal-referenceframe-txparams.js: -------------------------------------------------------------------------------- 1 | /* Helmert transform parameters tx(mm) ty(mm) tz(mm) s(ppb) rx(mas) ry(mas) rz(mas) */ 2 | export default { 3 | /* eslint-disable key-spacing, indent */ 4 | 'ITRF2014→ITRF2008': { epoch: '2010.0', 5 | params: [ 1.6, 1.9, 2.4, -0.02, 0.00, 0.00, 0.00 ], 6 | rates: [ 0.0, 0.0, -0.1, 0.03, 0.00, 0.00, 0.00 ] }, 7 | 'ITRF2014→ITRF2005': { epoch: '2010.0', 8 | params: [ 2.6, 1.0, -2.3, 0.92, 0.00, 0.00, 0.00 ], 9 | rates: [ 0.3, 0.0, -0.1, 0.03, 0.00, 0.00, 0.00 ] }, 10 | 'ITRF2014→ITRF2000': { epoch: '2010.0', 11 | params: [ 0.7, 1.2, -26.1, 2.12, 0.00, 0.00, 0.00 ], 12 | rates: [ 0.1, 0.1, -1.9, 0.11, 0.00, 0.00, 0.00 ] }, 13 | 'ITRF2014→ITRF97': { epoch: '2010.0', 14 | params: [ 7.4, -0.5, -62.8, 3.80, 0.00, 0.00, 0.26 ], 15 | rates: [ 0.1, -0.5, -3.3, 0.12, 0.00, 0.00, 0.02 ] }, 16 | 'ITRF2014→ITRF96': { epoch: '2010.0', 17 | params: [ 7.4, -0.5, -62.8, 3.80, 0.00, 0.00, 0.26 ], 18 | rates: [ 0.1, -0.5, -3.3, 0.12, 0.00, 0.00, 0.02 ] }, 19 | 'ITRF2014→ITRF94': { epoch: '2010.0', 20 | params: [ 7.4, -0.5, -62.8, 3.80, 0.00, 0.00, 0.26 ], 21 | rates: [ 0.1, -0.5, -3.3, 0.12, 0.00, 0.00, 0.02 ] }, 22 | 'ITRF2014→ITRF93': { epoch: '2010.0', 23 | params: [ -50.4, 3.3, -60.2, 4.29, -2.81, -3.38, 0.40 ], 24 | rates: [ -2.8, -0.1, -2.5, 0.12, -0.11, -0.19, 0.07 ] }, 25 | 'ITRF2014→ITRF92': { epoch: '2010.0', 26 | params: [ 15.4, 1.5, -70.8, 3.09, 0.00, 0.00, 0.26 ], 27 | rates: [ 0.1, -0.5, -3.3, 0.12, 0.00, 0.00, 0.02 ] }, 28 | 'ITRF2014→ITRF91': { epoch: '2010.0', 29 | params: [ 27.4, 15.5, -76.8, 4.49, 0.00, 0.00, 0.26 ], 30 | rates: [ 0.1, -0.5, -3.3, 0.12, 0.00, 0.00, 0.02 ] }, 31 | 'ITRF2014→ITRF90': { epoch: '2010.0', 32 | params: [ 25.4, 11.5, -92.8, 4.79, 0.00, 0.00, 0.26 ], 33 | rates: [ 0.1, -0.5, -3.3, 0.12, 0.00, 0.00, 0.02 ] }, 34 | 'ITRF2014→ITRF89': { epoch: '2010.0', 35 | params: [ 30.4, 35.5, -130.8, 8.19, 0.00, 0.00, 0.26 ], 36 | rates: [ 0.1, -0.5, -3.3, 0.12, 0.00, 0.00, 0.02 ] }, 37 | 'ITRF2014→ITRF88': { epoch: '2010.0', 38 | params: [ 25.4, -0.5, -154.8, 11.29, 0.10, 0.00, 0.26 ], 39 | rates: [ 0.1, -0.5, -3.3, 0.12, 0.00, 0.00, 0.02 ] }, 40 | 41 | 'ITRF2008→ITRF2005': { epoch: '2000.0', 42 | params: [ -2.0, -0.9, -4.7, 0.94, 0.00, 0.00, 0.00 ], 43 | rates: [ 0.3, 0.0, 0.0, 0.00, 0.00, 0.00, 0.00 ] }, 44 | 'ITRF2008→ITRF2000': { epoch: '2000.0', 45 | params: [ -1.9, -1.7, -10.5, 1.34, 0.00, 0.00, 0.00 ], 46 | rates: [ 0.1, 0.1, -1.8, 0.08, 0.00, 0.00, 0.00 ] }, 47 | 'ITRF2008→ITRF97': { epoch: '2000.0', 48 | params: [ 4.8, 2.6, -33.2, 2.92, 0.00, 0.00, 0.06 ], 49 | rates: [ 0.1, -0.5, -3.2, 0.09, 0.00, 0.00, 0.02 ] }, 50 | 'ITRF2008→ITRF96': { epoch: '2000.0', 51 | params: [ 4.8, 2.6, -33.2, 2.92, 0.00, 0.00, 0.06 ], 52 | rates: [ 0.1, -0.5, -3.2, 0.09, 0.00, 0.00, 0.02 ] }, 53 | 'ITRF2008→ITRF94': { epoch: '2000.0', 54 | params: [ 4.8, 2.6, -33.2, 2.92, 0.00, 0.00, 0.06 ], 55 | rates: [ 0.1, -0.5, -3.2, 0.09, 0.00, 0.00, 0.02 ] }, 56 | 'ITRF2008→ITRF93': { epoch: '2000.0', 57 | params: [ -24.0, 2.4, -38.6, 3.41, -1.71, -1.48, -0.30 ], 58 | rates: [ -2.8, -0.1, -2.4, 0.09, -0.11, -0.19, 0.07 ] }, 59 | 'ITRF2008→ITRF92': { epoch: '2000.0', 60 | params: [ 12.8, 4.6, -41.2, 2.21, 0.00, 0.00, 0.06 ], 61 | rates: [ 0.1, -0.5, -3.2, 0.09, 0.00, 0.00, 0.02 ] }, 62 | 'ITRF2008→ITRF91': { epoch: '2000.0', 63 | params: [ 24.8, 18.6, -47.2, 3.61, 0.00, 0.00, 0.06 ], 64 | rates: [ 0.1, -0.5, -3.2, 0.09, 0.00, 0.00, 0.02 ] }, 65 | 'ITRF2008→ITRF90': { epoch: '2000.0', 66 | params: [ 22.8, 14.6, -63.2, 3.91, 0.00, 0.00, 0.06 ], 67 | rates: [ 0.1, -0.5, -3.2, 0.09, 0.00, 0.00, 0.02 ] }, 68 | 'ITRF2008→ITRF89': { epoch: '2000.0', 69 | params: [ 27.8, 38.6, -101.2, 7.31, 0.00, 0.00, 0.06 ], 70 | rates: [ 0.1, -0.5, -3.2, 0.09, 0.00, 0.00, 0.02 ] }, 71 | 'ITRF2008→ITRF88': { epoch: '2000.0', 72 | params: [ 22.8, 2.6, -125.2, 10.41, 0.10, 0.00, 0.06 ], 73 | rates: [ 0.1, -0.5, -3.2, 0.09, 0.00, 0.00, 0.02 ] }, 74 | 75 | 'ITRF2005→ITRF2000': { epoch: '2000.0', 76 | params: [ 0.1, -0.8, -5.8, 0.40, 0.000, 0.000, 0.000 ], 77 | rates: [ -0.2, 0.1, -1.8, 0.08, 0.000, 0.000, 0.000 ] }, 78 | 79 | 'ITRF2000→ITRF97': { epoch: '1997.0', 80 | params: [ 0.67, 0.61, -1.85, 1.55, 0.00, 0.00, 0.00 ], 81 | rates: [ 0.00, -0.06, -0.14, 0.01, 0.00, 0.00, 0.02 ] }, 82 | 'ITRF2000→ITRF96': { epoch: '1997.0', 83 | params: [ 0.67, 0.61, -1.85, 1.55, 0.00, 0.00, 0.00 ], 84 | rates: [ 0.00, -0.06, -0.14, 0.01, 0.00, 0.00, 0.02 ] }, 85 | 'ITRF2000→ITRF94': { epoch: '1997.0', 86 | params: [ 0.67, 0.61, -1.85, 1.55, 0.00, 0.00, 0.00 ], 87 | rates: [ 0.00, -0.06, -0.14, 0.01, 0.00, 0.00, 0.02 ] }, 88 | 'ITRF2000→ITRF93': { epoch: '1988.0', 89 | params: [ 12.7, 6.5, -20.9, 1.95, -0.39, 0.80, -1.14 ], 90 | rates: [ -2.9, -0.2, -0.6, 0.01, -0.11, -0.19, 0.07 ] }, 91 | 'ITRF2000→ITRF92': { epoch: '1988.0', 92 | params: [ 1.47, 1.35, -1.39, 0.75, 0.00, 0.00, -0.18 ], 93 | rates: [ 0.00, -0.06, -0.14, 0.01, 0.00, 0.00, 0.02 ] }, 94 | 'ITRF2000→ITRF91': { epoch: '1988.0', 95 | params: [ 26.7, 27.5, -19.9, 2.15, 0.00, 0.00, -0.18 ], 96 | rates: [ 0.0, -0.6, -1.4, 0.01, 0.00, 0.00, 0.02 ] }, 97 | 'ITRF2000→ITRF90': { epoch: '1988.0', 98 | params: [ 2.47, 2.35, -3.59, 2.45, 0.00, 0.00, -0.18 ], 99 | rates: [ 0.00, -0.06, -0.14, 0.01, 0.00, 0.00, 0.02 ] }, 100 | 'ITRF2000→ITRF89': { epoch: '1988.0', 101 | params: [ 2.97, 4.75, -7.39, 5.85, 0.00, 0.00, -0.18 ], 102 | rates: [ 0.00, -0.06, -0.14, 0.01, 0.00, 0.00, 0.02 ] }, 103 | 'ITRF2000→ITRF88': { epoch: '1988.0', 104 | params: [ 2.47, 1.15, -9.79, 8.95, 0.10, 0.00, -0.18 ], 105 | rates: [ 0.00, -0.06, -0.14, 0.01, 0.00, 0.00, 0.02 ] }, 106 | 107 | 'ITRF2000→NAD83': { epoch: '1997.0', // note NAD83(CORS96) 108 | params: [ 995.6, -1901.3, -521.5, 0.62, 25.915, 9.426, 11.599 ], 109 | rates: [ 0.7, -0.7, 0.5, -0.18, 0.067, -0.757, -0.051 ] }, 110 | 111 | 'ITRF2014→ETRF2000': { epoch: '2000.0', 112 | params: [ 53.7, 51.2, -55.1, 1.02, 0.891, 5.390, -8.712 ], 113 | rates: [ 0.1, 0.1, -1.9, 0.11, 0.081, 0.490, -0.792 ] }, 114 | 'ITRF2008→ETRF2000': { epoch: '2000.0', 115 | params: [ 52.1, 49.3, -58.5, 1.34, 0.891, 5.390, -8.712 ], 116 | rates: [ 0.1, 0.1, -1.8, 0.08, 0.081, 0.490, -0.792 ] }, 117 | 'ITRF2005→ETRF2000': { epoch: '2000.0', 118 | params: [ 54.1, 50.2, -53.8, 0.40, 0.891, 5.390, -8.712 ], 119 | rates: [ -0.2, 0.1, -1.8, 0.08, 0.081, 0.490, -0.792 ] }, 120 | 'ITRF2000→ETRF2000': { epoch: '2000.0', 121 | params: [ 54.0, 51.0, -48.0, 0.00, 0.891, 5.390, -8.712 ], 122 | rates: [ 0.0, 0.0, 0.0, 0.00, 0.081, 0.490, -0.792 ] }, 123 | 124 | 'ITRF2008→GDA94': { epoch: '1994.0', 125 | params: [ -84.68, -19.42, 32.01, 9.710, -0.4254, 2.2578, 2.4015 ], 126 | rates: [ 1.42, 1.34, 0.90, 0.109, 1.5461, 1.1820, 1.1551 ] }, 127 | 'ITRF2005→GDA94': { epoch: '1994.0', 128 | params: [ -79.73, -6.86, 38.03, 6.636, 0.0351, -2.1211, -2.1411 ], 129 | rates: [ 2.25, -0.62, -0.56, 0.294, -1.4707, -1.1443, -1.1701 ] }, 130 | 'ITRF2000→GDA94': { epoch: '1994.0', 131 | params: [ -45.91, -29.85, -20.37, 7.070, -1.6705, 0.4594, 1.9356 ], 132 | rates: [ -4.66, 3.55, 11.24, 0.249, 1.7454, 1.4868, 1.2240 ] }, 133 | }; 134 | /* Note WGS84(G730/G873/G1150) are coincident with ITRF at 10-centimetre level; WGS84(G1674) and 135 | * ITRF20014 / ITRF2008 ‘are likely to agree at the centimeter level’ (QPS). 136 | * 137 | * sources: 138 | * - ITRS: itrf.ensg.ign.fr/trans_para.php 139 | * - NAD83: Transforming Positions and Velocities between the International Terrestrial Reference 140 | * Frame of 2000 and North American Datum of 1983, Soler & Snay, 2004; 141 | * www.ngs.noaa.gov/CORS/Articles/SolerSnayASCE.pdf 142 | * - ETRS: etrs89.ensg.ign.fr/memo-V8.pdf / www.euref.eu/symposia/2016SanSebastian/01-02-Altamimi.pdf 143 | * - GDA: ITRF to GDA94 coordinate transformations, Dawson & Woods, 2010 144 | * (note sign of rotations for GDA94 reversed from Dawson & Woods 2010 as “Australia assumes rotation 145 | * to be of coordinate axes” rather than the more conventional “position around the coordinate axes”) 146 | * more are available at: 147 | * confluence.qps.nl/qinsy/files/en/29856813/45482834/2/1453459502000/ITRF_Transformation_Parameters.xlsx 148 | */ 149 | -------------------------------------------------------------------------------- /latlon-ellipsoidal-vincenty.js: -------------------------------------------------------------------------------- 1 | /* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 2 | /* Vincenty Direct and Inverse Solution of Geodesics on the Ellipsoid (c) Chris Veness 2002-2022 */ 3 | /* MIT Licence */ 4 | /* www.ngs.noaa.gov/PUBS_LIB/inverse.pdf */ 5 | /* www.movable-type.co.uk/scripts/latlong-vincenty.html */ 6 | /* www.movable-type.co.uk/scripts/geodesy-library.html#latlon-ellipsoidal-vincenty */ 7 | /* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 8 | 9 | import LatLonEllipsoidal, { Dms } from './latlon-ellipsoidal.js'; 10 | 11 | const π = Math.PI; 12 | const ε = Number.EPSILON; 13 | 14 | 15 | /** 16 | * Distances & bearings between points, and destination points given start points & initial bearings, 17 | * calculated on an ellipsoidal earth model using ‘direct and inverse solutions of geodesics on the 18 | * ellipsoid’ devised by Thaddeus Vincenty. 19 | * 20 | * From: T Vincenty, "Direct and Inverse Solutions of Geodesics on the Ellipsoid with application of 21 | * nested equations", Survey Review, vol XXIII no 176, 1975. www.ngs.noaa.gov/PUBS_LIB/inverse.pdf. 22 | * 23 | * @module latlon-ellipsoidal-vincenty 24 | */ 25 | 26 | /* LatLonEllipsoidal_Vincenty - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 27 | 28 | /** 29 | * Extends LatLonEllipsoidal with methods for calculating distances and bearings between points, and 30 | * destination points given distances and initial bearings, accurate to within 0.5mm distance, 31 | * 0.000015″ bearing. 32 | * 33 | * By default, these calculations are made on a WGS-84 ellipsoid. For geodesic calculations on other 34 | * ellipsoids, monkey-patch the LatLon point by setting the datum of ‘this’ point to make it appear 35 | * as a LatLonEllipsoidal_Datum or LatLonEllipsoidal_ReferenceFrame point: e.g. 36 | * 37 | * import LatLon, { Dms } from '../latlon-ellipsoidal-vincenty.js'; 38 | * import { datums } from '../latlon-ellipsoidal-datum.js'; 39 | * const le = new LatLon(50.065716, -5.713824); // in OSGB-36 40 | * const jog = new LatLon(58.644399, -3.068521); // in OSGB-36 41 | * le.datum = datums.OSGB36; // source point determines ellipsoid to use 42 | * const d = le.distanceTo(jog); // = 969982.014; 27.848m more than on WGS-84 ellipsoid 43 | * 44 | * @extends LatLonEllipsoidal 45 | */ 46 | class LatLonEllipsoidal_Vincenty extends LatLonEllipsoidal { 47 | 48 | /** 49 | * Returns the distance between ‘this’ point and destination point along a geodesic on the 50 | * surface of the ellipsoid, using Vincenty inverse solution. 51 | * 52 | * @param {LatLon} point - Latitude/longitude of destination point. 53 | * @returns {number} Distance in metres between points or NaN if failed to converge. 54 | * 55 | * @example 56 | * const p1 = new LatLon(50.06632, -5.71475); 57 | * const p2 = new LatLon(58.64402, -3.07009); 58 | * const d = p1.distanceTo(p2); // 969,954.166 m 59 | */ 60 | distanceTo(point) { 61 | try { 62 | const dist = this.inverse(point).distance; 63 | return Number(dist.toFixed(3)); // round to 1mm precision 64 | } catch (e) { 65 | if (e instanceof EvalError) return NaN; // λ > π or failed to converge 66 | throw e; 67 | } 68 | } 69 | 70 | 71 | /** 72 | * Returns the initial bearing to travel along a geodesic from ‘this’ point to the given point, 73 | * using Vincenty inverse solution. 74 | * 75 | * @param {LatLon} point - Latitude/longitude of destination point. 76 | * @returns {number} Initial bearing in degrees from north (0°..360°) or NaN if failed to converge. 77 | * 78 | * @example 79 | * const p1 = new LatLon(50.06632, -5.71475); 80 | * const p2 = new LatLon(58.64402, -3.07009); 81 | * const b1 = p1.initialBearingTo(p2); // 9.1419° 82 | */ 83 | initialBearingTo(point) { 84 | try { 85 | const brng = this.inverse(point).initialBearing; 86 | return Number(brng.toFixed(7)); // round to 0.001″ precision 87 | } catch (e) { 88 | if (e instanceof EvalError) return NaN; // λ > π or failed to converge 89 | throw e; 90 | } 91 | } 92 | 93 | 94 | /** 95 | * Returns the final bearing having travelled along a geodesic from ‘this’ point to the given 96 | * point, using Vincenty inverse solution. 97 | * 98 | * @param {LatLon} point - Latitude/longitude of destination point. 99 | * @returns {number} Final bearing in degrees from north (0°..360°) or NaN if failed to converge. 100 | * 101 | * @example 102 | * const p1 = new LatLon(50.06632, -5.71475); 103 | * const p2 = new LatLon(58.64402, -3.07009); 104 | * const b2 = p1.finalBearingTo(p2); // 11.2972° 105 | */ 106 | finalBearingTo(point) { 107 | try { 108 | const brng = this.inverse(point).finalBearing; 109 | return Number(brng.toFixed(7)); // round to 0.001″ precision 110 | } catch (e) { 111 | if (e instanceof EvalError) return NaN; // λ > π or failed to converge 112 | throw e; 113 | } 114 | } 115 | 116 | 117 | /** 118 | * Returns the destination point having travelled the given distance along a geodesic given by 119 | * initial bearing from ‘this’ point, using Vincenty direct solution. 120 | * 121 | * @param {number} distance - Distance travelled along the geodesic in metres. 122 | * @param {number} initialBearing - Initial bearing in degrees from north. 123 | * @returns {LatLon} Destination point. 124 | * 125 | * @example 126 | * const p1 = new LatLon(-37.95103, 144.42487); 127 | * const p2 = p1.destinationPoint(54972.271, 306.86816); // 37.6528°S, 143.9265°E 128 | */ 129 | destinationPoint(distance, initialBearing) { 130 | return this.direct(Number(distance), Number(initialBearing)).point; 131 | } 132 | 133 | 134 | /** 135 | * Returns the final bearing having travelled along a geodesic given by initial bearing for a 136 | * given distance from ‘this’ point, using Vincenty direct solution. 137 | * TODO: arg order? (this is consistent with destinationPoint, but perhaps less intuitive) 138 | * 139 | * @param {number} distance - Distance travelled along the geodesic in metres. 140 | * @param {LatLon} initialBearing - Initial bearing in degrees from north. 141 | * @returns {number} Final bearing in degrees from north (0°..360°). 142 | * 143 | * @example 144 | * const p1 = new LatLon(-37.95103, 144.42487); 145 | * const b2 = p1.finalBearingOn(54972.271, 306.86816); // 307.1736° 146 | */ 147 | finalBearingOn(distance, initialBearing) { 148 | const brng = this.direct(Number(distance), Number(initialBearing)).finalBearing; 149 | return Number(brng.toFixed(7)); // round to 0.001″ precision 150 | } 151 | 152 | 153 | /** 154 | * Returns the point at given fraction between ‘this’ point and given point. 155 | * 156 | * @param {LatLon} point - Latitude/longitude of destination point. 157 | * @param {number} fraction - Fraction between the two points (0 = this point, 1 = specified point). 158 | * @returns {LatLon} Intermediate point between this point and destination point. 159 | * 160 | * @example 161 | * const p1 = new LatLon(50.06632, -5.71475); 162 | * const p2 = new LatLon(58.64402, -3.07009); 163 | * const pInt = p1.intermediatePointTo(p2, 0.5); // 54.3639°N, 004.5304°W 164 | */ 165 | intermediatePointTo(point, fraction) { 166 | if (fraction == 0) return this; 167 | if (fraction == 1) return point; 168 | 169 | const inverse = this.inverse(point); 170 | const dist = inverse.distance; 171 | const brng = inverse.initialBearing; 172 | return isNaN(brng) ? this : this.destinationPoint(dist*fraction, brng); 173 | } 174 | 175 | 176 | /* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 177 | 178 | 179 | /** 180 | * Vincenty direct calculation. 181 | * 182 | * Ellipsoid parameters are taken from datum of 'this' point. Height is ignored. 183 | * 184 | * @private 185 | * @param {number} distance - Distance along bearing in metres. 186 | * @param {number} initialBearing - Initial bearing in degrees from north. 187 | * @returns (Object} Object including point (destination point), finalBearing. 188 | * @throws {RangeError} Point must be on surface of ellipsoid. 189 | * @throws {EvalError} Formula failed to converge. 190 | */ 191 | direct(distance, initialBearing) { 192 | if (isNaN(distance)) throw new TypeError(`invalid distance ${distance}`); 193 | if (distance == 0) return { point: this, finalBearing: NaN, iterations: 0 }; 194 | if (isNaN(initialBearing)) throw new TypeError(`invalid bearing ${initialBearing}`); 195 | if (this.height != 0) throw new RangeError('point must be on the surface of the ellipsoid'); 196 | 197 | const φ1 = this.lat.toRadians(), λ1 = this.lon.toRadians(); 198 | const α1 = Number(initialBearing).toRadians(); 199 | const s = Number(distance); 200 | 201 | // allow alternative ellipsoid to be specified 202 | const ellipsoid = this.datum ? this.datum.ellipsoid : LatLonEllipsoidal.ellipsoids.WGS84; 203 | const { a, b, f } = ellipsoid; 204 | 205 | const sinα1 = Math.sin(α1); 206 | const cosα1 = Math.cos(α1); 207 | 208 | const tanU1 = (1-f) * Math.tan(φ1), cosU1 = 1 / Math.sqrt((1 + tanU1*tanU1)), sinU1 = tanU1 * cosU1; 209 | const σ1 = Math.atan2(tanU1, cosα1); // σ1 = angular distance on the sphere from the equator to P1 210 | const sinα = cosU1 * sinα1; // α = azimuth of the geodesic at the equator 211 | const cosSqα = 1 - sinα*sinα; 212 | const uSq = cosSqα * (a*a - b*b) / (b*b); 213 | const A = 1 + uSq/16384*(4096+uSq*(-768+uSq*(320-175*uSq))); 214 | const B = uSq/1024 * (256+uSq*(-128+uSq*(74-47*uSq))); 215 | 216 | let σ = s / (b*A), sinσ = null, cosσ = null; // σ = angular distance P₁ P₂ on the sphere 217 | let cos2σₘ = null; // σₘ = angular distance on the sphere from the equator to the midpoint of the line 218 | 219 | let σʹ = null, iterations = 0; 220 | do { 221 | cos2σₘ = Math.cos(2*σ1 + σ); 222 | sinσ = Math.sin(σ); 223 | cosσ = Math.cos(σ); 224 | const Δσ = B*sinσ*(cos2σₘ+B/4*(cosσ*(-1+2*cos2σₘ*cos2σₘ)-B/6*cos2σₘ*(-3+4*sinσ*sinσ)*(-3+4*cos2σₘ*cos2σₘ))); 225 | σʹ = σ; 226 | σ = s / (b*A) + Δσ; 227 | } while (Math.abs(σ-σʹ) > 1e-12 && ++iterations<100); // TV: 'iterate until negligible change in λ' (≈0.006mm) 228 | if (iterations >= 100) throw new EvalError('Vincenty formula failed to converge'); // not possible? 229 | 230 | const x = sinU1*sinσ - cosU1*cosσ*cosα1; 231 | const φ2 = Math.atan2(sinU1*cosσ + cosU1*sinσ*cosα1, (1-f)*Math.sqrt(sinα*sinα + x*x)); 232 | const λ = Math.atan2(sinσ*sinα1, cosU1*cosσ - sinU1*sinσ*cosα1); 233 | const C = f/16*cosSqα*(4+f*(4-3*cosSqα)); 234 | const L = λ - (1-C) * f * sinα * (σ + C*sinσ*(cos2σₘ+C*cosσ*(-1+2*cos2σₘ*cos2σₘ))); 235 | const λ2 = λ1 + L; 236 | 237 | const α2 = Math.atan2(sinα, -x); 238 | 239 | const destinationPoint = new LatLonEllipsoidal_Vincenty(φ2.toDegrees(), λ2.toDegrees(), 0, this.datum); 240 | 241 | return { 242 | point: destinationPoint, 243 | finalBearing: Dms.wrap360(α2.toDegrees()), 244 | iterations: iterations, 245 | }; 246 | } 247 | 248 | 249 | /** 250 | * Vincenty inverse calculation. 251 | * 252 | * Ellipsoid parameters are taken from datum of 'this' point. Height is ignored. 253 | * 254 | * @private 255 | * @param {LatLon} point - Latitude/longitude of destination point. 256 | * @returns {Object} Object including distance, initialBearing, finalBearing. 257 | * @throws {TypeError} Invalid point. 258 | * @throws {RangeError} Points must be on surface of ellipsoid. 259 | * @throws {EvalError} Formula failed to converge. 260 | */ 261 | inverse(point) { 262 | if (!(point instanceof LatLonEllipsoidal)) throw new TypeError(`invalid point ‘${point}’`); 263 | if (this.height!=0 || point.height!=0) throw new RangeError('point must be on the surface of the ellipsoid'); 264 | 265 | const p1 = this, p2 = point; 266 | const φ1 = p1.lat.toRadians(), λ1 = p1.lon.toRadians(); 267 | const φ2 = p2.lat.toRadians(), λ2 = p2.lon.toRadians(); 268 | 269 | // allow alternative ellipsoid to be specified 270 | const ellipsoid = this.datum ? this.datum.ellipsoid : LatLonEllipsoidal.ellipsoids.WGS84; 271 | const { a, b, f } = ellipsoid; 272 | 273 | const L = λ2 - λ1; // L = difference in longitude, U = reduced latitude, defined by tan U = (1-f)·tanφ. 274 | const tanU1 = (1-f) * Math.tan(φ1), cosU1 = 1 / Math.sqrt((1 + tanU1*tanU1)), sinU1 = tanU1 * cosU1; 275 | const tanU2 = (1-f) * Math.tan(φ2), cosU2 = 1 / Math.sqrt((1 + tanU2*tanU2)), sinU2 = tanU2 * cosU2; 276 | 277 | const antipodal = Math.abs(L) > π/2 || Math.abs(φ2-φ1) > π/2; 278 | 279 | let λ = L, sinλ = null, cosλ = null; // λ = difference in longitude on an auxiliary sphere 280 | let σ = antipodal ? π : 0, sinσ = 0, cosσ = antipodal ? -1 : 1, sinSqσ = null; // σ = angular distance P₁ P₂ on the sphere 281 | let cos2σₘ = 1; // σₘ = angular distance on the sphere from the equator to the midpoint of the line 282 | let cosSqα = 1; // α = azimuth of the geodesic at the equator 283 | 284 | let λʹ = null, iterations = 0; 285 | do { 286 | sinλ = Math.sin(λ); 287 | cosλ = Math.cos(λ); 288 | sinSqσ = (cosU2*sinλ)**2 + (cosU1*sinU2-sinU1*cosU2*cosλ)**2; 289 | if (Math.abs(sinSqσ) < 1e-24) break; // co-incident/antipodal points (σ < ≈0.006mm) 290 | sinσ = Math.sqrt(sinSqσ); 291 | cosσ = sinU1*sinU2 + cosU1*cosU2*cosλ; 292 | σ = Math.atan2(sinσ, cosσ); 293 | const sinα = cosU1 * cosU2 * sinλ / sinσ; 294 | cosSqα = 1 - sinα*sinα; 295 | cos2σₘ = (cosSqα != 0) ? (cosσ - 2*sinU1*sinU2/cosSqα) : 0; // on equatorial line cos²α = 0 (§6) 296 | const C = f/16*cosSqα*(4+f*(4-3*cosSqα)); 297 | λʹ = λ; 298 | λ = L + (1-C) * f * sinα * (σ + C*sinσ*(cos2σₘ+C*cosσ*(-1+2*cos2σₘ*cos2σₘ))); 299 | const iterationCheck = antipodal ? Math.abs(λ)-π : Math.abs(λ); 300 | if (iterationCheck > π) throw new EvalError('λ > π'); 301 | } while (Math.abs(λ-λʹ) > 1e-12 && ++iterations<1000); // TV: 'iterate until negligible change in λ' (≈0.006mm) 302 | if (iterations >= 1000) throw new EvalError('Vincenty formula failed to converge'); 303 | 304 | const uSq = cosSqα * (a*a - b*b) / (b*b); 305 | const A = 1 + uSq/16384*(4096+uSq*(-768+uSq*(320-175*uSq))); 306 | const B = uSq/1024 * (256+uSq*(-128+uSq*(74-47*uSq))); 307 | const Δσ = B*sinσ*(cos2σₘ+B/4*(cosσ*(-1+2*cos2σₘ*cos2σₘ)-B/6*cos2σₘ*(-3+4*sinσ*sinσ)*(-3+4*cos2σₘ*cos2σₘ))); 308 | 309 | const s = b*A*(σ-Δσ); // s = length of the geodesic 310 | 311 | // note special handling of exactly antipodal points where sin²σ = 0 (due to discontinuity 312 | // atan2(0, 0) = 0 but atan2(ε, 0) = π/2 / 90°) - in which case bearing is always meridional, 313 | // due north (or due south!) 314 | // α = azimuths of the geodesic; α2 the direction P₁ P₂ produced 315 | const α1 = Math.abs(sinSqσ) < ε ? 0 : Math.atan2(cosU2*sinλ, cosU1*sinU2-sinU1*cosU2*cosλ); 316 | const α2 = Math.abs(sinSqσ) < ε ? π : Math.atan2(cosU1*sinλ, -sinU1*cosU2+cosU1*sinU2*cosλ); 317 | 318 | return { 319 | distance: s, 320 | initialBearing: Math.abs(s) < ε ? NaN : Dms.wrap360(α1.toDegrees()), 321 | finalBearing: Math.abs(s) < ε ? NaN : Dms.wrap360(α2.toDegrees()), 322 | iterations: iterations, 323 | }; 324 | } 325 | 326 | } 327 | 328 | 329 | /* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 330 | 331 | export { LatLonEllipsoidal_Vincenty as default, Dms }; 332 | -------------------------------------------------------------------------------- /latlon-nvector-ellipsoidal.js: -------------------------------------------------------------------------------- 1 | /* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 2 | /* Vector-based ellipsoidal geodetic (latitude/longitude) functions (c) Chris Veness 2015-2021 */ 3 | /* MIT Licence */ 4 | /* www.movable-type.co.uk/scripts/latlong-vectors.html */ 5 | /* www.movable-type.co.uk/scripts/geodesy-library.html#latlon-nvector-ellipsoidal */ 6 | /* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 7 | 8 | import LatLonEllipsoidal, { Cartesian, Vector3d, Dms } from './latlon-ellipsoidal.js'; 9 | 10 | 11 | /** 12 | * Tools for working with points on (ellipsoidal models of) the earth’s surface using a vector-based 13 | * approach using ‘n-vectors’ (rather than the more common spherical trigonometry). 14 | * 15 | * Based on Kenneth Gade’s ‘Non-singular Horizontal Position Representation’. 16 | * 17 | * Note that these formulations take x => 0°N,0°E, y => 0°N,90°E, z => 90°N (in order that n-vector 18 | * = cartesian vector at 0°N,0°E); Gade uses x => 90°N, y => 0°N,90°E, z => 0°N,0°E. 19 | * 20 | * @module latlon-nvector-ellipsoidal 21 | */ 22 | 23 | 24 | /* LatLon_NvectorEllipsoidal - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 25 | 26 | 27 | /** 28 | * Latitude/longitude points on an ellipsoidal model earth augmented with methods for calculating 29 | * delta vectors between points, and converting to n-vectors. 30 | * 31 | * @extends LatLonEllipsoidal 32 | */ 33 | class LatLon_NvectorEllipsoidal extends LatLonEllipsoidal { 34 | 35 | /** 36 | * Calculates delta from ‘this’ point to supplied point. 37 | * 38 | * The delta is given as a north-east-down NED vector. Note that this is a linear delta, 39 | * unrelated to a geodesic on the ellipsoid. 40 | * 41 | * Points need not be defined on the same datum. 42 | * 43 | * @param {LatLon} point - Point delta is to be determined to. 44 | * @returns {Ned} Delta from ‘this’ point to supplied point in local tangent plane of this point. 45 | * @throws {TypeError} Invalid point. 46 | * 47 | * @example 48 | * const a = new LatLon(49.66618, 3.45063, 99); 49 | * const b = new LatLon(48.88667, 2.37472, 64); 50 | * const delta = a.deltaTo(b); // [N:-86127,E:-78901,D:1104] 51 | * const dist = delta.length; // 116809.178 m 52 | * const brng = delta.bearing; // 222.493° 53 | * const elev = delta.elevation; // -0.5416° 54 | */ 55 | deltaTo(point) { 56 | if (!(point instanceof LatLonEllipsoidal)) throw new TypeError(`invalid point ‘${point}’`); 57 | 58 | // get delta in cartesian frame 59 | const c1 = this.toCartesian(); 60 | const c2 = point.toCartesian(); 61 | const δc = c2.minus(c1); 62 | 63 | // get local (n-vector) coordinate frame 64 | const n1 = this.toNvector(); 65 | const a = new Vector3d(0, 0, 1); // axis vector pointing to 90°N 66 | const d = n1.negate(); // down (pointing opposite to n-vector) 67 | const e = a.cross(n1).unit(); // east (pointing perpendicular to the plane) 68 | const n = e.cross(d); // north (by right hand rule) 69 | 70 | // rotation matrix is built from n-vector coordinate frame axes (using row vectors) 71 | const r = [ 72 | [ n.x, n.y, n.z ], 73 | [ e.x, e.y, e.z ], 74 | [ d.x, d.y, d.z ], 75 | ]; 76 | 77 | // apply rotation to δc to get delta in n-vector reference frame 78 | const δn = new Cartesian( 79 | r[0][0]*δc.x + r[0][1]*δc.y + r[0][2]*δc.z, 80 | r[1][0]*δc.x + r[1][1]*δc.y + r[1][2]*δc.z, 81 | r[2][0]*δc.x + r[2][1]*δc.y + r[2][2]*δc.z, 82 | ); 83 | 84 | return new Ned(δn.x, δn.y, δn.z); 85 | } 86 | 87 | 88 | /** 89 | * Calculates destination point using supplied delta from ‘this’ point. 90 | * 91 | * The delta is given as a north-east-down NED vector. Note that this is a linear delta, 92 | * unrelated to a geodesic on the ellipsoid. 93 | * 94 | * @param {Ned} delta - Delta from ‘this’ point to supplied point in local tangent plane of this point. 95 | * @returns {LatLon} Destination point. 96 | * 97 | * @example 98 | * const a = new LatLon(49.66618, 3.45063, 99); 99 | * const delta = Ned.fromDistanceBearingElevation(116809.178, 222.493, -0.5416); // [N:-86127,E:-78901,D:1104] 100 | * const b = a.destinationPoint(delta); // 48.8867°N, 002.3747°E 101 | */ 102 | destinationPoint(delta) { 103 | if (!(delta instanceof Ned)) throw new TypeError('delta is not Ned object'); 104 | 105 | // convert North-East-Down delta to standard x/y/z vector in coordinate frame of n-vector 106 | const δn = new Vector3d(delta.north, delta.east, delta.down); 107 | 108 | // get local (n-vector) coordinate frame 109 | const n1 = this.toNvector(); 110 | const a = new Vector3d(0, 0, 1); // axis vector pointing to 90°N 111 | const d = n1.negate(); // down (pointing opposite to n-vector) 112 | const e = a.cross(n1).unit(); // east (pointing perpendicular to the plane) 113 | const n = e.cross(d); // north (by right hand rule) 114 | 115 | // rotation matrix is built from n-vector coordinate frame axes (using column vectors) 116 | const r = [ 117 | [ n.x, e.x, d.x ], 118 | [ n.y, e.y, d.y ], 119 | [ n.z, e.z, d.z ], 120 | ]; 121 | 122 | // apply rotation to δn to get delta in cartesian (ECEF) coordinate reference frame 123 | const δc = new Cartesian( 124 | r[0][0]*δn.x + r[0][1]*δn.y + r[0][2]*δn.z, 125 | r[1][0]*δn.x + r[1][1]*δn.y + r[1][2]*δn.z, 126 | r[2][0]*δn.x + r[2][1]*δn.y + r[2][2]*δn.z, 127 | ); 128 | 129 | // apply (cartesian) delta to c1 to obtain destination point as cartesian coordinate 130 | const c1 = this.toCartesian(); // convert this LatLon to Cartesian 131 | const v2 = c1.plus(δc); // the plus() gives us a plain vector,.. 132 | const c2 = new Cartesian(v2.x, v2.y, v2.z); // ... need to convert it to Cartesian to get LatLon 133 | 134 | // return destination cartesian coordinate as latitude/longitude 135 | return c2.toLatLon(); 136 | } 137 | 138 | 139 | /** 140 | * Converts ‘this’ lat/lon point to n-vector (normal to the earth's surface). 141 | * 142 | * @returns {Nvector} N-vector representing lat/lon point. 143 | * 144 | * @example 145 | * const p = new LatLon(45, 45); 146 | * const n = p.toNvector(); // [0.5000,0.5000,0.7071] 147 | */ 148 | toNvector() { // note: replicated in LatLonNvectorSpherical 149 | const φ = this.lat.toRadians(); 150 | const λ = this.lon.toRadians(); 151 | 152 | const sinφ = Math.sin(φ), cosφ = Math.cos(φ); 153 | const sinλ = Math.sin(λ), cosλ = Math.cos(λ); 154 | 155 | // right-handed vector: x -> 0°E,0°N; y -> 90°E,0°N, z -> 90°N 156 | const x = cosφ * cosλ; 157 | const y = cosφ * sinλ; 158 | const z = sinφ; 159 | 160 | return new NvectorEllipsoidal(x, y, z, this.h, this.datum); 161 | } 162 | 163 | 164 | /** 165 | * Converts ‘this’ point from (geodetic) latitude/longitude coordinates to (geocentric) cartesian 166 | * (x/y/z) coordinates. 167 | * 168 | * @returns {Cartesian} Cartesian point equivalent to lat/lon point, with x, y, z in metres from 169 | * earth centre. 170 | */ 171 | toCartesian() { 172 | const c = super.toCartesian(); // c is 'Cartesian' 173 | 174 | // return Cartesian_Nvector to have toNvector() available as method of exported LatLon 175 | return new Cartesian_Nvector(c.x, c.y, c.z); 176 | } 177 | 178 | } 179 | 180 | 181 | /* Nvector - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 182 | 183 | 184 | /** 185 | * An n-vector is a position representation using a (unit) vector normal to the Earth ellipsoid. 186 | * Unlike latitude/longitude points, n-vectors have no singularities or discontinuities. 187 | * 188 | * For many applications, n-vectors are more convenient to work with than other position 189 | * representations such as latitude/longitude, earth-centred earth-fixed (ECEF) vectors, UTM 190 | * coordinates, etc. 191 | * 192 | * @extends Vector3d 193 | */ 194 | class NvectorEllipsoidal extends Vector3d { 195 | 196 | // note commonality with latlon-nvector-spherical 197 | 198 | /** 199 | * Creates a 3d n-vector normal to the Earth's surface. 200 | * 201 | * @param {number} x - X component of n-vector (towards 0°N, 0°E). 202 | * @param {number} y - Y component of n-vector (towards 0°N, 90°E). 203 | * @param {number} z - Z component of n-vector (towards 90°N). 204 | * @param {number} [h=0] - Height above ellipsoid surface in metres. 205 | * @param {LatLon.datums} [datum=WGS84] - Datum this n-vector is defined within. 206 | */ 207 | constructor(x, y, z, h=0, datum=LatLonEllipsoidal.datums.WGS84) { 208 | const u = new Vector3d(x, y, z).unit(); // n-vectors are always normalised 209 | 210 | super(u.x, u.y, u.z); 211 | 212 | this.h = Number(h); 213 | this.datum = datum; 214 | } 215 | 216 | 217 | /** 218 | * Converts ‘this’ n-vector to latitude/longitude point. 219 | * 220 | * @returns {LatLon} Latitude/longitude point equivalent to this n-vector. 221 | * 222 | * @example 223 | * const p = new Nvector(0.500000, 0.500000, 0.707107).toLatLon(); // 45.0000°N, 045.0000°E 224 | */ 225 | toLatLon() { 226 | // tanφ = z / √(x²+y²), tanλ = y / x (same as spherical calculation) 227 | 228 | const { x, y, z } = this; 229 | 230 | const φ = Math.atan2(z, Math.sqrt(x*x + y*y)); 231 | const λ = Math.atan2(y, x); 232 | 233 | return new LatLon_NvectorEllipsoidal(φ.toDegrees(), λ.toDegrees(), this.h, this.datum); 234 | } 235 | 236 | 237 | /** 238 | * Converts ‘this’ n-vector to cartesian coordinate. 239 | * 240 | * qv Gade 2010 ‘A Non-singular Horizontal Position Representation’ eqn 22 241 | * 242 | * @returns {Cartesian} Cartesian coordinate equivalent to this n-vector. 243 | * 244 | * @example 245 | * const c = new Nvector(0.500000, 0.500000, 0.707107).toCartesian(); // [3194419,3194419,4487349] 246 | * const p = c.toLatLon(); // 45.0000°N, 045.0000°E 247 | */ 248 | toCartesian() { 249 | const { b, f } = this.datum.ellipsoid; 250 | const { x, y, z, h } = this; 251 | 252 | const m = (1-f) * (1-f); // (1−f)² = b²/a² 253 | const n = b / Math.sqrt(x*x/m + y*y/m + z*z); 254 | 255 | const xʹ = n * x / m + x*h; 256 | const yʹ = n * y / m + y*h; 257 | const zʹ = n * z + z*h; 258 | 259 | return new Cartesian_Nvector(xʹ, yʹ, zʹ); 260 | } 261 | 262 | 263 | /** 264 | * Returns a string representation of ‘this’ (unit) n-vector. Height component is only shown if 265 | * dpHeight is specified. 266 | * 267 | * @param {number} [dp=3] - Number of decimal places to display. 268 | * @param {number} [dpHeight=null] - Number of decimal places to use for height; default is no height display. 269 | * @returns {string} Comma-separated x, y, z, h values. 270 | * 271 | * @example 272 | * new Nvector(0.5000, 0.5000, 0.7071).toString(); // [0.500,0.500,0.707] 273 | * new Nvector(0.5000, 0.5000, 0.7071, 1).toString(6, 0); // [0.500002,0.500002,0.707103+1m] 274 | */ 275 | toString(dp=3, dpHeight=null) { 276 | const { x, y, z } = this; 277 | const h = `${this.h>=0 ? '+' : ''}${this.h.toFixed(dpHeight)}m`; 278 | 279 | return `[${x.toFixed(dp)},${y.toFixed(dp)},${z.toFixed(dp)}${dpHeight==null ? '' : h}]`; 280 | } 281 | 282 | } 283 | 284 | 285 | /* Cartesian - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 286 | 287 | 288 | /** 289 | * Cartesian_Nvector extends Cartesian with method to convert cartesian coordinates to n-vectors. 290 | * 291 | * @extends Cartesian 292 | */ 293 | class Cartesian_Nvector extends Cartesian { 294 | 295 | 296 | /** 297 | * Converts ‘this’ cartesian coordinate to an n-vector. 298 | * 299 | * qv Gade 2010 ‘A Non-singular Horizontal Position Representation’ eqn 23 300 | * 301 | * @param {LatLon.datums} [datum=WGS84] - Datum to use for conversion. 302 | * @returns {Nvector} N-vector equivalent to this cartesian coordinate. 303 | * 304 | * @example 305 | * const c = new Cartesian(3980581, 97, 4966825); 306 | * const n = c.toNvector(); // { x: 0.6228, y: 0.0000, z: 0.7824, h: 0.0000 } 307 | */ 308 | toNvector(datum=LatLonEllipsoidal.datums.WGS84) { 309 | const { a, f } = datum.ellipsoid; 310 | const { x, y, z } = this; 311 | 312 | const e2 = 2*f - f*f; // e² = 1st eccentricity squared ≡ (a²-b²)/a² 313 | const e4 = e2*e2; // e⁴ 314 | 315 | const p = (x*x + y*y) / (a*a); 316 | const q = z*z * (1-e2) / (a*a); 317 | const r = (p + q - e4) / 6; 318 | const s = (e4*p*q) / (4*r*r*r); 319 | const t = Math.cbrt(1 + s + Math.sqrt(2*s+s*s)); 320 | const u = r * (1 + t + 1/t); 321 | const v = Math.sqrt(u*u + e4*q); 322 | const w = e2 * (u + v - q) / (2*v); 323 | const k = Math.sqrt(u + v + w*w) - w; 324 | const d = k * Math.sqrt(x*x + y*y) / (k + e2); 325 | 326 | const tmp = 1 / Math.sqrt(d*d + z*z); 327 | const xʹ = tmp * k/(k+e2) * x; 328 | const yʹ = tmp * k/(k+e2) * y; 329 | const zʹ = tmp * z; 330 | const h = (k + e2 - 1)/k * Math.sqrt(d*d + z*z); 331 | 332 | const n = new NvectorEllipsoidal(xʹ, yʹ, zʹ, h, datum); 333 | 334 | return n; 335 | } 336 | 337 | } 338 | 339 | 340 | /* Ned - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 341 | 342 | 343 | /** 344 | * North-east-down (NED), also known as local tangent plane (LTP), is a vector in the local 345 | * coordinate frame of a body. 346 | */ 347 | class Ned { 348 | 349 | /** 350 | * Creates North-East-Down vector. 351 | * 352 | * @param {number} north - North component in metres. 353 | * @param {number} east - East component in metres. 354 | * @param {number} down - Down component (normal to the surface of the ellipsoid) in metres. 355 | * 356 | * @example 357 | * import { Ned } from '/js/geodesy/latlon-nvector-ellipsoidal.js'; 358 | * const delta = new Ned(110569, 111297, 1936); // [N:110569,E:111297,D:1936] 359 | */ 360 | constructor(north, east, down) { 361 | this.north = north; 362 | this.east = east; 363 | this.down = down; 364 | } 365 | 366 | 367 | /** 368 | * Length of NED vector. 369 | * 370 | * @returns {number} Length of NED vector in metres. 371 | */ 372 | get length() { 373 | const { north, east, down } = this; 374 | 375 | return Math.sqrt(north*north + east*east + down*down); 376 | } 377 | 378 | 379 | /** 380 | * Bearing of NED vector. 381 | * 382 | * @returns {number} Bearing of NED vector in degrees from north. 383 | */ 384 | get bearing() { 385 | const θ = Math.atan2(this.east, this.north); 386 | 387 | return Dms.wrap360(θ.toDegrees()); // normalise to range 0..360° 388 | } 389 | 390 | 391 | /** 392 | * Elevation of NED vector. 393 | * 394 | * @returns {number} Elevation of NED vector in degrees from horizontal (ie tangent to ellipsoid surface). 395 | */ 396 | get elevation() { 397 | const α = Math.asin(this.down/this.length); 398 | 399 | return -α.toDegrees(); 400 | } 401 | 402 | 403 | /** 404 | * Creates North-East-Down vector from distance, bearing, & elevation (in local coordinate system). 405 | * 406 | * @param {number} dist - Length of NED vector in metres. 407 | * @param {number} brng - Bearing (in degrees from north) of NED vector . 408 | * @param {number} elev - Elevation (in degrees from local coordinate frame horizontal) of NED vector. 409 | * @returns {Ned} North-East-Down vector equivalent to distance, bearing, elevation. 410 | * 411 | * @example 412 | * const delta = Ned.fromDistanceBearingElevation(116809.178, 222.493, -0.5416); // [N:-86127,E:-78901,D:1104] 413 | */ 414 | static fromDistanceBearingElevation(dist, brng, elev) { 415 | const θ = Number(brng).toRadians(); 416 | const α = Number(elev).toRadians(); 417 | dist = Number(dist); 418 | 419 | const sinθ = Math.sin(θ), cosθ = Math.cos(θ); 420 | const sinα = Math.sin(α), cosα = Math.cos(α); 421 | 422 | const n = cosθ * dist*cosα; 423 | const e = sinθ * dist*cosα; 424 | const d = -sinα * dist; 425 | 426 | return new Ned(n, e, d); 427 | } 428 | 429 | 430 | /** 431 | * Returns a string representation of ‘this’ NED vector. 432 | * 433 | * @param {number} [dp=0] - Number of decimal places to display. 434 | * @returns {string} Comma-separated (labelled) n, e, d values. 435 | */ 436 | toString(dp=0) { 437 | return `[N:${this.north.toFixed(dp)},E:${this.east.toFixed(dp)},D:${this.down.toFixed(dp)}]`; 438 | } 439 | 440 | } 441 | 442 | 443 | /* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 444 | 445 | export { LatLon_NvectorEllipsoidal as default, NvectorEllipsoidal as Nvector, Cartesian_Nvector as Cartesian, Ned, Dms }; 446 | -------------------------------------------------------------------------------- /mgrs.js: -------------------------------------------------------------------------------- 1 | /* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 2 | /* MGRS / UTM Conversion Functions (c) Chris Veness 2014-2022 */ 3 | /* MIT Licence */ 4 | /* www.movable-type.co.uk/scripts/latlong-utm-mgrs.html */ 5 | /* www.movable-type.co.uk/scripts/geodesy-library.html#mgrs */ 6 | /* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 7 | 8 | import Utm, { LatLon as LatLonEllipsoidal, Dms } from './utm.js'; 9 | 10 | 11 | /** 12 | * Military Grid Reference System (MGRS/NATO) grid references provides geocoordinate references 13 | * covering the entire globe, based on UTM projections. 14 | * 15 | * MGRS references comprise a grid zone designator, a 100km square identification, and an easting 16 | * and northing (in metres); e.g. ‘31U DQ 48251 11932’. 17 | * 18 | * Depending on requirements, some parts of the reference may be omitted (implied), and 19 | * eastings/northings may be given to varying resolution. 20 | * 21 | * qv www.fgdc.gov/standards/projects/FGDC-standards-projects/usng/fgdc_std_011_2001_usng.pdf 22 | * 23 | * @module mgrs 24 | */ 25 | 26 | 27 | /* 28 | * Latitude bands C..X 8° each, covering 80°S to 84°N 29 | */ 30 | const latBands = 'CDEFGHJKLMNPQRSTUVWXX'; // X is repeated for 80-84°N 31 | 32 | 33 | /* 34 | * 100km grid square column (‘e’) letters repeat every third zone 35 | */ 36 | const e100kLetters = [ 'ABCDEFGH', 'JKLMNPQR', 'STUVWXYZ' ]; 37 | 38 | 39 | /* 40 | * 100km grid square row (‘n’) letters repeat every other zone 41 | */ 42 | const n100kLetters = [ 'ABCDEFGHJKLMNPQRSTUV', 'FGHJKLMNPQRSTUVABCDE' ]; 43 | 44 | 45 | /* Mgrs - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 46 | 47 | 48 | /** 49 | * Military Grid Reference System (MGRS/NATO) grid references, with methods to parse references, and 50 | * to convert to UTM coordinates. 51 | */ 52 | class Mgrs { 53 | 54 | /** 55 | * Creates an Mgrs grid reference object. 56 | * 57 | * @param {number} zone - 6° longitudinal zone (1..60 covering 180°W..180°E). 58 | * @param {string} band - 8° latitudinal band (C..X covering 80°S..84°N). 59 | * @param {string} e100k - First letter (E) of 100km grid square. 60 | * @param {string} n100k - Second letter (N) of 100km grid square. 61 | * @param {number} easting - Easting in metres within 100km grid square. 62 | * @param {number} northing - Northing in metres within 100km grid square. 63 | * @param {LatLon.datums} [datum=WGS84] - Datum UTM coordinate is based on. 64 | * @throws {RangeError} Invalid MGRS grid reference. 65 | * 66 | * @example 67 | * import Mgrs from '/js/geodesy/mgrs.js'; 68 | * const mgrsRef = new Mgrs(31, 'U', 'D', 'Q', 48251, 11932); // 31U DQ 48251 11932 69 | */ 70 | constructor(zone, band, e100k, n100k, easting, northing, datum=LatLonEllipsoidal.datums.WGS84) { 71 | if (!(1<=zone && zone<=60)) throw new RangeError(`invalid MGRS zone ‘${zone}’`); 72 | if (zone != parseInt(zone)) throw new RangeError(`invalid MGRS zone ‘${zone}’`); 73 | const errors = []; // check & report all other possible errors rather than reporting one-by-one 74 | if (band.length!=1 || latBands.indexOf(band) == -1) errors.push(`invalid MGRS band ‘${band}’`); 75 | if (e100k.length!=1 || e100kLetters[(zone-1)%3].indexOf(e100k) == -1) errors.push(`invalid MGRS 100km grid square column ‘${e100k}’ for zone ${zone}`); 76 | if (n100k.length!=1 || n100kLetters[0].indexOf(n100k) == -1) errors.push(`invalid MGRS 100km grid square row ‘${n100k}’`); 77 | if (isNaN(Number(easting))) errors.push(`invalid MGRS easting ‘${easting}’`); 78 | if (isNaN(Number(northing))) errors.push(`invalid MGRS northing ‘${northing}’`); 79 | if (Number(easting) < 0 || Number(easting) > 99999) errors.push(`invalid MGRS easting ‘${easting}’`); 80 | if (Number(northing) < 0 || Number(northing) > 99999) errors.push(`invalid MGRS northing ‘${northing}’`); 81 | if (!datum || datum.ellipsoid==undefined) errors.push(`unrecognised datum ‘${datum}’`); 82 | if (errors.length > 0) throw new RangeError(errors.join(', ')); 83 | 84 | this.zone = Number(zone); 85 | this.band = band; 86 | this.e100k = e100k; 87 | this.n100k = n100k; 88 | this.easting = Math.floor(easting); 89 | this.northing = Math.floor(northing); 90 | this.datum = datum; 91 | } 92 | 93 | 94 | /** 95 | * Converts MGRS grid reference to UTM coordinate. 96 | * 97 | * Grid references refer to squares rather than points (with the size of the square indicated 98 | * by the precision of the reference); this conversion will return the UTM coordinate of the SW 99 | * corner of the grid reference square. 100 | * 101 | * @returns {Utm} UTM coordinate of SW corner of this MGRS grid reference. 102 | * 103 | * @example 104 | * const mgrsRef = Mgrs.parse('31U DQ 48251 11932'); 105 | * const utmCoord = mgrsRef.toUtm(); // 31 N 448251 5411932 106 | */ 107 | toUtm() { 108 | const hemisphere = this.band>='N' ? 'N' : 'S'; 109 | 110 | // get easting specified by e100k (note +1 because eastings start at 166e3 due to 500km false origin) 111 | const col = e100kLetters[(this.zone-1)%3].indexOf(this.e100k) + 1; 112 | const e100kNum = col * 100e3; // e100k in metres 113 | 114 | // get northing specified by n100k 115 | const row = n100kLetters[(this.zone-1)%2].indexOf(this.n100k); 116 | const n100kNum = row * 100e3; // n100k in metres 117 | 118 | // get latitude of (bottom of) band (10 bands above the equator, 8°latitude each) 119 | const latBand = (latBands.indexOf(this.band)-10)*8; 120 | 121 | // get southern-most northing of bottom of band, using floor() to extend to include entirety 122 | // of bottom-most 100km square - note in northern hemisphere, centre of zone will be furthest 123 | // south; in southern hemisphere extremity of zone will be furthest south, so use 3°E / 0°E 124 | const lon = this.band >= 'N' ? 3 : 0; 125 | const nBand = Math.floor(new LatLonEllipsoidal(latBand, lon).toUtm().northing/100e3)*100e3; 126 | 127 | // 100km grid square row letters repeat every 2,000km north; add enough 2,000km blocks to 128 | // get into required band 129 | let n2M = 0; // northing of 2,000km block 130 | while (n2M + n100kNum + this.northing < nBand) n2M += 2000e3; 131 | 132 | return new Utm_Mgrs(this.zone, hemisphere, e100kNum+this.easting, n2M+n100kNum+this.northing, this.datum); 133 | } 134 | 135 | 136 | /** 137 | * Parses string representation of MGRS grid reference. 138 | * 139 | * An MGRS grid reference comprises (space-separated) 140 | * - grid zone designator (GZD) 141 | * - 100km grid square letter-pair 142 | * - easting 143 | * - northing. 144 | * 145 | * @param {string} mgrsGridRef - String representation of MGRS grid reference. 146 | * @returns {Mgrs} Mgrs grid reference object. 147 | * @throws {Error} Invalid MGRS grid reference. 148 | * 149 | * @example 150 | * const mgrsRef = Mgrs.parse('31U DQ 48251 11932'); 151 | * const mgrsRef = Mgrs.parse('31UDQ4825111932'); // military style no separators 152 | * // mgrsRef: { zone:31, band:'U', e100k:'D', n100k:'Q', easting:48251, northing:11932 } 153 | */ 154 | static parse(mgrsGridRef) { 155 | if (!mgrsGridRef) throw new Error(`invalid MGRS grid reference ‘${mgrsGridRef}’`); 156 | 157 | // check for military-style grid reference with no separators 158 | if (!mgrsGridRef.trim().match(/\s/)) { // convert mgrsGridRef to standard space-separated format 159 | const ref = mgrsGridRef.match(/(\d\d?[A-Z])([A-Z]{2})([0-9]{2,10})/i); 160 | if (!ref) throw new Error(`invalid MGRS grid reference ‘${mgrsGridRef}’`); 161 | 162 | const [ , gzd, en100k, en ] = ref; // split grid ref into gzd, en100k, en 163 | const [ easting, northing ] = [ en.slice(0, en.length/2), en.slice(-en.length/2) ]; 164 | mgrsGridRef = `${gzd} ${en100k} ${easting} ${northing}`; 165 | } 166 | 167 | // match separate elements (separated by whitespace) 168 | const ref = mgrsGridRef.match(/\S+/g); // returns [ gzd, e100k, easting, northing ] 169 | if (ref==null || ref.length!=4) throw new Error(`invalid MGRS grid reference ‘${mgrsGridRef}’`); 170 | 171 | const [ gzd, en100k, e, n ] = ref; // split grid ref into gzd, en100k, e, n 172 | const [ , zone, band ] = gzd.match(/(\d\d?)([A-Z])/i); // split gzd into zone, band 173 | const [ e100k, n100k ] = en100k.split(''); // split 100km letter-pair into e, n 174 | 175 | // standardise to 10-digit refs - ie metres) (but only if < 10-digit refs, to allow decimals) 176 | const easting = e.length>=5 ? e : e.padEnd(5, '0'); 177 | const northing = n.length>=5 ? n : n.padEnd(5, '0'); 178 | 179 | return new Mgrs(zone, band, e100k, n100k, easting, northing); 180 | } 181 | 182 | 183 | /** 184 | * Returns a string representation of an MGRS grid reference. 185 | * 186 | * To distinguish from civilian UTM coordinate representations, no space is included within the 187 | * zone/band grid zone designator. 188 | * 189 | * Components are separated by spaces: for a military-style unseparated string, use 190 | * Mgrs.toString().replace(/ /g, ''); 191 | * 192 | * Note that MGRS grid references get truncated, not rounded (unlike UTM coordinates); grid 193 | * references indicate a bounding square, rather than a point, with the size of the square 194 | * indicated by the precision - a precision of 10 indicates a 1-metre square, a precision of 4 195 | * indicates a 1,000-metre square (hence 31U DQ 48 11 indicates a 1km square with SW corner at 196 | * 31 N 448000 5411000, which would include the 1m square 31U DQ 48251 11932). 197 | * 198 | * @param {number} [digits=10] - Precision of returned grid reference (eg 4 = km, 10 = m). 199 | * @returns {string} This grid reference in standard format. 200 | * @throws {RangeError} Invalid precision. 201 | * 202 | * @example 203 | * const mgrsStr = new Mgrs(31, 'U', 'D', 'Q', 48251, 11932).toString(); // 31U DQ 48251 11932 204 | */ 205 | toString(digits=10) { 206 | if (![ 2, 4, 6, 8, 10 ].includes(Number(digits))) throw new RangeError(`invalid precision ‘${digits}’`); 207 | 208 | const { zone, band, e100k, n100k, easting, northing } = this; 209 | 210 | // truncate to required precision 211 | const eRounded = Math.floor(easting/Math.pow(10, 5-digits/2)); 212 | const nRounded = Math.floor(northing/Math.pow(10, 5-digits/2)); 213 | 214 | // ensure leading zeros 215 | const zPadded = zone.toString().padStart(2, '0'); 216 | const ePadded = eRounded.toString().padStart(digits/2, '0'); 217 | const nPadded = nRounded.toString().padStart(digits/2, '0'); 218 | 219 | return `${zPadded}${band} ${e100k}${n100k} ${ePadded} ${nPadded}`; 220 | } 221 | } 222 | 223 | 224 | /* Utm_Mgrs - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 225 | 226 | 227 | /** 228 | * Extends Utm with method to convert UTM coordinate to MGRS reference. 229 | * 230 | * @extends Utm 231 | */ 232 | class Utm_Mgrs extends Utm { 233 | 234 | /** 235 | * Converts UTM coordinate to MGRS reference. 236 | * 237 | * @returns {Mgrs} 238 | * @throws {TypeError} Invalid UTM coordinate. 239 | * 240 | * @example 241 | * const utmCoord = new Utm(31, 'N', 448251, 5411932); 242 | * const mgrsRef = utmCoord.toMgrs(); // 31U DQ 48251 11932 243 | */ 244 | toMgrs() { 245 | // MGRS zone is same as UTM zone 246 | const zone = this.zone; 247 | 248 | // convert UTM to lat/long to get latitude to determine band 249 | const latlong = this.toLatLon(); 250 | // grid zones are 8° tall, 0°N is 10th band 251 | const band = latBands.charAt(Math.floor(latlong.lat.toFixed(12)/8+10)); // latitude band 252 | 253 | // columns in zone 1 are A-H, zone 2 J-R, zone 3 S-Z, then repeating every 3rd zone 254 | const col = Math.floor(this.easting / 100e3); 255 | // (note -1 because eastings start at 166e3 due to 500km false origin) 256 | const e100k = e100kLetters[(zone-1)%3].charAt(col-1); 257 | 258 | // rows in even zones are A-V, in odd zones are F-E 259 | const row = Math.floor(this.northing / 100e3) % 20; 260 | const n100k = n100kLetters[(zone-1)%2].charAt(row); 261 | 262 | // truncate easting/northing to within 100km grid square & round to 1-metre precision 263 | const easting = Math.floor(this.easting % 100e3); 264 | const northing = Math.floor(this.northing % 100e3); 265 | 266 | return new Mgrs(zone, band, e100k, n100k, easting, northing); 267 | } 268 | 269 | } 270 | 271 | 272 | /** 273 | * Extends LatLonEllipsoidal adding toMgrs() method to the Utm object returned by LatLon.toUtm(). 274 | * 275 | * @extends LatLonEllipsoidal 276 | */ 277 | class Latlon_Utm_Mgrs extends LatLonEllipsoidal { 278 | 279 | /** 280 | * Converts latitude/longitude to UTM coordinate. 281 | * 282 | * Shadow of LatLon.toUtm, returning Utm augmented with toMgrs() method. 283 | * 284 | * @param {number} [zoneOverride] - Use specified zone rather than zone within which point lies; 285 | * note overriding the UTM zone has the potential to result in negative eastings, and 286 | * perverse results within Norway/Svalbard exceptions (this is unlikely to be relevant 287 | * for MGRS, but is needed as Mgrs passes through the Utm class). 288 | * @returns {Utm} UTM coordinate. 289 | * @throws {Error} If point not valid, if point outside latitude range. 290 | * 291 | * @example 292 | * const latlong = new LatLon(48.8582, 2.2945); 293 | * const utmCoord = latlong.toUtm(); // 31 N 448252 5411933 294 | */ 295 | toUtm(zoneOverride=undefined) { 296 | const utm = super.toUtm(zoneOverride); 297 | return new Utm_Mgrs(utm.zone, utm.hemisphere, utm.easting, utm.northing, utm.datum, utm.convergence, utm.scale); 298 | } 299 | 300 | } 301 | 302 | 303 | /* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 304 | 305 | export { Mgrs as default, Utm_Mgrs as Utm, Latlon_Utm_Mgrs as LatLon, Dms }; 306 | -------------------------------------------------------------------------------- /osgridref.js: -------------------------------------------------------------------------------- 1 | /* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 2 | /* Ordnance Survey Grid Reference functions (c) Chris Veness 2005-2021 */ 3 | /* MIT Licence */ 4 | /* www.movable-type.co.uk/scripts/latlong-gridref.html */ 5 | /* www.movable-type.co.uk/scripts/geodesy-library.html#osgridref */ 6 | /* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 7 | 8 | import LatLonEllipsoidal, { Dms } from './latlon-ellipsoidal-datum.js'; 9 | 10 | 11 | /** 12 | * Ordnance Survey OSGB grid references provide geocoordinate references for UK mapping purposes. 13 | * 14 | * Formulation implemented here due to Thomas, Redfearn, etc is as published by OS, but is inferior 15 | * to Krüger as used by e.g. Karney 2011. 16 | * 17 | * www.ordnancesurvey.co.uk/documents/resources/guide-coordinate-systems-great-britain.pdf. 18 | * 19 | * Note OSGB grid references cover Great Britain only; Ireland and the Channel Islands have their 20 | * own references. 21 | * 22 | * Note that these formulae are based on ellipsoidal calculations, and according to the OS are 23 | * accurate to about 4–5 metres – for greater accuracy, a geoid-based transformation (OSTN15) must 24 | * be used. 25 | */ 26 | 27 | /* 28 | * Converted 2015 to work with WGS84 by default, OSGB36 as option; 29 | * www.ordnancesurvey.co.uk/blog/2014/12/confirmation-on-changes-to-latitude-and-longitude 30 | */ 31 | 32 | 33 | /* OsGridRef - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 34 | 35 | 36 | const nationalGrid = { 37 | trueOrigin: { lat: 49, lon: -2 }, // true origin of grid 49°N,2°W on OSGB36 datum 38 | falseOrigin: { easting: -400e3, northing: 100e3 }, // easting & northing of false origin, metres from true origin 39 | scaleFactor: 0.9996012717, // scale factor on central meridian 40 | ellipsoid: LatLonEllipsoidal.ellipsoids.Airy1830, 41 | }; 42 | // note Irish National Grid uses t/o 53°30′N, 8°W, f/o 200kmW, 250kmS, scale factor 1.000035, on Airy 1830 Modified ellipsoid 43 | 44 | 45 | /** 46 | * OS Grid References with methods to parse and convert them to latitude/longitude points. 47 | */ 48 | class OsGridRef { 49 | 50 | /** 51 | * Creates an OsGridRef object. 52 | * 53 | * @param {number} easting - Easting in metres from OS Grid false origin. 54 | * @param {number} northing - Northing in metres from OS Grid false origin. 55 | * 56 | * @example 57 | * import OsGridRef from '/js/geodesy/osgridref.js'; 58 | * const gridref = new OsGridRef(651409, 313177); 59 | */ 60 | constructor(easting, northing) { 61 | this.easting = Number(easting); 62 | this.northing = Number(northing); 63 | 64 | if (isNaN(easting) || this.easting<0 || this.easting>700e3) throw new RangeError(`invalid easting ‘${easting}’`); 65 | if (isNaN(northing) || this.northing<0 || this.northing>1300e3) throw new RangeError(`invalid northing ‘${northing}’`); 66 | } 67 | 68 | 69 | /** 70 | * Converts ‘this’ Ordnance Survey Grid Reference easting/northing coordinate to latitude/longitude 71 | * (SW corner of grid square). 72 | * 73 | * While OS Grid References are based on OSGB-36, the Ordnance Survey have deprecated the use of 74 | * OSGB-36 for latitude/longitude coordinates (in favour of WGS-84), hence this function returns 75 | * WGS-84 by default, with OSGB-36 as an option. See www.ordnancesurvey.co.uk/blog/2014/12/2. 76 | * 77 | * Note formulation implemented here due to Thomas, Redfearn, etc is as published by OS, but is 78 | * inferior to Krüger as used by e.g. Karney 2011. 79 | * 80 | * @param {LatLon.datum} [datum=WGS84] - Datum to convert grid reference into. 81 | * @returns {LatLon} Latitude/longitude of supplied grid reference. 82 | * 83 | * @example 84 | * const gridref = new OsGridRef(651409.903, 313177.270); 85 | * const pWgs84 = gridref.toLatLon(); // 52°39′28.723″N, 001°42′57.787″E 86 | * // to obtain (historical) OSGB36 lat/lon point: 87 | * const pOsgb = gridref.toLatLon(LatLon.datums.OSGB36); // 52°39′27.253″N, 001°43′04.518″E 88 | */ 89 | toLatLon(datum=LatLonEllipsoidal.datums.WGS84) { 90 | const { easting: E, northing: N } = this; 91 | 92 | const { a, b } = nationalGrid.ellipsoid; // a = 6377563.396, b = 6356256.909 93 | const φ0 = nationalGrid.trueOrigin.lat.toRadians(); // latitude of true origin, 49°N 94 | const λ0 = nationalGrid.trueOrigin.lon.toRadians(); // longitude of true origin, 2°W 95 | const E0 = -nationalGrid.falseOrigin.easting; // easting of true origin, 400km 96 | const N0 = -nationalGrid.falseOrigin.northing; // northing of true origin, -100km 97 | const F0 = nationalGrid.scaleFactor; // 0.9996012717 98 | 99 | const e2 = 1 - (b*b)/(a*a); // eccentricity squared 100 | const n = (a-b)/(a+b), n2 = n*n, n3 = n*n*n; // n, n², n³ 101 | 102 | let φ=φ0, M=0; 103 | do { 104 | φ = (N-N0-M)/(a*F0) + φ; 105 | 106 | const Ma = (1 + n + (5/4)*n2 + (5/4)*n3) * (φ-φ0); 107 | const Mb = (3*n + 3*n2 + (21/8)*n3) * Math.sin(φ-φ0) * Math.cos(φ+φ0); 108 | const Mc = ((15/8)*n2 + (15/8)*n3) * Math.sin(2*(φ-φ0)) * Math.cos(2*(φ+φ0)); 109 | const Md = (35/24)*n3 * Math.sin(3*(φ-φ0)) * Math.cos(3*(φ+φ0)); 110 | M = b * F0 * (Ma - Mb + Mc - Md); // meridional arc 111 | 112 | } while (Math.abs(N-N0-M) >= 0.00001); // ie until < 0.01mm 113 | 114 | const cosφ = Math.cos(φ), sinφ = Math.sin(φ); 115 | const ν = a*F0/Math.sqrt(1-e2*sinφ*sinφ); // nu = transverse radius of curvature 116 | const ρ = a*F0*(1-e2)/Math.pow(1-e2*sinφ*sinφ, 1.5); // rho = meridional radius of curvature 117 | const η2 = ν/ρ-1; // eta = ? 118 | 119 | const tanφ = Math.tan(φ); 120 | const tan2φ = tanφ*tanφ, tan4φ = tan2φ*tan2φ, tan6φ = tan4φ*tan2φ; 121 | const secφ = 1/cosφ; 122 | const ν3 = ν*ν*ν, ν5 = ν3*ν*ν, ν7 = ν5*ν*ν; 123 | const VII = tanφ/(2*ρ*ν); 124 | const VIII = tanφ/(24*ρ*ν3)*(5+3*tan2φ+η2-9*tan2φ*η2); 125 | const IX = tanφ/(720*ρ*ν5)*(61+90*tan2φ+45*tan4φ); 126 | const X = secφ/ν; 127 | const XI = secφ/(6*ν3)*(ν/ρ+2*tan2φ); 128 | const XII = secφ/(120*ν5)*(5+28*tan2φ+24*tan4φ); 129 | const XIIA = secφ/(5040*ν7)*(61+662*tan2φ+1320*tan4φ+720*tan6φ); 130 | 131 | const dE = (E-E0), dE2 = dE*dE, dE3 = dE2*dE, dE4 = dE2*dE2, dE5 = dE3*dE2, dE6 = dE4*dE2, dE7 = dE5*dE2; 132 | φ = φ - VII*dE2 + VIII*dE4 - IX*dE6; 133 | const λ = λ0 + X*dE - XI*dE3 + XII*dE5 - XIIA*dE7; 134 | 135 | let point = new LatLon_OsGridRef(φ.toDegrees(), λ.toDegrees(), 0, LatLonEllipsoidal.datums.OSGB36); 136 | 137 | if (datum != LatLonEllipsoidal.datums.OSGB36) { 138 | // if point is required in datum other than OSGB36, convert it 139 | point = point.convertDatum(datum); 140 | // convertDatum() gives us a LatLon: convert to LatLon_OsGridRef which includes toOsGrid() 141 | point = new LatLon_OsGridRef(point.lat, point.lon, point.height, point.datum); 142 | } 143 | 144 | return point; 145 | } 146 | 147 | 148 | /** 149 | * Parses grid reference to OsGridRef object. 150 | * 151 | * Accepts standard grid references (eg 'SU 387 148'), with or without whitespace separators, from 152 | * two-digit references up to 10-digit references (1m × 1m square), or fully numeric comma-separated 153 | * references in metres (eg '438700,114800'). 154 | * 155 | * @param {string} gridref - Standard format OS Grid Reference. 156 | * @returns {OsGridRef} Numeric version of grid reference in metres from false origin (SW corner of 157 | * supplied grid square). 158 | * @throws {Error} Invalid grid reference. 159 | * 160 | * @example 161 | * const grid = OsGridRef.parse('TG 51409 13177'); // grid: { easting: 651409, northing: 313177 } 162 | */ 163 | static parse(gridref) { 164 | gridref = String(gridref).trim(); 165 | 166 | // check for fully numeric comma-separated gridref format 167 | let match = gridref.match(/^(\d+),\s*(\d+)$/); 168 | if (match) return new OsGridRef(match[1], match[2]); 169 | 170 | // validate format 171 | match = gridref.match(/^[HNST][ABCDEFGHJKLMNOPQRSTUVWXYZ]\s*[0-9]+\s*[0-9]+$/i); 172 | if (!match) throw new Error(`invalid grid reference ‘${gridref}’`); 173 | 174 | // get numeric values of letter references, mapping A->0, B->1, C->2, etc: 175 | let l1 = gridref.toUpperCase().charCodeAt(0) - 'A'.charCodeAt(0); // 500km square 176 | let l2 = gridref.toUpperCase().charCodeAt(1) - 'A'.charCodeAt(0); // 100km square 177 | // shuffle down letters after 'I' since 'I' is not used in grid: 178 | if (l1 > 7) l1--; 179 | if (l2 > 7) l2--; 180 | 181 | // convert grid letters into 100km-square indexes from false origin (grid square SV): 182 | const e100km = ((l1 - 2) % 5) * 5 + (l2 % 5); 183 | const n100km = (19 - Math.floor(l1 / 5) * 5) - Math.floor(l2 / 5); 184 | 185 | // skip grid letters to get numeric (easting/northing) part of ref 186 | let en = gridref.slice(2).trim().split(/\s+/); 187 | // if e/n not whitespace separated, split half way 188 | if (en.length == 1) en = [ en[0].slice(0, en[0].length / 2), en[0].slice(en[0].length / 2) ]; 189 | 190 | // validation 191 | if (en[0].length != en[1].length) throw new Error(`invalid grid reference ‘${gridref}’`); 192 | 193 | // standardise to 10-digit refs (metres) 194 | en[0] = en[0].padEnd(5, '0'); 195 | en[1] = en[1].padEnd(5, '0'); 196 | 197 | const e = e100km + en[0]; 198 | const n = n100km + en[1]; 199 | 200 | return new OsGridRef(e, n); 201 | } 202 | 203 | 204 | /** 205 | * Converts ‘this’ numeric grid reference to standard OS Grid Reference. 206 | * 207 | * @param {number} [digits=10] - Precision of returned grid reference (10 digits = metres); 208 | * digits=0 will return grid reference in numeric format. 209 | * @returns {string} This grid reference in standard format. 210 | * 211 | * @example 212 | * const gridref = new OsGridRef(651409, 313177).toString(8); // 'TG 5140 1317' 213 | * const gridref = new OsGridRef(651409, 313177).toString(0); // '651409,313177' 214 | */ 215 | toString(digits=10) { 216 | if (![ 0,2,4,6,8,10,12,14,16 ].includes(Number(digits))) throw new RangeError(`invalid precision ‘${digits}’`); // eslint-disable-line comma-spacing 217 | 218 | let { easting: e, northing: n } = this; 219 | 220 | // use digits = 0 to return numeric format (in metres) - note northing may be >= 1e7 221 | if (digits == 0) { 222 | const format = { useGrouping: false, minimumIntegerDigits: 6, maximumFractionDigits: 3 }; 223 | const ePad = e.toLocaleString('en', format); 224 | const nPad = n.toLocaleString('en', format); 225 | return `${ePad},${nPad}`; 226 | } 227 | 228 | // get the 100km-grid indices 229 | const e100km = Math.floor(e / 100000), n100km = Math.floor(n / 100000); 230 | 231 | // translate those into numeric equivalents of the grid letters 232 | let l1 = (19 - n100km) - (19 - n100km) % 5 + Math.floor((e100km + 10) / 5); 233 | let l2 = (19 - n100km) * 5 % 25 + e100km % 5; 234 | 235 | // compensate for skipped 'I' and build grid letter-pairs 236 | if (l1 > 7) l1++; 237 | if (l2 > 7) l2++; 238 | const letterPair = String.fromCharCode(l1 + 'A'.charCodeAt(0), l2 + 'A'.charCodeAt(0)); 239 | 240 | // strip 100km-grid indices from easting & northing, and reduce precision 241 | e = Math.floor((e % 100000) / Math.pow(10, 5 - digits / 2)); 242 | n = Math.floor((n % 100000) / Math.pow(10, 5 - digits / 2)); 243 | 244 | // pad eastings & northings with leading zeros 245 | e = e.toString().padStart(digits/2, '0'); 246 | n = n.toString().padStart(digits/2, '0'); 247 | 248 | return `${letterPair} ${e} ${n}`; 249 | } 250 | 251 | } 252 | 253 | 254 | /* LatLon_OsGridRef - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 255 | 256 | 257 | /** 258 | * Extends LatLon class with method to convert LatLon point to OS Grid Reference. 259 | * 260 | * @extends LatLonEllipsoidal 261 | */ 262 | class LatLon_OsGridRef extends LatLonEllipsoidal { 263 | 264 | /** 265 | * Converts latitude/longitude to Ordnance Survey grid reference easting/northing coordinate. 266 | * 267 | * @returns {OsGridRef} OS Grid Reference easting/northing. 268 | * 269 | * @example 270 | * const grid = new LatLon(52.65798, 1.71605).toOsGrid(); // TG 51409 13177 271 | * // for conversion of (historical) OSGB36 latitude/longitude point: 272 | * const grid = new LatLon(52.65798, 1.71605).toOsGrid(LatLon.datums.OSGB36); 273 | */ 274 | toOsGrid() { 275 | // if necessary convert to OSGB36 first 276 | const point = this.datum == LatLonEllipsoidal.datums.OSGB36 277 | ? this 278 | : this.convertDatum(LatLonEllipsoidal.datums.OSGB36); 279 | 280 | const φ = point.lat.toRadians(); 281 | const λ = point.lon.toRadians(); 282 | 283 | const { a, b } = nationalGrid.ellipsoid; // a = 6377563.396, b = 6356256.909 284 | const φ0 = nationalGrid.trueOrigin.lat.toRadians(); // latitude of true origin, 49°N 285 | const λ0 = nationalGrid.trueOrigin.lon.toRadians(); // longitude of true origin, 2°W 286 | const E0 = -nationalGrid.falseOrigin.easting; // easting of true origin, 400km 287 | const N0 = -nationalGrid.falseOrigin.northing; // northing of true origin, -100km 288 | const F0 = nationalGrid.scaleFactor; // 0.9996012717 289 | 290 | const e2 = 1 - (b*b)/(a*a); // eccentricity squared 291 | const n = (a-b)/(a+b), n2 = n*n, n3 = n*n*n; // n, n², n³ 292 | 293 | const cosφ = Math.cos(φ), sinφ = Math.sin(φ); 294 | const ν = a*F0/Math.sqrt(1-e2*sinφ*sinφ); // nu = transverse radius of curvature 295 | const ρ = a*F0*(1-e2)/Math.pow(1-e2*sinφ*sinφ, 1.5); // rho = meridional radius of curvature 296 | const η2 = ν/ρ-1; // eta = ? 297 | 298 | const Ma = (1 + n + (5/4)*n2 + (5/4)*n3) * (φ-φ0); 299 | const Mb = (3*n + 3*n2 + (21/8)*n3) * Math.sin(φ-φ0) * Math.cos(φ+φ0); 300 | const Mc = ((15/8)*n2 + (15/8)*n3) * Math.sin(2*(φ-φ0)) * Math.cos(2*(φ+φ0)); 301 | const Md = (35/24)*n3 * Math.sin(3*(φ-φ0)) * Math.cos(3*(φ+φ0)); 302 | const M = b * F0 * (Ma - Mb + Mc - Md); // meridional arc 303 | 304 | const cos3φ = cosφ*cosφ*cosφ; 305 | const cos5φ = cos3φ*cosφ*cosφ; 306 | const tan2φ = Math.tan(φ)*Math.tan(φ); 307 | const tan4φ = tan2φ*tan2φ; 308 | 309 | const I = M + N0; 310 | const II = (ν/2)*sinφ*cosφ; 311 | const III = (ν/24)*sinφ*cos3φ*(5-tan2φ+9*η2); 312 | const IIIA = (ν/720)*sinφ*cos5φ*(61-58*tan2φ+tan4φ); 313 | const IV = ν*cosφ; 314 | const V = (ν/6)*cos3φ*(ν/ρ-tan2φ); 315 | const VI = (ν/120) * cos5φ * (5 - 18*tan2φ + tan4φ + 14*η2 - 58*tan2φ*η2); 316 | 317 | const Δλ = λ-λ0; 318 | const Δλ2 = Δλ*Δλ, Δλ3 = Δλ2*Δλ, Δλ4 = Δλ3*Δλ, Δλ5 = Δλ4*Δλ, Δλ6 = Δλ5*Δλ; 319 | 320 | let N = I + II*Δλ2 + III*Δλ4 + IIIA*Δλ6; 321 | let E = E0 + IV*Δλ + V*Δλ3 + VI*Δλ5; 322 | 323 | N = Number(N.toFixed(3)); // round to mm precision 324 | E = Number(E.toFixed(3)); 325 | 326 | try { 327 | return new OsGridRef(E, N); // note: gets truncated to SW corner of 1m grid square 328 | } catch (e) { 329 | throw new Error(`${e.message} from (${point.lat.toFixed(6)},${point.lon.toFixed(6)}).toOsGrid()`); 330 | } 331 | } 332 | 333 | 334 | /** 335 | * Override LatLonEllipsoidal.convertDatum() with version which returns LatLon_OsGridRef. 336 | */ 337 | convertDatum(toDatum) { 338 | const osgbED = super.convertDatum(toDatum); // returns LatLonEllipsoidal_Datum 339 | const osgbOSGR = new LatLon_OsGridRef(osgbED.lat, osgbED.lon, osgbED.height, osgbED.datum); 340 | return osgbOSGR; 341 | } 342 | 343 | } 344 | 345 | 346 | /* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 347 | 348 | export { OsGridRef as default, LatLon_OsGridRef as LatLon, Dms }; 349 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "geodesy", 3 | "description": "Libraries of geodesy functions", 4 | "homepage": "http://www.movable-type.co.uk/scripts/geodesy-library.html", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/chrisveness/geodesy" 8 | }, 9 | "keywords": [ "geodesy", "latitude", "longitude", "distance", "bearing", "destination", "haversine", "vincenty", "rhumb", "ellipsoid", "datum", "reference-frame", "TRF", "vector", "n-vector", "wgs84", "utm", "mgrs" ], 10 | "author": "Chris Veness", 11 | "version": "2.4.0", 12 | "license": "MIT", 13 | "type": "module", 14 | "module": "./", 15 | "engines": { 16 | "node": ">=8.0.0" 17 | }, 18 | "files": [ "dms.js", "latlon-*.js", "mgrs.js", "osgridref.js", "utm.js", "vector3d.js" ], 19 | "bugs": "https://github.com/chrisveness/geodesy/issues", 20 | "scripts": { 21 | "test": "mocha --exit test/*.js", 22 | "lint": "eslint .", 23 | "cover": "c8 -r html npm test", 24 | "docs": "jsdoc *.js README.md -d ../../geodesy/docs" 25 | }, 26 | "devDependencies": { 27 | "c8": "^7.0.0", 28 | "chai": "^4.0.0", 29 | "coveralls": "^3.0.0", 30 | "eslint": "^8.0.0", 31 | "jsdoc": "^3.0.0", 32 | "mocha": "^9.0.0" 33 | }, 34 | "eslintConfig": { 35 | "env": { 36 | "browser": true, 37 | "mocha": true, 38 | "node": true 39 | }, 40 | "parserOptions": { 41 | "ecmaVersion": 2022, 42 | "sourceType": "module" 43 | }, 44 | "extends": "eslint:recommended", 45 | "globals": { 46 | "should": true 47 | }, 48 | "rules": { 49 | "array-bracket-spacing": [ "error", "always" ], 50 | "comma-dangle": [ "error", "always-multiline" ], 51 | "comma-spacing": [ "error" ], 52 | "curly": [ "error", "multi-line" ], 53 | "indent": [ "error", 4, { "SwitchCase": 1 } ], 54 | "key-spacing": [ "error", { "align": "value" } ], 55 | "keyword-spacing": [ "error" ], 56 | "no-case-declarations": "warn", 57 | "no-console": [ "warn", { "allow": [ "error", "info", "debug", "assert" ] } ], 58 | "no-irregular-whitespace": "warn", 59 | "no-redeclare": "warn", 60 | "no-shadow": "warn", 61 | "no-unused-vars": "warn", 62 | "no-var": "error", 63 | "object-curly-spacing": [ "error", "always" ], 64 | "prefer-const": "error", 65 | "quotes": [ "error", "single", "avoid-escape" ], 66 | "require-await": "error", 67 | "semi": [ "error", "always" ], 68 | "space-before-blocks": [ "error", "always" ], 69 | "space-in-parens": [ "error" ] 70 | } 71 | }, 72 | "jsdoc": { 73 | "plugins": [ "plugins/markdown" ], 74 | "markdown": { "idInHeadings": true } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /test/dms-tests.js: -------------------------------------------------------------------------------- 1 | /* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 2 | /* Geodesy Test Harness - dms (c) Chris Veness 2014-2021 */ 3 | /* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 4 | 5 | import Dms from '../dms.js'; 6 | 7 | if (typeof window == 'undefined') { // node 8 | const { default: chai } = await import('chai'); 9 | global.should = chai.should(); 10 | } 11 | 12 | 13 | describe('dms', function() { 14 | const test = it; // just an alias 15 | Dms.separator = ''; // tests are easier without any DMS separator 16 | 17 | describe('0°', function() { 18 | test('parse 0.0°', () => Dms.parse('0.0°').should.equal(0)); 19 | test('output 000.0000°', () => Dms.toDms(0).should.equal('000.0000°')); 20 | test('parse 0°', () => Dms.parse('0°').should.equal(0)); 21 | test('output 000°', () => Dms.toDms(0, 'd', 0).should.equal('000°')); 22 | test('parse 000 00 00 ', () => Dms.parse('000 00 00 ').should.equal(0)); 23 | test('parse 000°00′00″', () => Dms.parse('000°00′00″').should.equal(0)); 24 | test('output 000°00′00″', () => Dms.toDms(0, 'dms').should.equal('000°00′00″')); 25 | test('parse 000°00′00.0″', () => Dms.parse('000°00′00.0″').should.equal(0)); 26 | test('output 000°00′00.00″', () => Dms.toDms(0, 'dms', 2).should.equal('000°00′00.00″')); 27 | test('parse num 0', () => Dms.parse(0).should.equal(0)); 28 | test('output str 0', () => Dms.toDms('0', 'dms', 2).should.equal('000°00′00.00″')); 29 | }); 30 | 31 | describe('parse variations', function() { // including whitespace, different d/m/s symbols (ordinal, ascii/typo quotes) 32 | const variations = [ 33 | '45.76260', 34 | '45.76260 ', 35 | '45.76260°', 36 | '45°45.756′', 37 | '45° 45.756′', 38 | '45 45.756', 39 | '45°45′45.36″', 40 | '45º45\'45.36"', 41 | '45°45’45.36”', 42 | '45 45 45.36 ', 43 | '45° 45′ 45.36″', 44 | '45º 45\' 45.36"', 45 | '45° 45’ 45.36”', 46 | ]; 47 | for (const v in variations) test(`parse dms variations v = ‘${variations[v]}’`, () => Dms.parse(variations[v]).should.equal(45.76260)); 48 | for (const v in variations) test(`parse dms variations v = ‘-${variations[v]}’`, () => Dms.parse('-'+variations[v]).should.equal(-45.76260)); 49 | for (const v in variations) test(`parse dms variations v = ‘${variations[v]}N'`, () => Dms.parse(variations[v]+'N').should.equal(45.76260)); 50 | for (const v in variations) test(`parse dms variations v = ‘${variations[v]}S'`, () => Dms.parse(variations[v]+'S').should.equal(-45.76260)); 51 | for (const v in variations) test(`parse dms variations v = ‘${variations[v]}E'`, () => Dms.parse(variations[v]+'E').should.equal(45.76260)); 52 | for (const v in variations) test(`parse dms variations v = ‘${variations[v]}W'`, () => Dms.parse(variations[v]+'W').should.equal(-45.76260)); 53 | for (const v in variations) test(`parse dms variations v = ‘${variations[v]} N'`, () => Dms.parse(variations[v]+' N').should.equal(45.76260)); 54 | for (const v in variations) test(`parse dms variations v = ‘${variations[v]} S'`, () => Dms.parse(variations[v]+' S').should.equal(-45.76260)); 55 | for (const v in variations) test(`parse dms variations v = ‘${variations[v]} E'`, () => Dms.parse(variations[v]+' E').should.equal(45.76260)); 56 | for (const v in variations) test(`parse dms variations v = ‘${variations[v]} W'`, () => Dms.parse(variations[v]+' W').should.equal(-45.76260)); 57 | test('parse dms variations '+' ws before+after', () => Dms.parse(' 45°45′45.36″ ').should.equal(45.76260)); 58 | }); 59 | 60 | describe('parse out-of-range (should be normalised externally)', function() { 61 | test('parse 185', () => Dms.parse('185').should.be.equal(185)); 62 | test('parse 365', () => Dms.parse('365').should.be.equal(365)); 63 | test('parse -185', () => Dms.parse('-185').should.be.equal(-185)); 64 | test('parse -365', () => Dms.parse('-365').should.be.equal(-365)); 65 | }); 66 | 67 | describe('output variations', function() { 68 | test('output dms ', () => Dms.toDms(9.1525).should.equal('009.1525°')); 69 | test('output dms '+'d', () => Dms.toDms(9.1525, 'd').should.equal('009.1525°')); 70 | test('output dms '+'dm', () => Dms.toDms(9.1525, 'dm').should.equal('009°09.15′')); 71 | test('output dms '+'dms', () => Dms.toDms(9.1525, 'dms').should.equal('009°09′09″')); 72 | test('output dms '+'dm,6', () => Dms.toDms(9.1525, 'd', 6).should.equal('009.152500°')); 73 | test('output dms '+'dm,4', () => Dms.toDms(9.1525, 'dm', 4).should.equal('009°09.1500′')); 74 | test('output dms '+'dms,2', () => Dms.toDms(9.1525, 'dms', 2).should.equal('009°09′09.00″')); 75 | test('output dms '+'x', () => Dms.toDms(9.1525, 'x').should.equal('009.1525°')); 76 | test('output dms '+'x,6', () => Dms.toDms(9.1525, 'x', 6).should.equal('009.152500°')); // !! 77 | }); 78 | 79 | describe('compass points', function() { 80 | test('1 -> N ', () => Dms.compassPoint(1).should.equal('N')); 81 | test('0 -> N ', () => Dms.compassPoint(0).should.equal('N')); 82 | test('-1 -> N ', () => Dms.compassPoint(-1).should.equal('N')); 83 | test('359 -> N ', () => Dms.compassPoint(359).should.equal('N')); 84 | test('24 -> NNE ', () => Dms.compassPoint(24).should.equal('NNE')); 85 | test('24:1 -> N ', () => Dms.compassPoint(24, 1).should.equal('N')); 86 | test('24:2 -> NE ', () => Dms.compassPoint(24, 2).should.equal('NE')); 87 | test('24:3 -> NNE ', () => Dms.compassPoint(24, 3).should.equal('NNE')); 88 | test('226 -> SW ', () => Dms.compassPoint(226).should.equal('SW')); 89 | test('226:1 -> W ', () => Dms.compassPoint(226, 1).should.equal('W')); 90 | test('226:2 -> SW ', () => Dms.compassPoint(226, 2).should.equal('SW')); 91 | test('226:3 -> SW ', () => Dms.compassPoint(226, 3).should.equal('SW')); 92 | test('237 -> WSW ', () => Dms.compassPoint(237).should.equal('WSW')); 93 | test('237:1 -> W ', () => Dms.compassPoint(237, 1).should.equal('W')); 94 | test('237:2 -> SW ', () => Dms.compassPoint(237, 2).should.equal('SW')); 95 | test('237:3 -> WSW ', () => Dms.compassPoint(237, 3).should.equal('WSW')); 96 | test('bad precision', () => should.Throw(function() { Dms.compassPoint(0, 0); }, RangeError)); 97 | }); 98 | 99 | describe('misc', function() { 100 | test('toLat num', () => Dms.toLat(51.2, 'dms').should.equal('51°12′00″N')); 101 | test('toLat rnd-up', () => Dms.toLat(51.19999999999999, 'dm').should.equal('51°12.00′N')); 102 | test('toLat rnd-up', () => Dms.toLat(51.19999999999999, 'dms').should.equal('51°12′00″N')); 103 | test('toLat str', () => Dms.toLat('51.2', 'dms').should.equal('51°12′00″N')); 104 | test('toLat xxx', () => Dms.toLat('xxx', 'dms').should.equal('–')); 105 | test('toLon num', () => Dms.toLon(0.33, 'dms').should.equal('000°19′48″E')); 106 | test('toLon str', () => Dms.toLon('0.33', 'dms').should.equal('000°19′48″E')); 107 | test('toLon xxx', () => Dms.toLon('xxx', 'dms').should.equal('–')); 108 | test('toDMS rnd-up', () => Dms.toDms(51.99999999999999, 'd').should.equal('052.0000°')); 109 | test('toDMS rnd-up', () => Dms.toDms(51.99999999999999, 'dm').should.equal('052°00.00′')); 110 | test('toDMS rnd-up', () => Dms.toDms(51.99999999999999, 'dms').should.equal('052°00′00″')); 111 | test('toDMS rnd-up', () => Dms.toDms(51.19999999999999, 'd').should.equal('051.2000°')); 112 | test('toDMS rnd-up', () => Dms.toDms(51.19999999999999, 'dm').should.equal('051°12.00′')); 113 | test('toDMS rnd-up', () => Dms.toDms(51.19999999999999, 'dms').should.equal('051°12′00″')); 114 | test('toBrng num', () => Dms.toBrng(1).should.equal('001.0000°')); 115 | test('toBrng str', () => Dms.toBrng('1').should.equal('001.0000°')); 116 | test('toBrng xxx', () => Dms.toBrng('xxx').should.equal('–')); 117 | test('toLocale', () => Dms.toLocale('123,456.789').should.equal('123,456.789')); 118 | test('fromLocale', () => Dms.fromLocale('51°28′40.12″N').should.equal('51°28′40.12″N')); 119 | test('fromLocale', () => Dms.fromLocale('51°28′40.12″N, 000°00′05.31″W').should.equal('51°28′40.12″N, 000°00′05.31″W')); 120 | }); 121 | 122 | describe('parse failures', function() { 123 | test('parse 0 0 0 0', () => Dms.parse('0 0 0 0').should.be.NaN); 124 | test('parse str', () => Dms.parse('xxx').should.be.NaN); 125 | test('parse ""', () => Dms.parse('').should.be.NaN); 126 | test('parse null', () => Dms.parse(null).should.be.NaN); 127 | test('parse obj', () => Dms.parse({ a: 1 }).should.be.NaN); 128 | test('parse true', () => Dms.parse(true).should.be.NaN); 129 | test('parse false', () => Dms.parse(false).should.be.NaN); 130 | }); 131 | 132 | describe('convert failures', function() { 133 | test('output 0 0 0 0', () => should.equal(Dms.toDms('0 0 0 0'), null)); 134 | test('output xxx', () => should.equal(Dms.toDms('xxx', 'dms', 2), null)); 135 | test('output xxx', () => should.equal(Dms.toDms('xxx'), null)); 136 | test('output ""', () => should.equal(Dms.toDms(''), null)); 137 | test('output " "', () => should.equal(Dms.toDms(' '), null)); 138 | test('output null', () => should.equal(Dms.toDms(null), null)); 139 | test('output obj', () => should.equal(Dms.toDms({ a: 1 }), null)); 140 | test('output true', () => should.equal(Dms.toDms(true), null)); 141 | test('output false', () => should.equal(Dms.toDms(false), null)); 142 | test('output ∞', () => should.equal(Dms.toDms(1/0), null)); 143 | }); 144 | 145 | describe('wrap360', function() { 146 | /* eslint-disable space-in-parens */ 147 | test('-450°', () => Dms.wrap360(-450).should.equal(270)); 148 | test('-405°', () => Dms.wrap360(-405).should.equal(315)); 149 | test('-360°', () => Dms.wrap360(-360).should.equal( 0)); 150 | test('-315°', () => Dms.wrap360(-315).should.equal( 45)); 151 | test('-270°', () => Dms.wrap360(-270).should.equal( 90)); 152 | test('-225°', () => Dms.wrap360(-225).should.equal(135)); 153 | test('-180°', () => Dms.wrap360(-180).should.equal(180)); 154 | test('-135°', () => Dms.wrap360(-135).should.equal(225)); 155 | test(' -90°', () => Dms.wrap360( -90).should.equal(270)); 156 | test(' -45°', () => Dms.wrap360( -45).should.equal(315)); 157 | test(' 0°', () => Dms.wrap360( 0).should.equal( 0)); 158 | test(' 45°', () => Dms.wrap360( 45).should.equal( 45)); 159 | test(' 90°', () => Dms.wrap360( 90).should.equal( 90)); 160 | test(' 135°', () => Dms.wrap360( 135).should.equal(135)); 161 | test(' 180°', () => Dms.wrap360( 180).should.equal(180)); 162 | test(' 225°', () => Dms.wrap360( 225).should.equal(225)); 163 | test(' 270°', () => Dms.wrap360( 270).should.equal(270)); 164 | test(' 315°', () => Dms.wrap360( 315).should.equal(315)); 165 | test(' 360°', () => Dms.wrap360( 360).should.equal( 0)); 166 | test(' 405°', () => Dms.wrap360( 405).should.equal( 45)); 167 | test(' 450°', () => Dms.wrap360( 450).should.equal( 90)); 168 | }); 169 | 170 | describe('wrap180', function() { 171 | test('-450°', () => Dms.wrap180(-450).should.equal( -90)); 172 | test('-405°', () => Dms.wrap180(-405).should.equal( -45)); 173 | test('-360°', () => Dms.wrap180(-360).should.equal( 0)); 174 | test('-315°', () => Dms.wrap180(-315).should.equal( 45)); 175 | test('-270°', () => Dms.wrap180(-270).should.equal( 90)); 176 | test('-225°', () => Dms.wrap180(-225).should.equal( 135)); 177 | test('-180°', () => Dms.wrap180(-180).should.equal(-180)); 178 | test('-135°', () => Dms.wrap180(-135).should.equal(-135)); 179 | test(' -90°', () => Dms.wrap180( -90).should.equal( -90)); 180 | test(' -45°', () => Dms.wrap180( -45).should.equal( -45)); 181 | test(' 0°', () => Dms.wrap180( 0).should.equal( 0)); 182 | test(' 45°', () => Dms.wrap180( 45).should.equal( 45)); 183 | test(' 90°', () => Dms.wrap180( 90).should.equal( 90)); 184 | test(' 135°', () => Dms.wrap180( 135).should.equal( 135)); 185 | test(' 180°', () => Dms.wrap180( 180).should.equal( 180)); 186 | test(' 225°', () => Dms.wrap180( 225).should.equal(-135)); 187 | test(' 270°', () => Dms.wrap180( 270).should.equal( -90)); 188 | test(' 315°', () => Dms.wrap180( 315).should.equal( -45)); 189 | test(' 360°', () => Dms.wrap180( 360).should.equal( 0)); 190 | test(' 405°', () => Dms.wrap180( 405).should.equal( 45)); 191 | test(' 450°', () => Dms.wrap180( 450).should.equal( 90)); 192 | }); 193 | 194 | describe('wrap90', function() { 195 | test('-450°', () => Dms.wrap90(-450).should.equal( -90)); 196 | test('-405°', () => Dms.wrap90(-405).should.equal( -45)); 197 | test('-360°', () => Dms.wrap90(-360).should.equal( 0)); 198 | test('-315°', () => Dms.wrap90(-315).should.equal( 45)); 199 | test('-270°', () => Dms.wrap90(-270).should.equal( 90)); 200 | test('-225°', () => Dms.wrap90(-225).should.equal( 45)); 201 | test('-180°', () => Dms.wrap90(-180).should.equal( 0)); 202 | test('-135°', () => Dms.wrap90(-135).should.equal( -45)); 203 | test(' -90°', () => Dms.wrap90( -90).should.equal( -90)); 204 | test(' -45°', () => Dms.wrap90( -45).should.equal( -45)); 205 | test(' 0°', () => Dms.wrap90( 0).should.equal( 0)); 206 | test(' 45°', () => Dms.wrap90( 45).should.equal( 45)); 207 | test(' 90°', () => Dms.wrap90( 90).should.equal( 90)); 208 | test(' 135°', () => Dms.wrap90( 135).should.equal( 45)); 209 | test(' 180°', () => Dms.wrap90( 180).should.equal( 0)); 210 | test(' 225°', () => Dms.wrap90( 225).should.equal( -45)); 211 | test(' 270°', () => Dms.wrap90( 270).should.equal( -90)); 212 | test(' 315°', () => Dms.wrap90( 315).should.equal( -45)); 213 | test(' 360°', () => Dms.wrap90( 360).should.equal( 0)); 214 | test(' 405°', () => Dms.wrap90( 405).should.equal( 45)); 215 | test(' 450°', () => Dms.wrap90( 450).should.equal( 90)); 216 | }); 217 | 218 | }); 219 | -------------------------------------------------------------------------------- /test/geodesy-test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Geodesy Library Tests 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 30 | 31 | 32 | 33 |

Geodesy Library Tests

34 | 43 |
44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /test/latlon-ellipsoidal-datum-tests.js: -------------------------------------------------------------------------------- 1 | /* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 2 | /* Geodesy Test Harness - ellipsoidal datums (c) Chris Veness 2014-2021 */ 3 | /* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 4 | 5 | import LatLon, { Cartesian, Dms } from '../latlon-ellipsoidal-datum.js'; 6 | 7 | if (typeof window == 'undefined') { // node 8 | const { default: chai } = await import('chai'); 9 | global.should = chai.should(); 10 | } 11 | 12 | 13 | describe('latlon-ellipsoidal-datum', function() { 14 | const test = it; // just an alias 15 | Dms.separator = ''; // tests are easier without any DMS separator 16 | 17 | describe('@examples', function() { 18 | test('constructor', () => new LatLon(53.3444, -6.2577, 17, LatLon.datums.Irl1975).toString().should.equal('53.3444°N, 006.2577°W')); 19 | test('ellipsoids', () => Object.keys(LatLon.ellipsoids).should.include('Airy1830')); 20 | test('datums', () => Object.keys(LatLon.datums).should.include('OSGB36')); 21 | test('parse', () => LatLon.parse('51.47736, 0.0000', 0, LatLon.datums.OSGB36).toString().should.equal('51.4774°N, 000.0000°E')); 22 | test('convertDatum', () => new LatLon(51.47788, -0.00147).convertDatum(LatLon.datums.OSGB36).toString().should.equal('51.4774°N, 000.0001°E')); 23 | test('Cartesian.toLatLon', () => new Cartesian(4027893.924, 307041.993, 4919474.294).toLatLon().convertDatum(LatLon.datums.OSGB36).toString().should.equal('50.7971°N, 004.3612°E')); 24 | }); 25 | 26 | describe('valid datum', function() { 27 | test('constructor', () => should.Throw(function() { new LatLon(0, 0, 0, null); }, TypeError, 'unrecognised datum ‘null’')); 28 | test('parse', () => should.Throw(function() { LatLon.parse('0, 0', 0, null); }, TypeError, 'unrecognised datum ‘null’')); 29 | }); 30 | 31 | describe('getter', function() { 32 | test('ellipsoid getter', () => LatLon.ellipsoids.should.have.property('WGS84')); 33 | }); 34 | 35 | describe('convert datum (Greenwich)', function() { 36 | const greenwichWGS84 = new LatLon(51.47788, -0.00147); // default WGS84 37 | const greenwichOSGB36 = greenwichWGS84.convertDatum(LatLon.datums.OSGB36); 38 | // greenwichOSGB36.height = 0; 39 | test('convert WGS84 -> OSGB36', () => greenwichOSGB36.toString('d', 6).should.equal('51.477364°N, 000.000150°E')); // TODO: huh? should be 0°E? out by c. 10 metres / 0.5″! am I missing something? 40 | test('convert round-trip', () => greenwichOSGB36.convertDatum(LatLon.datums.WGS84).toString('d', 5).should.equal('51.47788°N, 000.00147°W')); 41 | test('convert fails', () => should.Throw(function() { new LatLon(51, 0).convertDatum(null); }, TypeError, 'unrecognised datum ‘null’')); 42 | }); 43 | 44 | describe('convert datum (Petroleum Operations Notices)', function() { 45 | // https://www.gov.uk/guidance/oil-and-gas-petroleum-operations-notices#test-point-using-osgb-petroleum-transformation-parameters 46 | test('convert WGS84 -> OSGB36', () => new LatLon(53, 1, 50).convertDatum(LatLon.datums.OSGB36).toString('dms', 3, 2).should.equal('52°59′58.719″N, 001°00′06.490″E +3.99m')); 47 | // https://www.gov.uk/guidance/oil-and-gas-petroleum-operations-notices#test-point-using-common-offshore-transformation-parameters 48 | test('convert WGS84 -> ED50', () => new LatLon(53, 1, 50).convertDatum(LatLon.datums.ED50).toString('dms', 3, 2).should.equal('53°00′02.887″N, 001°00′05.101″E +2.72m')); 49 | test('convert round-trip', () => new LatLon(53, 1, 50).convertDatum(LatLon.datums.OSGB36).convertDatum(LatLon.datums.ED50).convertDatum(LatLon.datums.WGS84).toString('d', 4, 1).should.equal('53.0000°N, 001.0000°E +50.0m')); 50 | }); 51 | 52 | describe('equals', function() { 53 | const p1 = new LatLon(51.47788, -0.00147, 1, LatLon.datums.WGS84); 54 | const p2 = new LatLon(51.47788, -0.00147, 1, LatLon.datums.WGS84); 55 | test('JS equals', () => (p1 == p2).should.equal(false)); 56 | test('LL equals', () => p1.equals(p2).should.equal(true)); 57 | test('LL neq (lat)', () => p1.equals(new LatLon(0, -0.00147, 1, LatLon.datums.WGS84)).should.equal(false)); 58 | test('LL neq (lon)', () => p1.equals(new LatLon(51.47788, 0, 1, LatLon.datums.WGS84)).should.equal(false)); 59 | test('LL neq (height)', () => p1.equals(new LatLon(51.47788, -0.00147, 99, LatLon.datums.WGS84)).should.equal(false)); 60 | test('LL neq (datum)', () => p1.equals(new LatLon(51.47788, -0.00147, 1, LatLon.datums.Irl1975)).should.be.false); 61 | test('equals (fail)', () => should.Throw(function() { p1.equals(null); }, TypeError, 'invalid point ‘null’')); 62 | }); 63 | 64 | describe('cartesian', function() { 65 | const p = LatLon.parse('45N, 45E'); 66 | test('toCartesian', () => p.toCartesian().toString().should.equal('[3194419,3194419,4487348]')); 67 | const c = new Cartesian(3194419, 3194419, 4487348); 68 | test('toLatLon', () => c.toLatLon().toString().should.equal('45.0000°N, 045.0000°E')); 69 | test('toLatLon fail', () => should.Throw(function() { c.toLatLon('xx'); }, TypeError, 'unrecognised datum ‘xx’')); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /test/latlon-ellipsoidal-referenceframe-tests.js: -------------------------------------------------------------------------------- 1 | /* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 2 | /* Geodesy Test Harness - ellipsoidal reference frames (c) Chris Veness 2014-2021 */ 3 | /* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 4 | 5 | import LatLon, { Cartesian, Dms } from '../latlon-ellipsoidal-referenceframe.js'; 6 | 7 | if (typeof window == 'undefined') { // node 8 | const { default: chai } = await import('chai'); 9 | global.should = chai.should(); 10 | } 11 | 12 | 13 | describe('latlon-ellipsoidal-referenceframe', function() { 14 | const test = it; // just an alias 15 | Dms.separator = ''; // tests are easier without any DMS separator 16 | 17 | describe('constructor', function() { 18 | test('TRF', () => new LatLon(0, 0, 0, LatLon.referenceFrames.ITRF2014, 2000.0).toString().should.equal('00.0000°N, 000.0000°E')); 19 | test('bad TRF fail', () => should.Throw(function() { new LatLon(0, 0, 0, null); }, TypeError, 'unrecognised reference frame')); 20 | test('bad epoch fail', () => should.Throw(function() { new LatLon(0, 0, 0, LatLon.referenceFrames.ITRF2014, 'xxx'); }, TypeError, 'invalid epoch ’xxx’')); 21 | }); 22 | 23 | describe('@examples', function() { 24 | test('constructor', () => new LatLon(51.47788, -0.00147, 0, LatLon.referenceFrames.ITRF2000).toString().should.equal('51.4779°N, 000.0015°W')); 25 | test('parse p1', () => LatLon.parse(51.47788, -0.00147, 17, LatLon.referenceFrames.ETRF2000).toString().should.equal('51.4779°N, 000.0015°W')); 26 | test('parse p2', () => LatLon.parse('51.47788, -0.00147', 17, LatLon.referenceFrames.ETRF2000).toString().should.equal('51.4779°N, 000.0015°W')); 27 | test('parse p3', () => LatLon.parse({ lat: 52.205, lon: 0.119 }, 17, LatLon.referenceFrames.ETRF2000).toString().should.equal('52.2050°N, 000.1190°E')); 28 | const pItrf = new LatLon(51.47788000, -0.00147000, 0, LatLon.referenceFrames.ITRF2000); 29 | test('convertReferenceFrame', () => pItrf.convertReferenceFrame(LatLon.referenceFrames.ETRF2000).toString('d', 8).should.equal('51.47787826°N, 000.00147125°W')); 30 | test('toString 1', () => new LatLon(51.47788, -0.00147, 0, LatLon.referenceFrames.ITRF2014).toString().should.equal('51.4779°N, 000.0015°W')); 31 | test('toString 2', () => new LatLon(51.47788, -0.00147, 0, LatLon.referenceFrames.ITRF2014).toString('dms').should.equal('51°28′40″N, 000°00′05″W')); 32 | test('toString 3', () => new LatLon(51.47788, -0.00147, 42, LatLon.referenceFrames.ITRF2014).toString('dms', 0, 0).should.equal('51°28′40″N, 000°00′05″W +42m')); 33 | test('Cartesian.toLatLon', () => new Cartesian(4027893.924, 307041.993, 4919474.294, LatLon.referenceFrames.ITRF2000).toLatLon().toString().should.equal('50.7978°N, 004.3592°E')); 34 | test('Cartesian.convertReferenceFrame', () => new Cartesian(3980574.247, -102.127, 4966830.065, LatLon.referenceFrames.ITRF2000).convertReferenceFrame(LatLon.referenceFrames.ETRF2000).toString(3).should.equal('[3980574.395,-102.214,4966829.941](ETRF2000@1997.0)')); 35 | }); 36 | 37 | describe('parse', function() { 38 | test('parse lat+lon', () => LatLon.parse(51.47788, -0.00147, 17, LatLon.referenceFrames.ITRF2000).toString('d', 4, null, true).should.equal('51.4779°N, 000.0015°W (ITRF2000)')); 39 | test('parse lat+lon+epoch', () => LatLon.parse(51.47788, -0.00147, 17, LatLon.referenceFrames.ITRF2000, 2012.0).toString('d', 4, null, true).should.equal('51.4779°N, 000.0015°W (ITRF2000@2012.0)')); 40 | test('parse latlon', () => LatLon.parse('51.47788, -0.00147', 17, LatLon.referenceFrames.ITRF2000).toString('d', 4, null, true).should.equal('51.4779°N, 000.0015°W (ITRF2000)')); 41 | test('parse latlon+epoch', () => LatLon.parse('51.47788, -0.00147', 17, LatLon.referenceFrames.ITRF2000, 2012.0).toString('d', 4, null, true).should.equal('51.4779°N, 000.0015°W (ITRF2000@2012.0)')); 42 | }); 43 | 44 | describe('getters/setters', function() { 45 | test('referenceFrame', () => new LatLon(0, 0).referenceFrame.name.should.equal('ITRF2014')); 46 | test('epoch', () => new LatLon(0, 0).epoch.should.equal(2010.0)); 47 | test('ellipsoids', () => Object.keys(LatLon.ellipsoids).join().should.equal('WGS84,GRS80')); 48 | test('referenceFrames', () => Object.keys(LatLon.referenceFrames).should.include('ITRF2014')); 49 | test('transformParameters', () => Object.keys(LatLon.transformParameters).should.include('ITRF2014→ITRF2008')); 50 | }); 51 | 52 | describe('parse fail', function() { 53 | test('empty', () => should.Throw(function() { LatLon.parse(); }, TypeError, 'invalid (empty) point')); 54 | test('l,l bad TRF', () => should.Throw(function() { LatLon.parse(0, 0, 0, 0); }, TypeError, 'unrecognised reference frame')); 55 | test('l,l bad TRF', () => should.Throw(function() { LatLon.parse(0, 0, 0, null); }, TypeError, 'unrecognised reference frame')); 56 | test('l/l bad TRF', () => should.Throw(function() { LatLon.parse('0, 0', 0, 0); }, TypeError, 'unrecognised reference frame')); 57 | test('l/l bad TRF', () => should.Throw(function() { LatLon.parse('0, 0', 0, null); }, TypeError, 'unrecognised reference frame')); 58 | }); 59 | 60 | describe('convertReferenceFrame fail', function() { 61 | test('no TRF', () => should.Throw(function() { new LatLon(0, 0).convertReferenceFrame('ITRF2014'); }, TypeError, 'unrecognised reference frame')); 62 | test('no TRF', () => should.Throw(function() { new Cartesian(1, 2, 3).convertReferenceFrame('ITRF2014'); }, TypeError, 'unrecognised reference frame')); 63 | }); 64 | 65 | describe('Cartesian constructor fail', function() { 66 | test('empty', () => should.Throw(function() { new Cartesian(4027893.924, 307041.993, 4919474.294, 'ITRF2000'); }, Error, 'unrecognised reference frame')); 67 | test('empty', () => should.Throw(function() { new Cartesian(4027893.924, 307041.993, 4919474.294, LatLon.referenceFrames.ITRF2000, 'last year'); }, Error, 'invalid epoch ’last year’')); 68 | }); 69 | 70 | describe('Cartesian setter fail', function() { 71 | test('bad TRF', () => should.Throw(function() { new Cartesian(1, 2, 3).referenceFrame = 'ITRF2014'; }, TypeError, 'unrecognised reference frame')); 72 | test('bad epoch', () => should.Throw(function() { new Cartesian(1, 2, 3).epoch = 'last year'; }, TypeError, 'invalid epoch ’last year’')); 73 | }); 74 | 75 | describe('Cartesian.toLatLon fail', function() { 76 | test('empty', () => should.Throw(function() { new Cartesian(4027893.924, 307041.993, 4919474.294).toLatLon(null); }, Error, 'cartesian reference frame not defined')); 77 | }); 78 | 79 | describe('convertReferenceFrame', function() { 80 | test('geod no-op', () => new LatLon(0, 0, 0, LatLon.referenceFrames.ITRF2000).convertReferenceFrame(LatLon.referenceFrames.ITRF2000).toString().should.equal('00.0000°N, 000.0000°E')); 81 | test('cart no-op', () => new Cartesian(1, 2, 3, LatLon.referenceFrames.ITRF2000).convertReferenceFrame(LatLon.referenceFrames.ITRF2000).toString().should.equal('[1,2,3](ITRF2000)')); 82 | test('chained conversion round-trip', () => { 83 | const nad83 = new LatLon(0, 0, 0, LatLon.referenceFrames.NAD83); 84 | const itrf2014 = nad83.convertReferenceFrame(LatLon.referenceFrames.ITRF2014); // goes via ITRF2000 85 | itrf2014.convertReferenceFrame(LatLon.referenceFrames.NAD83).toString('d', 8).should.equal('00.00000000°N, 000.00000000°W'); 86 | }); 87 | }); 88 | 89 | describe('Dawson & Woods 2010', function() { // ITRF to GDA94 coordinate transformations, John Dawson and Alex Woods, Journal of Applied Geodesy 4 (2010) 90 | const itrf2005 = LatLon.parse('23°40′12.41482″S, 133°53′7.86712″E', 603.2562, LatLon.referenceFrames.ITRF2005, 2010.4559); 91 | const gda94 = itrf2005.convertReferenceFrame(LatLon.referenceFrames.GDA94, 2010.4559); 92 | test('Appendix A cartesian', () => gda94.toCartesian().toString(4).should.equal('[-4052051.7614,4212836.1945,-2545106.0146](GDA94@2010.4559)')); 93 | test('Appendix A geodetic', () => gda94.toString('dms', 5, 4, true).should.equal('23°40′12.44582″S, 133°53′07.84795″E +603.3361m (GDA94@2010.4559)')); 94 | // note variations in final decimal for gda94ˣ, gda94ᶻ, gda94ᵠ – difference in rounding and/or Cartesian.toLatLon()? 95 | const itrf2005ʹ = gda94.convertReferenceFrame(LatLon.referenceFrames.ITRF2005, 2010.4559); 96 | test('Appendix A roundtrip', () => itrf2005ʹ.toString('dms', 5, 4, true).should.equal('23°40′12.41482″S, 133°53′07.86712″E +603.2562m (ITRF2005@2010.4559)')); 97 | }); 98 | 99 | describe('Proj4 Onsala observatory', function() { // https://github.com/OSGeo/proj.4/blob/2aaf53/test/gie/more_builtins.gie#L357 100 | const cITRF2000 = new Cartesian(3370658.37800, 711877.31400, 5349787.08600, LatLon.referenceFrames.ITRF2000, 2017.0); 101 | test('from GNSStrans', () => cITRF2000.convertReferenceFrame(LatLon.referenceFrames.ITRF93, 2017.0).toString(5).should.equal('[3370658.18892,711877.42369,5349787.12430](ITRF93@2017.0)')); 102 | // accurate to within 0.02mm 103 | }); 104 | 105 | describe('NGS Data Sheet Meades Ranch', function() { // https://www.ngs.noaa.gov/cgi-bin/ds_mark.prl?PidBox=kg0640 106 | const nad83_2011 = LatLon.parse('39 13 26.71220(N), 098 32 31.74540(W)', 573.961, LatLon.referenceFrames.NAD83, 2010.0); 107 | test('cartesian', () => nad83_2011.toCartesian().toString(3).should.equal('[-734972.563,4893188.492,4011982.811](NAD83@2010.0)')); 108 | }); 109 | 110 | describe('EUREF Permanent Network', function() { // epncb.oma.be/_productsservices/coord_trans (tutorial) 111 | test('Ex1: ITRF2005(2007.0)->ITRF91(2007.0)', function() { 112 | const orbITRF2005 = new Cartesian(4027894.006, 307045.600, 4919474.910, LatLon.referenceFrames.ITRF2005, 2007.0); 113 | const orbITRF91 = orbITRF2005.convertReferenceFrame(LatLon.referenceFrames.ITRF91); 114 | orbITRF91.toString(4).should.equal('[4027894.0444,307045.6209,4919474.8613](ITRF91@2007.0)'); 115 | }); 116 | test('Ex2: ITRF2005(2007.0)->ITRF91(1999.0)', function() { 117 | const orbITRF2005 = new Cartesian(4027894.006, 307045.600, 4919474.910, LatLon.referenceFrames.ITRF2005, 2007.0); 118 | const orbITRF91 = orbITRF2005.convertReferenceFrame(LatLon.referenceFrames.ITRF91); 119 | orbITRF91.toString(4).should.equal('[4027894.0444,307045.6209,4919474.8613](ITRF91@2007.0)'); 120 | }); 121 | test('Ex4: ITRF2000(2012.0)->ETRF2000(2012.0)', function() { 122 | const orbITRF2000 = new Cartesian(4027894.006, 307045.600, 4919474.910, LatLon.referenceFrames.ITRF2000, 2012.0); 123 | const orbETRF2000 = orbITRF2000.convertReferenceFrame(LatLon.referenceFrames.ETRF2000); 124 | orbETRF2000.toString(4).should.equal('[4027894.3559,307045.2508,4919474.6447](ETRF2000@2012.0)'); 125 | }); 126 | test('Ex5: ITRF2014(2012.0)->ETRF2000(2012.0)', function() { 127 | const orbITRF2014 = new Cartesian(4027894.006, 307045.600, 4919474.910, LatLon.referenceFrames.ITRF2014, 2012.0); 128 | const orbETRF2000 = orbITRF2014.convertReferenceFrame(LatLon.referenceFrames.ETRF2000); 129 | orbETRF2000.toString(4).should.equal('[4027894.3662,307045.2530,4919474.6263](ETRF2000@2012.0)'); 130 | }); 131 | }); 132 | 133 | }); 134 | -------------------------------------------------------------------------------- /test/latlon-ellipsoidal-tests.js: -------------------------------------------------------------------------------- 1 | /* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 2 | /* Geodesy Test Harness - latlon-ellipsoidal (c) Chris Veness 2014-2024 */ 3 | /* */ 4 | /* Usage: */ 5 | /* $ mocha test/latlon-ellipsoidal-tests.js */ 6 | /* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 7 | 8 | import LatLon, { Cartesian, Dms } from '../latlon-ellipsoidal.js'; 9 | 10 | if (typeof window == 'undefined') { // node 11 | const { default: chai } = await import('chai'); 12 | global.should = chai.should(); 13 | } 14 | 15 | 16 | describe('latlon-ellipsoidal', function() { 17 | const test = it; // just an alias 18 | Dms.separator = ''; // tests are easier without any DMS separator 19 | 20 | describe('constructor', function() { 21 | test('@example', () => new LatLon(51.47788, -0.00147, 17).toString('d', 4, 2).should.equal('51.4779°N, 000.0015°W +17.00m')); 22 | test('non-numeric lat fail', () => should.Throw(function() { new LatLon('x', 0, 0); }, TypeError, 'invalid lat ‘x’')); 23 | test('non-numeric lon fail', () => should.Throw(function() { new LatLon(0, 'x', 0); }, TypeError, 'invalid lon ‘x’')); 24 | test('non-numeric height fail', () => should.Throw(function() { new LatLon(0, 0, 'x'); }, TypeError, 'invalid height ‘x’')); 25 | }); 26 | 27 | describe('parse', function() { 28 | test('@example p1', () => LatLon.parse(51.47788, -0.00147).toString().should.equal('51.4779°N, 000.0015°W')); 29 | test('@example p2', () => LatLon.parse('51°28′40″N, 000°00′05″W', 17).toString().should.equal('51.4778°N, 000.0014°W')); 30 | test('@example p3', () => LatLon.parse({ lat: 52.205, lon: 0.119 }, 17).toString().should.equal('52.2050°N, 000.1190°E')); 31 | test('@example p4', () => LatLon.parse({ lat: 52.205, lon: 0.119, height: 17 }).toString().should.equal('52.2050°N, 000.1190°E')); 32 | test('numeric lat, long', () => LatLon.parse(51.47788, -0.00147).toString().should.equal('51.4779°N, 000.0015°W')); 33 | test('numeric lat, long, h', () => LatLon.parse(51.47788, -0.00147, 99).toString('d', 4, 0).should.equal('51.4779°N, 000.0015°W +99m')); 34 | test('string lat, long d', () => LatLon.parse('51.47788', '-0.00147').toString().should.equal('51.4779°N, 000.0015°W')); 35 | test('string lat, long d, h', () => LatLon.parse('51.47788', '-0.00147', '99').toString('d', 4, 0).should.equal('51.4779°N, 000.0015°W +99m')); 36 | test('string lat, long dm', () => LatLon.parse('51°28.67′N, 000°00.09′E').toString('dm').should.equal('51°28.67′N, 000°00.09′E')); 37 | test('string lat, long dm, h', () => LatLon.parse('51°28.67′N, 000°00.09′E', '99').toString('dm', 2, 0).should.equal('51°28.67′N, 000°00.09′E +99m')); 38 | test('string lat, long dms', () => LatLon.parse('51°28′40″N, 000°00′05″E').toString('dms').should.equal('51°28′40″N, 000°00′05″E')); 39 | test('string lat, long dms, h', () => LatLon.parse('51°28′40″N, 000°00′05″E', '99').toString('dms', 0, 0).should.equal('51°28′40″N, 000°00′05″E +99m')); 40 | test('comma-separated', () => LatLon.parse('51.47788, -0.00147').toString().should.equal('51.4779°N, 000.0015°W')); 41 | test('comma-separated, h', () => LatLon.parse('51.47788, -0.00147', 99).toString('d', 4, 0).should.equal('51.4779°N, 000.0015°W +99m')); 42 | test('comma-separated, h-str', () => LatLon.parse('51.47788, -0.00147', '99').toString('d', 4, 0).should.equal('51.4779°N, 000.0015°W +99m')); 43 | test('{ lat, lon }', () => LatLon.parse({ lat: 51.47788, lon: -0.00147 }).toString().should.equal('51.4779°N, 000.0015°W')); 44 | test('{ lat, lon }, h', () => LatLon.parse({ lat: 51.47788, lon: -0.00147 }, 99).toString('d', 4, 0).should.equal('51.4779°N, 000.0015°W +99m')); 45 | test('{ "lat", "lon" }', () => LatLon.parse({ lat: '51.47788', lon: '-0.00147' }).toString().should.equal('51.4779°N, 000.0015°W')); 46 | test('{ "lat", "lon" }, h', () => LatLon.parse({ lat: '51.47788', lon: '-0.00147' }, 99).toString('d', 4, 0).should.equal('51.4779°N, 000.0015°W +99m')); 47 | test('{ lat, lng }', () => LatLon.parse({ lat: 51.47788, lng: -0.00147 }).toString().should.equal('51.4779°N, 000.0015°W')); 48 | test('{ lat, lng }, h', () => LatLon.parse({ lat: 51.47788, lng: -0.00147 }, 99).toString('d', 4, 0).should.equal('51.4779°N, 000.0015°W +99m')); 49 | test('{ lat, lon, h }', () => LatLon.parse({ lat: 51.47788, lon: -0.00147, height: 99 }).toString('d', 4, 0).should.equal('51.4779°N, 000.0015°W +99m')); 50 | test('{ lat’de, long’de }', () => LatLon.parse({ latitude: 51.47788, longitude: -0.00147 }).toString().should.equal('51.4779°N, 000.0015°W')); 51 | test('{ lat’de, long’de }, h', () => LatLon.parse({ latitude: 51.47788, longitude: -0.00147 }, 99).toString('d', 4, 0).should.equal('51.4779°N, 000.0015°W +99m')); 52 | test('GeoJSON', () => LatLon.parse({ type: 'Point', coordinates: [ -0.00147, 51.47788 ] }).toString().should.equal('51.4779°N, 000.0015°W')); 53 | test('GeoJSON w/h', () => LatLon.parse({ type: 'Point', coordinates: [ -0.00147, 51.47788, 99 ] }).toString().should.equal('51.4779°N, 000.0015°W')); 54 | test('GeoJSON, h', () => LatLon.parse({ type: 'Point', coordinates: [ -0.00147, 51.47788 ] }, 99).toString().should.equal('51.4779°N, 000.0015°W')); 55 | }); 56 | 57 | describe('parse fail', function() { 58 | test('empty', () => should.Throw(function() { LatLon.parse(); }, TypeError, 'invalid (empty) point')); 59 | test('single arg num', () => should.Throw(function() { LatLon.parse(1); }, TypeError, 'invalid point ‘1’')); 60 | test('single arg str', () => should.Throw(function() { LatLon.parse('London'); }, TypeError, 'invalid point ‘London’')); 61 | test('single arg str + h', () => should.Throw(function() { LatLon.parse('London', 99); }, TypeError, 'invalid point ‘London,99’')); 62 | test('invalid comma arg', () => should.Throw(function() { LatLon.parse('London,UK'); }, TypeError, 'invalid point ‘London,UK’')); 63 | test('invalid comma arg + h', () => should.Throw(function() { LatLon.parse('London,UK', 99); }, TypeError, 'invalid point ‘London,UK’')); 64 | test('empty object', () => should.Throw(function() { LatLon.parse({}); }, TypeError, 'invalid point ‘{}’')); 65 | test('invalid object 1', () => should.Throw(function() { LatLon.parse({ y: 51.47788, x: -0.00147 }); }, TypeError, 'invalid point ‘{"y":51.47788,"x":-0.00147}’')); 66 | test('invalid object 2', () => should.Throw(function() { LatLon.parse({ lat: 'y', lon: 'x' }); }, TypeError, 'invalid point ‘{"lat":"y","lon":"x"}’')); 67 | test('invalid lat,lon', () => should.Throw(function() { LatLon.parse(null, null); }, TypeError, 'invalid point ‘,’')); 68 | }); 69 | 70 | describe('toString', function() { 71 | test('default', () => new LatLon(1, -2).toString().should.equal('01.0000°N, 002.0000°W')); 72 | test('d', () => new LatLon(1, -2).toString('d').should.equal('01.0000°N, 002.0000°W')); 73 | test('dm', () => new LatLon(1, -2).toString('dm').should.equal('01°00.00′N, 002°00.00′W')); 74 | test('dms', () => new LatLon(1, -2).toString('dms').should.equal('01°00′00″N, 002°00′00″W')); 75 | test('d,6', () => new LatLon(1, -2).toString('d', 6).should.equal('01.000000°N, 002.000000°W')); 76 | test('dm,4', () => new LatLon(1, -2).toString('dm', 4).should.equal('01°00.0000′N, 002°00.0000′W')); 77 | test('dms,2', () => new LatLon(1, -2).toString('dms', 2).should.equal('01°00′00.00″N, 002°00′00.00″W')); 78 | test('d,6,2', () => new LatLon(1, -2).toString('d', 6, 2).should.equal('01.000000°N, 002.000000°W +0.00m')); 79 | test('dm,4,2', () => new LatLon(1, -2).toString('dm', 4, 2).should.equal('01°00.0000′N, 002°00.0000′W +0.00m')); 80 | test('dms,2,2', () => new LatLon(1, -2).toString('dms', 2, 2).should.equal('01°00′00.00″N, 002°00′00.00″W +0.00m')); 81 | test('d6+m', () => new LatLon(1, -2, 99).toString('d', 6, 2).should.equal('01.000000°N, 002.000000°W +99.00m')); 82 | test('dm4+m', () => new LatLon(1, -2, 99).toString('dm', 4, 2).should.equal('01°00.0000′N, 002°00.0000′W +99.00m')); 83 | test('dms2+m', () => new LatLon(1, -2, 99).toString('dms', 2, 2).should.equal('01°00′00.00″N, 002°00′00.00″W +99.00m')); 84 | test('d6-m', () => new LatLon(1, -2, -99).toString('d', 6, 2).should.equal('01.000000°N, 002.000000°W -99.00m')); 85 | test('dm4-m', () => new LatLon(1, -2, -99).toString('dm', 4, 2).should.equal('01°00.0000′N, 002°00.0000′W -99.00m')); 86 | test('dms2-m', () => new LatLon(1, -2, -99).toString('dms', 2, 2).should.equal('01°00′00.00″N, 002°00′00.00″W -99.00m')); 87 | test('n', () => new LatLon(1, -2).toString('n').should.equal('1.0000, -2.0000')); 88 | test('n,6', () => new LatLon(1, -2).toString('n', 6).should.equal('1.000000, -2.000000')); 89 | test('n,6,0', () => new LatLon(1, -2).toString('n', 6, 0).should.equal('1.000000, -2.000000 +0m')); 90 | test('n,6,2', () => new LatLon(1, -2).toString('n', 6, 2).should.equal('1.000000, -2.000000 +0.00m')); 91 | test('n,6,2+h', () => new LatLon(1, -2, 99).toString('n', 6, 2).should.equal('1.000000, -2.000000 +99.00m')); 92 | test('n,6,2-h', () => new LatLon(1, -2, -99).toString('n', 6, 2).should.equal('1.000000, -2.000000 -99.00m')); 93 | }); 94 | 95 | describe('getters/setters', function() { 96 | const p = new LatLon(51.47788, -0.00147, 99); 97 | test('get lat', () => p.lat.should.equal(51.47788)); 98 | test('get latitude', () => p.latitude.should.equal(51.47788)); 99 | test('get lon', () => p.lon.should.equal(-0.00147)); 100 | test('get lng', () => p.lng.should.equal(-0.00147)); 101 | test('get longitude', () => p.longitude.should.equal(-0.00147)); 102 | test('get height', () => p.height.should.equal(99)); 103 | test('set lat', () => { p.lat = 48.8584; p.lat.should.equal(48.8584); }); 104 | test('set latitude', () => { p.latitude = 48.8584; p.latitude.should.equal(48.8584); }); 105 | test('set lon', () => { p.lon = 2.2945; p.lon.should.equal(2.2945); }); 106 | test('set lng', () => { p.lng = 2.2945; p.lng.should.equal(2.2945); }); 107 | test('set longitude', () => { p.longitude = 2.2945; p.longitude.should.equal(2.2945); }); 108 | test('set height', () => { p.height = 9; p.height.should.equal(9); }); 109 | test('get ellipsoids', () => LatLon.ellipsoids.should.deep.equal({ WGS84: { a: 6378137, b: 6356752.314245, f: 1/298.257223563 } })); 110 | test('set lat fail text', () => should.Throw(function() { p.lat = 'x'; }, TypeError, 'invalid lat ‘x’')); 111 | test('set lat fail null', () => should.Throw(function() { p.lat = null; }, TypeError, 'invalid lat ‘null’')); 112 | test('set latitude fail text', () => should.Throw(function() { p.latitude = 'x'; }, TypeError, 'invalid latitude ‘x’')); 113 | test('set latitude fail null', () => should.Throw(function() { p.latitude = null; }, TypeError, 'invalid latitude ‘null’')); 114 | test('set lon fail text', () => should.Throw(function() { p.lon = 'x'; }, TypeError, 'invalid lon ‘x’')); 115 | test('set lon fail null', () => should.Throw(function() { p.lon = null; }, TypeError, 'invalid lon ‘null’')); 116 | test('set lng fail text', () => should.Throw(function() { p.lng = 'x'; }, TypeError, 'invalid lng ‘x’')); 117 | test('set lng fail null', () => should.Throw(function() { p.lng = null; }, TypeError, 'invalid lng ‘null’')); 118 | test('set longitude fail text', () => should.Throw(function() { p.longitude = 'x'; }, TypeError, 'invalid longitude ‘x’')); 119 | test('set longitude fail null', () => should.Throw(function() { p.longitude = null; }, TypeError, 'invalid longitude ‘null’')); 120 | test('set height fail text', () => should.Throw(function() { p.height = 'x'; }, TypeError, 'invalid height ‘x’')); 121 | test('set height fail null', () => should.Throw(function() { p.height = null; }, TypeError, 'invalid height ‘null’')); 122 | }); 123 | 124 | describe('equals', function() { 125 | const p1 = new LatLon(51.47788, -0.00147); 126 | const p2 = new LatLon(51.47788, -0.00147); 127 | test('JS equals', () => (p1 == p2).should.equal(false)); 128 | test('LL equals', () => (p1.equals(p2)).should.equal(true)); 129 | test('LL neq lat', () => (p1.equals(new LatLon(0, -0.00147))).should.equal(false)); 130 | test('LL neq lon', () => (p1.equals(new LatLon(51.47788, 0))).should.equal(false)); 131 | test('LL neq h', () => (p1.equals(new LatLon(51.47788, -0.00147, 99))).should.equal(false)); 132 | test('equals fail', () => should.Throw(function() { p1.equals(null); }, TypeError, 'invalid point ‘null’')); 133 | }); 134 | 135 | describe('cartesian', function() { 136 | const p = new LatLon(45, 45); 137 | test('toCartesian', () => p.toCartesian().toString().should.equal('[3194419,3194419,4487348]')); 138 | const c = new Cartesian(3194419, 3194419, 4487348); 139 | test('toLatLon', () => c.toLatLon().toString().should.equal('45.0000°N, 045.0000°E')); 140 | test('toLatLon w/ ellipse', () => c.toLatLon(LatLon.datums.WGS84.ellipsoid).toString().should.equal('45.0000°N, 045.0000°E')); 141 | test('toLatLon fail (null)', () => should.Throw(function() { c.toLatLon(null); }, TypeError, 'invalid ellipsoid ‘null’')); 142 | test('toLatLon fail (str)', () => should.Throw(function() { c.toLatLon('WGS84'); }, TypeError, 'invalid ellipsoid ‘WGS84’')); 143 | }); 144 | }); 145 | -------------------------------------------------------------------------------- /test/latlon-ellipsoidal-vincenty-tests.js: -------------------------------------------------------------------------------- 1 | /* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 2 | /* Geodesy Test Harness - latlon-ellipsoidal-vincenty (c) Chris Veness 2014-2022 */ 3 | /* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 4 | 5 | import LatLon, { Dms } from '../latlon-ellipsoidal-vincenty.js'; 6 | import { datums } from '../latlon-ellipsoidal-datum.js'; 7 | 8 | if (typeof window == 'undefined') { // node 9 | const { default: chai } = await import('chai'); 10 | global.should = chai.should(); 11 | } 12 | 13 | 14 | describe('latlon-ellipsoidal-vincenty', function() { 15 | const test = it; // just an alias 16 | Dms.separator = ''; // tests are easier without any DMS separator 17 | 18 | const circEquatorial = 40075016.686; // eslint-disable-line no-unused-vars 19 | const circMeridional = 40007862.918; 20 | 21 | describe('@examples', function() { 22 | test('distanceTo', () => new LatLon(50.06632, -5.71475).distanceTo(new LatLon(58.64402, -3.07009)).toFixed(3).should.equal('969954.166')); 23 | test('initialBearingTo', () => new LatLon(50.06632, -5.71475).initialBearingTo(new LatLon(58.64402, -3.07009)).toFixed(4).should.equal('9.1419')); 24 | test('finalBearingTo', () => new LatLon(50.06632, -5.71475).finalBearingTo(new LatLon(58.64402, -3.07009)).toFixed(4).should.equal('11.2972')); 25 | test('destinationPoint', () => new LatLon(-37.95103, 144.42487).destinationPoint(54972.271, 306.86816).toString().should.equal('37.6528°S, 143.9265°E')); 26 | test('finalBearingOn', () => new LatLon(-37.95103, 144.42487).finalBearingOn(54972.271, 306.86816).toFixed(4).should.equal('307.1736')); 27 | test('intermediatePointTo', () => new LatLon(50.06632, -5.71475).intermediatePointTo(new LatLon(58.64402, -3.07009), 0.5).toString().should.equal('54.3639°N, 004.5304°W')); 28 | }); 29 | 30 | describe('Rainsford (from TV Direct & Inverse Solutions)', function() { 31 | // Rainsford analysed errors in the order of the fifth digit of a second, and of the millimeter 32 | // TODO: some of these results exceed Rainsford's errors (if only marginally) - worth investigating? 33 | const a = { φ1: '55°45′00.00000″N', φ2: '33°26′00.00000″S', L: '108°13′00.00000″', s: '14110526.170', α1: '096°36′08.79960″', α2: '137°52′22.01454″' }; 34 | const b = { φ1: '37°19′54.95367″N', φ2: '26°07′42.83946″N', L: '041°28′35.50729″', s: '4085966.703', α1: '095°27′59.63089″', α2: '118°05′58.96161″' }; 35 | const c = { φ1: '35°16′11.24862″N', φ2: '67°22′14.77638″N', L: '137°47′28.31435″', s: '8084823.839', α1: '015°44′23.74850″', α2: '144°55′39.92147″' }; 36 | const d = { φ1: '1°00′00.00000″N', φ2: '00°59′53.83076″S', L: '179°17′48.02997″', s: '19960000.000', α1: '089°00′OO.00000″', α2: '091°00′06.11733″' }; 37 | const e = { φ1: '01°00′00.00000″N', φ2: '01°01′15.18952″N', L: '179°46′17.84244″', s: '19780006.558', α1: '004°59′59.99995″', α2: '174°59′59.88481″' }; 38 | // "The first example is on the Bessel Ellipsoid and the remaining ones are on the International" 39 | a.p1 = LatLon.parse(a.φ1, 0); a.p2 = LatLon.parse(a.φ2, a.L); a.p1.datum = datums.Potsdam; // using Bessel ellipsoid 40 | b.p1 = LatLon.parse(b.φ1, 0); b.p2 = LatLon.parse(b.φ2, b.L); b.p1.datum = datums.ED50; // using Intl1924 ellipsoid 41 | c.p1 = LatLon.parse(c.φ1, 0); c.p2 = LatLon.parse(c.φ2, c.L); c.p1.datum = datums.ED50; // using Intl1924 ellipsoid 42 | d.p1 = LatLon.parse(d.φ1, 0); d.p2 = LatLon.parse(d.φ2, d.L); d.p1.datum = datums.ED50; // using Intl1924 ellipsoid 43 | e.p1 = LatLon.parse(e.φ1, 0); e.p2 = LatLon.parse(e.φ2, e.L); e.p1.datum = datums.ED50; // using Intl1924 ellipsoid 44 | test('a direct dest', () => a.p1.destinationPoint(a.s, Dms.parse(a.α1)).toString('dms', 5).should.equal('33°26′00.00001″S, 108°13′00.00001″E')); // δ0.00001″ 45 | test('a inverse dist', () => a.p1.distanceTo(a.p2).toFixed(3).should.equal(a.s)); // δ- 46 | test('a inverse brng1', () => Dms.toBrng(a.p1.initialBearingTo(a.p2), 'dms', 5).should.equal('096°36′08.79948″')); // δ0.00012″ 47 | test('a inverse brng2', () => Dms.toBrng(a.p1.finalBearingTo(a.p2), 'dms', 5).should.equal('137°52′22.01448″')); // δ0.00006″ 48 | test('b direct dest', () => b.p1.destinationPoint(b.s, Dms.parse(b.α1)).toString('dms', 5).should.equal('26°07′42.83945″N, 041°28′35.50730″E')); // δ0.00001″ 49 | test('b inverse dist', () => b.p1.distanceTo(b.p2).toFixed(3).should.equal(b.s)); // δ- 50 | test('b inverse brng1', () => Dms.toBrng(b.p1.initialBearingTo(b.p2), 'dms', 5).should.equal('095°27′59.63076″')); // δ0.00013″ 51 | test('b inverse brng2', () => Dms.toBrng(b.p1.finalBearingTo(b.p2), 'dms', 5).should.equal('118°05′58.96176″')); // δ0.00015″ 52 | test('c direct dest', () => c.p1.destinationPoint(c.s, Dms.parse(c.α1)).toString('dms', 5).should.equal('67°22′14.77636″N, 137°47′28.31438″E')); // δ0.00003″ 53 | test('c inverse dist', () => c.p1.distanceTo(c.p2).toFixed(3).should.equal('8084823.838')); // δ1mm 54 | test('c inverse brng1', () => Dms.toBrng(c.p1.initialBearingTo(c.p2), 'dms', 5).should.equal('015°44′23.74836″')); // δ0.00014″ 55 | test('c inverse brng2', () => Dms.toBrng(c.p1.finalBearingTo(c.p2), 'dms', 5).should.equal('144°55′39.92160″')); // δ0.00013″ 56 | test('d direct dest', () => d.p1.destinationPoint(d.s, Dms.parse(d.α1)).toString('dms', 5).should.equal('00°59′53.83076″S, 179°17′48.02998″E')); // δ0.00001″ 57 | test('d inverse dist', () => d.p1.distanceTo(d.p2).toFixed(3).should.equal(d.s)); // δ- 58 | test('d inverse brng1', () => Dms.toBrng(d.p1.initialBearingTo(d.p2), 'dms', 5).should.equal('088°59′59.99892″')); // δ0.00108″ 59 | test('d inverse brng2', () => Dms.toBrng(d.p1.finalBearingTo(d.p2), 'dms', 5).should.equal('091°00′06.11820″')); // δ0.00087″ 60 | test('e direct dest', () => e.p1.destinationPoint(e.s, Dms.parse(e.α1)).toString('dms', 5).should.equal('01°01′15.18955″N, 179°46′17.84244″E')); // δ0.00003″ 61 | test('e inverse dist', () => e.p1.distanceTo(e.p2).toFixed(3).should.equal('19780006.559')); // δ1mm 62 | test('e inverse brng1', () => Dms.toBrng(e.p1.initialBearingTo(e.p2), 'dms', 5).should.equal('005°00′00.00000″')); // δ0.00005″ 63 | test('e inverse brng2', () => Dms.toBrng(e.p1.finalBearingTo(e.p2), 'dms', 5).should.equal('174°59′59.88480″')); // δ0.00001″ 64 | }); 65 | 66 | describe('UK', function() { 67 | const le = new LatLon(50.06632, -5.71475), jog = new LatLon(58.64402, -3.07009); 68 | const dist = 969954.166, brngInit = 9.1418775, brngFinal = 11.2972204; 69 | test('inverse distance', () => le.distanceTo(jog).should.equal(dist)); 70 | test('inverse initial bearing', () => le.initialBearingTo(jog).should.equal(brngInit)); 71 | test('inverse final bearing', () => le.finalBearingTo(jog).should.equal(brngFinal)); 72 | test('direct destination', () => le.destinationPoint(dist, brngInit).toString('d').should.equal(jog.toString('d'))); 73 | test('direct final bearing', () => le.finalBearingOn(dist, brngInit).should.equal(brngFinal)); 74 | test('intermediate point 0', () => le.intermediatePointTo(jog, 0).should.equal(le)); 75 | test('intermediate point 1', () => le.intermediatePointTo(jog, 1).should.equal(jog)); 76 | test('inverse distance (fail)', () => should.Throw(function() { le.distanceTo(null); }, TypeError, 'invalid point ‘null’')); 77 | test('inverse init brng (fail)', () => should.Throw(function() { le.initialBearingTo(null); }, TypeError, 'invalid point ‘null’')); 78 | test('inverse final brng (fail)', () => should.Throw(function() { le.finalBearingTo(null); }, TypeError, 'invalid point ‘null’')); 79 | }); 80 | 81 | describe('Geoscience Australia', function() { 82 | const flindersPeak = LatLon.parse('37°57′03.72030″S, 144°25′29.52440W″'); 83 | const buninyong = LatLon.parse('37°39′10.15610″S, 143°55′35.38390W″'); 84 | const dist = 54972.271, azFwd = '306°52′05.37″', azRev = '127°10′25.07″'; 85 | test('inverse distance', () => flindersPeak.distanceTo(buninyong).should.equal(dist)); 86 | test('inverse initial bearing', () => Dms.toBrng(flindersPeak.initialBearingTo(buninyong), 'dms', 2).should.equal(azFwd)); 87 | test('inverse final bearing', () => Dms.toBrng(flindersPeak.finalBearingTo(buninyong)-180, 'dms', 2).should.equal(azRev)); 88 | test('direct destination', () => flindersPeak.destinationPoint(dist, Dms.parse(azFwd)).toString('d').should.equal(buninyong.toString('d'))); 89 | test('direct final brng', () => Dms.toBrng(flindersPeak.finalBearingOn(dist, Dms.parse(azFwd))-180, 'dms', 2).should.equal(azRev)); 90 | }); 91 | 92 | describe('antipodal', function() { 93 | test('near-antipodal distance', () => new LatLon(0, 0).distanceTo(new LatLon(0.5, 179.5)).should.equal(19936288.579)); 94 | test('antipodal convergence failure dist', () => new LatLon(0, 0).distanceTo(new LatLon(0.5, 179.7)).should.be.NaN); 95 | test('antipodal convergence failure brng i', () => new LatLon(0, 0).initialBearingTo(new LatLon(0.5, 179.7)).should.be.NaN); 96 | test('antipodal convergence failure brng f', () => new LatLon(0, 0).finalBearingTo(new LatLon(0.5, 179.7)).should.be.NaN); 97 | test('antipodal distance equatorial', () => new LatLon(0, 0).distanceTo(new LatLon(0, 180)).should.equal(circMeridional/2)); 98 | test('antipodal brng equatorial', () => new LatLon(0, 0).initialBearingTo(new LatLon(0, 180)).should.equal(0)); 99 | test('antipodal distance meridional', () => new LatLon(90, 0).distanceTo(new LatLon(-90, 0)).should.equal(circMeridional/2)); 100 | test('antipodal brng meridional', () => new LatLon(90, 0).initialBearingTo(new LatLon(-90, 0)).should.equal(0)); 101 | }); 102 | 103 | describe('small dist (to 2mm)', function() { 104 | const p = new LatLon(0, 0); 105 | test('1e-5°', () => p.distanceTo(new LatLon(0.000010000, 0.000010000)).should.equal(1.569)); 106 | test('1e-6°', () => p.distanceTo(new LatLon(0.000001000, 0.000001000)).should.equal(0.157)); 107 | test('1e-7°', () => p.distanceTo(new LatLon(0.000000100, 0.000000100)).should.equal(0.016)); 108 | test('1e-8°', () => p.distanceTo(new LatLon(0.000000010, 0.000000010)).should.equal(0.002)); 109 | test('1e-9°', () => p.distanceTo(new LatLon(0.000000001, 0.000000001)).should.equal(0.000)); 110 | }); 111 | 112 | describe('coincident', function() { 113 | const le = new LatLon(50.06632, -5.71475); 114 | test('inverse coincident distance', () => le.distanceTo(le).should.equal(0)); 115 | test('inverse coincident initial bearing', () => le.initialBearingTo(le).should.be.NaN); 116 | test('inverse coincident final bearing', () => le.finalBearingTo(le).should.be.NaN); 117 | test('inverse equatorial distance', () => new LatLon(0, 0).distanceTo(new LatLon(0, 1)).should.equal(111319.491)); 118 | test('direct coincident destination', () => le.destinationPoint(0, 0).toString('d', 6).should.equal(le.toString('d', 6))); 119 | }); 120 | 121 | describe('antimeridian', function() { 122 | test('crossing antimeridian', () => new LatLon(30, 120).distanceTo(new LatLon(30, -120)).should.equal(10825924.089)); 123 | }); 124 | 125 | describe('quadrants', function() { 126 | /* eslint-disable space-in-parens, comma-spacing */ 127 | test('Q1 a', () => new LatLon( 30, 30).distanceTo(new LatLon( 60, 60)).should.equal(4015703.021)); 128 | test('Q1 b', () => new LatLon( 60, 60).distanceTo(new LatLon( 30, 30)).should.equal(4015703.021)); 129 | test('Q1 c', () => new LatLon( 30, 60).distanceTo(new LatLon( 60, 30)).should.equal(4015703.021)); 130 | test('Q1 d', () => new LatLon( 60, 30).distanceTo(new LatLon( 30, 60)).should.equal(4015703.021)); 131 | test('Q2 a', () => new LatLon( 30,-30).distanceTo(new LatLon( 60,-60)).should.equal(4015703.021)); 132 | test('Q2 b', () => new LatLon( 60,-60).distanceTo(new LatLon( 30,-30)).should.equal(4015703.021)); 133 | test('Q2 c', () => new LatLon( 30,-60).distanceTo(new LatLon( 60,-30)).should.equal(4015703.021)); 134 | test('Q2 d', () => new LatLon( 60,-30).distanceTo(new LatLon( 30,-60)).should.equal(4015703.021)); 135 | test('Q3 a', () => new LatLon(-30,-30).distanceTo(new LatLon(-60,-60)).should.equal(4015703.021)); 136 | test('Q3 b', () => new LatLon(-60,-60).distanceTo(new LatLon(-30,-30)).should.equal(4015703.021)); 137 | test('Q3 c', () => new LatLon(-30,-60).distanceTo(new LatLon(-60,-30)).should.equal(4015703.021)); 138 | test('Q3 d', () => new LatLon(-60,-30).distanceTo(new LatLon(-30,-60)).should.equal(4015703.021)); 139 | test('Q4 a', () => new LatLon(-30, 30).distanceTo(new LatLon(-60, 60)).should.equal(4015703.021)); 140 | test('Q4 b', () => new LatLon(-60, 60).distanceTo(new LatLon(-30, 30)).should.equal(4015703.021)); 141 | test('Q4 c', () => new LatLon(-30, 60).distanceTo(new LatLon(-60, 30)).should.equal(4015703.021)); 142 | test('Q4 d', () => new LatLon(-60, 30).distanceTo(new LatLon(-30, 60)).should.equal(4015703.021)); 143 | }); 144 | 145 | describe('surface only', function() { 146 | const le = new LatLon(50.06632, -5.71475, 1), jog = new LatLon(58.64402, -3.07009); 147 | test('distanceTo (fail)', () => should.Throw(function() { le.distanceTo(jog); }, RangeError, 'point must be on the surface of the ellipsoid')); 148 | test('initialBearingTo (fail)', () => should.Throw(function() { le.initialBearingTo(jog); }, RangeError, 'point must be on the surface of the ellipsoid')); 149 | test('finalBearingTo (fail)', () => should.Throw(function() { le.finalBearingTo(jog); }, RangeError, 'point must be on the surface of the ellipsoid')); 150 | test('destinationPoint (fail)', () => should.Throw(function() { le.destinationPoint(1, 0); }, RangeError, 'point must be on the surface of the ellipsoid')); 151 | test('finalBearingOn (fail)', () => should.Throw(function() { le.finalBearingOn(1, 0); }, RangeError, 'point must be on the surface of the ellipsoid')); 152 | }); 153 | 154 | describe('convergence', function() { 155 | test('vincenty antipodal λ > π', () => new LatLon(0.0, 0.0).distanceTo(new LatLon(0.5, 179.7)).should.be.NaN); 156 | test('vincenty antipodal convergence', () => new LatLon(5.0, 0.0).distanceTo(new LatLon(-5.1, 179.4)).should.be.NaN); 157 | }); 158 | 159 | describe('direct returns LatLonEllipsoidal_Vincenty object', function() { 160 | const p1 = new LatLon(1, 1); 161 | const p2 = p1.destinationPoint(1, 0); 162 | test('dest pt has distanceTo() method', () => p2.distanceTo(p1).should.equal(1)); 163 | }); 164 | 165 | describe('OSGB36 datum using Airy1830 ellipsoid', function() { 166 | const le = new LatLon(50.065716, -5.713824); // in OSGB-36 167 | const jog = new LatLon(58.644399, -3.068521); // in OSGB-36 168 | le.datum = datums.OSGB36; // source point determines ellipsoid to use 169 | const dist = 969982.014; // 27.848m more than on WGS-84 ellipsoid; Airy1830 has a smaller flattening, hence larger distance at higher latitudes 170 | const brngInit = 9.1428517; 171 | test('inverse distance', () => le.distanceTo(jog).should.equal(dist)); 172 | test('inverse bearing', () => le.initialBearingTo(jog).should.equal(brngInit)); 173 | test('direct destination', () => le.destinationPoint(dist, brngInit).toString('d', 6).should.equal('58.644399°N, 003.068521°W')); 174 | }); 175 | 176 | describe('constructor with strings', function() { 177 | test('distanceTo d', () => new LatLon('52.205', '0.119').distanceTo(new LatLon('48.857', '2.351')).should.equal(404607.806)); 178 | }); 179 | 180 | }); 181 | -------------------------------------------------------------------------------- /test/latlon-nvector-ellipsoidal-tests.js: -------------------------------------------------------------------------------- 1 | /* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 2 | /* Geodesy Test Harness - latlon-nvector-ellipsoidal (c) Chris Veness 2014-2021 */ 3 | /* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 4 | 5 | import LatLon, { Nvector, Cartesian, Ned, Dms } from '../latlon-nvector-ellipsoidal.js'; 6 | 7 | if (typeof window == 'undefined') { // node 8 | const { default: chai } = await import('chai'); 9 | global.should = chai.should(); 10 | } 11 | 12 | 13 | describe('latlon-nvector-ellipsoidal', function() { 14 | const test = it; // just an alias 15 | Dms.separator = ''; // tests are easier without any DMS separator 16 | 17 | describe('@examples LatLon', function() { 18 | test('deltaTo', () => new LatLon(49.66618, 3.45063, 99).deltaTo(new LatLon(48.88667, 2.37472, 64)).toString().should.equal('[N:-86127,E:-78901,D:1104]')); 19 | test('deltaTo l', () => new LatLon(49.66618, 3.45063, 99).deltaTo(new LatLon(48.88667, 2.37472, 64)).length.toFixed(3).should.equal('116809.178')); 20 | test('deltaTo b', () => new LatLon(49.66618, 3.45063, 99).deltaTo(new LatLon(48.88667, 2.37472, 64)).bearing.toFixed(3).should.equal('222.493')); 21 | test('deltaTo e', () => new LatLon(49.66618, 3.45063, 99).deltaTo(new LatLon(48.88667, 2.37472, 64)).elevation.toFixed(4).should.equal('-0.5416')); 22 | test('destinationPoint', () => new LatLon(49.66618, 3.45063, 99).destinationPoint(Ned.fromDistanceBearingElevation(116809.178, 222.493, -0.5416)).toString().should.equal('48.8867°N, 002.3747°E')); 23 | test('toNvector', () => new LatLon(45, 45).toNvector().toString(4).should.equal('[0.5000,0.5000,0.7071]')); 24 | }); 25 | 26 | describe('@examples Nvector', function() { 27 | test('toLatLon', () => new Nvector(0.5000, 0.5000, 0.707107).toLatLon().toString().should.equal('45.0000°N, 045.0000°E')); 28 | test('toCartesian', () => new Nvector(0.5000, 0.5000, 0.707107).toCartesian().toString().should.equal('[3194419,3194419,4487349]')); 29 | test('toString', () => new Nvector(0.5000, 0.5000, 0.7071).toString().should.equal('[0.500,0.500,0.707]')); 30 | test('toString', () => new Nvector(0.5000, 0.5000, 0.7071, 1).toString(6, 0).should.equal('[0.500002,0.500002,0.707103+1m]')); 31 | }); 32 | 33 | describe('@examples Cartesian', function() { 34 | test('toNvector', () => new Cartesian(3980581, 97, 4966825).toNvector().toString(4).should.equal('[0.6228,0.0000,0.7824]')); 35 | }); 36 | 37 | describe('@examples Ned', function() { 38 | test('constructor', () => new Ned(110569, 111297, 1936).toString().should.equal('[N:110569,E:111297,D:1936]')); 39 | test('fromDistanceBearingElevation', () => Ned.fromDistanceBearingElevation(116809.178, 222.493, -0.5416).toString().should.equal('[N:-86127,E:-78901,D:1104]')); 40 | }); 41 | 42 | describe('lat/lon / n-vector / cartesian conversions', function() { 43 | describe('lat/lon => cartesian', function() { 44 | test('0°N,0°E', () => new LatLon(0, 0).toCartesian().toString().should.equal('[6378137,0,0]')); 45 | test('0°N,90°E', () => new LatLon(0, 90).toCartesian().toString().should.equal('[0,6378137,0]')); 46 | test('90°N', () => new LatLon(90, 0).toCartesian().toString().should.equal('[0,0,6356752]')); 47 | test('45°N,45°E', () => new LatLon(45, 45).toCartesian().toString().should.equal('[3194419,3194419,4487348]')); 48 | test('-45°N,-45°E', () => new LatLon(-45, -45).toCartesian().toString().should.equal('[3194419,-3194419,-4487348]')); 49 | }); 50 | describe('cartesian => lat/lon', function() { 51 | test('0°N,0°E', () => new LatLon(0, 0).toCartesian().toLatLon().toString().should.equal('00.0000°N, 000.0000°E')); 52 | test('0°N,90°E', () => new LatLon(0, 90).toCartesian().toLatLon().toString().should.equal('00.0000°N, 090.0000°E')); 53 | test('90°N', () => new LatLon(90, 0).toCartesian().toLatLon().toString().should.equal('90.0000°N, 000.0000°E')); 54 | test('45°N,45°E', () => new LatLon(45, 45).toCartesian().toLatLon().toString().should.equal('45.0000°N, 045.0000°E')); 55 | test('-45°N,-45°E', () => new LatLon(-45, -45).toCartesian().toLatLon().toString().should.equal('45.0000°S, 045.0000°W')); 56 | }); 57 | describe('lat/lon => n-vector', function() { 58 | test('0°N,0°E', () => new LatLon(0, 0).toNvector().toString().should.equal('[1.000,0.000,0.000]')); 59 | test('0°N,90°E', () => new LatLon(0, 90).toNvector().toString().should.equal('[0.000,1.000,0.000]')); 60 | test('90°N', () => new LatLon(90, 0).toNvector().toString().should.equal('[0.000,0.000,1.000]')); 61 | test('45°N,45°E', () => new LatLon(45, 45).toNvector().toString().should.equal('[0.500,0.500,0.707]')); 62 | test('-45°N,-45°E', () => new LatLon(-45, -45).toNvector().toString().should.equal('[0.500,-0.500,-0.707]')); 63 | }); 64 | describe('n-vector => lat/lon', function() { 65 | test('0°N,0°E', () => new LatLon(0, 0).toNvector().toLatLon().toString().should.equal('00.0000°N, 000.0000°E')); 66 | test('0°N,90°E', () => new LatLon(0, 90).toNvector().toLatLon().toString().should.equal('00.0000°N, 090.0000°E')); 67 | test('90°N', () => new LatLon(90, 0).toNvector().toLatLon().toString().should.equal('90.0000°N, 000.0000°E')); 68 | test('45°N,45°E', () => new LatLon(45, 45).toNvector().toLatLon().toString().should.equal('45.0000°N, 045.0000°E')); 69 | test('-45°N,-45°E', () => new LatLon(-45, -45).toNvector().toLatLon().toString().should.equal('45.0000°S, 045.0000°W')); 70 | }); 71 | describe('n-vector => cartesian', function() { 72 | test('0°N,0°E', () => new LatLon(0, 0).toNvector().toCartesian().toString().should.equal('[6378137,0,0]')); 73 | test('0°N,90°E', () => new LatLon(0, 90).toNvector().toCartesian().toString().should.equal('[0,6378137,0]')); 74 | test('90°N', () => new LatLon(90, 0).toNvector().toCartesian().toString().should.equal('[0,0,6356752]')); 75 | test('45°N,45°E', () => new LatLon(45, 45).toNvector().toCartesian().toString().should.equal('[3194419,3194419,4487348]')); 76 | test('-45°N,-45°E', () => new LatLon(-45, -45).toNvector().toCartesian().toString().should.equal('[3194419,-3194419,-4487348]')); 77 | }); 78 | describe('cartesian => n-vector', function() { 79 | test('0°N,0°E', () => new LatLon(0, 0).toCartesian().toNvector().toString().should.equal('[1.000,0.000,0.000]')); 80 | test('0°N,90°E', () => new LatLon(0, 90).toCartesian().toNvector().toString().should.equal('[0.000,1.000,0.000]')); 81 | test('90°N', () => new LatLon(90, 0).toCartesian().toNvector().toString().should.equal('[0.000,0.000,1.000]')); 82 | test('45°N,45°E', () => new LatLon(45, 45).toCartesian().toNvector().toString().should.equal('[0.500,0.500,0.707]')); 83 | test('-45°N,-45°E', () => new LatLon(-45, -45).toCartesian().toNvector().toString().should.equal('[0.500,-0.500,-0.707]')); 84 | }); 85 | describe('cartesian', function() { 86 | test('0°N,0°E', () => new Nvector(1, 0, 0).toCartesian().toString().should.equal('[6378137,0,0]')); 87 | test('0°N,90°E', () => new Nvector(0, 1, 0).toCartesian().toString().should.equal('[0,6378137,0]')); 88 | test('90°N', () => new Nvector(0, 0, 1).toCartesian().toString().should.equal('[0,0,6356752]')); 89 | test('0°N,0°E @100m', () => new Nvector(1, 0, 0, 100).toCartesian().toString().should.equal('[6378237,0,0]')); 90 | test('0°N,90°E @100m', () => new Nvector(0, 1, 0, 100).toCartesian().toString().should.equal('[0,6378237,0]')); 91 | test('90°N @100m', () => new Nvector(0, 0, 1, 100).toCartesian().toString().should.equal('[0,0,6356852]')); 92 | test('45°N,45°E', () => new Nvector(0.5, 0.5, 0.7071).toCartesian().toString().should.equal('[3194434,3194434,4487327]')); 93 | test('45°N,45°E @100m', () => new Nvector(0.5, 0.5, 0.7071, 100).toCartesian().toString().should.equal('[3194484,3194484,4487398]')); 94 | }); 95 | describe('toString', function() { 96 | test('default', () => new Nvector(1, 0, 0).toString().should.equal('[1.000,0.000,0.000]')); 97 | test('dp=2', () => new Nvector(1, 0, 0).toString(2).should.equal('[1.00,0.00,0.00]')); 98 | test('dp=2,2', () => new Nvector(1, 0, 0).toString(2, 2).should.equal('[1.00,0.00,0.00+0.00m]')); 99 | test('h+ve', () => new Nvector(1, 0, 0, 1).toString(3, 2).should.equal('[1.000,0.000,0.000+1.00m]')); 100 | test('h-ve', () => new Nvector(1, 0, 0, -1).toString(3, 2).should.equal('[1.000,0.000,0.000-1.00m]')); 101 | }); 102 | }); 103 | 104 | describe('deltaTo', function() { 105 | test('0°N,0°E -> 1°N,1°E', () => new LatLon(0, 0).deltaTo(new LatLon(1, 1)).toString().should.equal('[N:110569,E:111297,D:1936]')); 106 | test('0°N,0°E -> 10°N,1°E', () => new LatLon(0, 0).deltaTo(new LatLon(10, 1)).toString().should.equal('[N:1100249,E:109634,D:97221]')); 107 | test('0°N,0°E -> 1°N,10°E', () => new LatLon(0, 0).deltaTo(new LatLon(1, 10)).toString().should.equal('[N:110569,E:1107384,D:97848]')); 108 | 109 | test('30°N,0°E -> 31°N,1°E', () => new LatLon(30, 0).deltaTo(new LatLon(31, 1)).toString().should.equal('[N:111272,E:95499,D:1689]')); 110 | test('30°N,0°E -> 40°N,1°E', () => new LatLon(30, 0).deltaTo(new LatLon(40, 1)).toString().should.equal('[N:1104162,E:85390,D:97241]')); 111 | test('30°N,0°E -> 31°N,10°E', () => new LatLon(30, 0).deltaTo(new LatLon(31, 10)).toString().should.equal('[N:152421,E:950201,D:72962]')); 112 | 113 | test('0°N,30°E -> 1°N,31°E', () => new LatLon(0, 30).deltaTo(new LatLon(1, 31)).toString().should.equal('[N:110569,E:111297,D:1936]')); 114 | test('0°N,30°E -> 10°N,31°E', () => new LatLon(0, 30).deltaTo(new LatLon(10, 31)).toString().should.equal('[N:1100249,E:109634,D:97221]')); 115 | test('0°N,30°E -> 1°N,40°E', () => new LatLon(0, 30).deltaTo(new LatLon(1, 40)).toString().should.equal('[N:110569,E:1107384,D:97848]')); 116 | 117 | test('30°N,30°E -> 31°N,31°E', () => new LatLon(30, 30).deltaTo(new LatLon(31, 31)).toString().should.equal('[N:111272,E:95499,D:1689]')); 118 | test('30°N,30°E -> 40°N,31°E', () => new LatLon(30, 30).deltaTo(new LatLon(40, 31)).toString().should.equal('[N:1104162,E:85390,D:97241]')); 119 | test('30°N,30°E -> 31°N,40°E', () => new LatLon(30, 30).deltaTo(new LatLon(31, 40)).toString().should.equal('[N:152421,E:950201,D:72962]')); 120 | 121 | test('89°N,0°E -> 90°N,0°E', () => new LatLon(89, 0).deltaTo(new LatLon(90, 0)).toString().should.equal('[N:111688,E:0,D:975]')); 122 | test('90°N,0°E -> 89°N,0°E', () => new LatLon(90, 0).deltaTo(new LatLon(89, 0)).toString().should.equal('[N:-111688,E:0,D:975]')); 123 | 124 | test('0°N,0°E -> 45°N,45°E', () => new LatLon(0, 0).deltaTo(new LatLon(45, 45)).toString().should.equal('[N:4487348,E:3194419,D:3183718]')); 125 | 126 | const a = new LatLon(49.66618, 3.45063); 127 | const b = new LatLon(48.88667, 2.37472); 128 | const δ = a.deltaTo(b); 129 | test('example delta', () => δ.toString().should.equal('[N:-86126,E:-78900,D:1069]')); 130 | test('example dist', () => δ.length.toFixed(3).should.equal('116807.681')); 131 | test('example brng', () => δ.bearing.toFixed(3).should.equal('222.493')); 132 | test('example elev', () => δ.elevation.toFixed(4).should.equal('-0.5245')); 133 | 134 | test('from delta', () => Ned.fromDistanceBearingElevation(δ.length, δ.bearing, δ.elevation).toString().should.equal('[N:-86126,E:-78900,D:1069]')); 135 | 136 | test('fail', () => should.Throw(function() { new LatLon(0, 0).deltaTo(null); }, TypeError, 'invalid point ‘null’')); 137 | }); 138 | 139 | describe('destinationPoint', function() { 140 | test('0°N,0°E -> 1°N,1°E', () => new LatLon(0, 0).destinationPoint(new Ned(110569, 111297, 1936)).toString().should.equal('01.0000°N, 001.0000°E')); 141 | test('0°N,0°E -> 10°N,1°E', () => new LatLon(0, 0).destinationPoint(new Ned(1100249, 109634, 97221)).toString().should.equal('10.0000°N, 001.0000°E')); 142 | test('0°N,0°E -> 1°N,10°E', () => new LatLon(0, 0).destinationPoint(new Ned(110569, 1107384, 97848)).toString().should.equal('01.0000°N, 010.0000°E')); 143 | 144 | test('30°N,0°E -> 31°N,1°E', () => new LatLon(30, 0).destinationPoint(new Ned(111272, 95499, 1689)).toString().should.equal('31.0000°N, 001.0000°E')); 145 | test('30°N,0°E -> 40°N,1°E', () => new LatLon(30, 0).destinationPoint(new Ned(1104162, 85390, 97241)).toString().should.equal('40.0000°N, 001.0000°E')); 146 | test('30°N,0°E -> 31°N,10°E', () => new LatLon(30, 0).destinationPoint(new Ned(152421, 950201, 72962)).toString().should.equal('31.0000°N, 010.0000°E')); 147 | 148 | test('0°N,30°E -> 1°N,31°E', () => new LatLon(0, 30).destinationPoint(new Ned(110569, 111297, 1936)).toString().should.equal('01.0000°N, 031.0000°E')); 149 | test('0°N,30°E -> 10°N,31°E', () => new LatLon(0, 30).destinationPoint(new Ned(1100249, 109634, 97221)).toString().should.equal('10.0000°N, 031.0000°E')); 150 | test('0°N,30°E -> 1°N,40°E', () => new LatLon(0, 30).destinationPoint(new Ned(110569, 1107384, 97848)).toString().should.equal('01.0000°N, 040.0000°E')); 151 | 152 | test('30°N,30°E -> 31°N,31°E', () => new LatLon(30, 30).destinationPoint(new Ned(111272, 95499, 1689)).toString().should.equal('31.0000°N, 031.0000°E')); 153 | test('30°N,30°E -> 40°N,31°E', () => new LatLon(30, 30).destinationPoint(new Ned(1104162, 85390, 97241)).toString().should.equal('40.0000°N, 031.0000°E')); 154 | test('30°N,30°E -> 31°N,40°E', () => new LatLon(30, 30).destinationPoint(new Ned(152421, 950201, 72962)).toString().should.equal('31.0000°N, 040.0000°E')); 155 | 156 | test('89°N,0°E -> 90°N,0°E', () => new LatLon(89, 0).destinationPoint(new Ned(111688, 0, 975)).toString().should.equal('90.0000°N, 000.0000°E')); 157 | test('90°N,0°E -> 89°N,0°E', () => new LatLon(90, 0).destinationPoint(new Ned(-111688, 0, 975)).toString().should.equal('89.0000°N, 000.0000°E')); 158 | 159 | test('0°N,0°E -> 45°N,45°E', () => new LatLon(0, 0).destinationPoint(new Ned(4487348, 3194419, 3183718)).toString().should.equal('45.0000°N, 045.0000°E')); 160 | 161 | test('fail', () => should.Throw(function() { new LatLon(0, 0).destinationPoint(null); }, TypeError, 'delta is not Ned object')); 162 | }); 163 | }); 164 | 165 | 166 | /* 167 | * nvector.readthedocs.io/en/latest/src/overview.html#unit-tests 168 | */ 169 | describe('navlab nvector examples (ellipsoidal)', function() { 170 | const test = it; // just an alias 171 | Dms.separator = ''; // tests are easier without any DMS separator 172 | 173 | describe('Example 1: A and B to delta', function() { 174 | const a = new LatLon(1, 2, 3); // defaults to WGS-84 175 | const b = new LatLon(4, 5, 6); 176 | const delta = a.deltaTo(b); // [N:331730.863,E:332998.501,D:17398.304] 177 | const dist = delta.length; // 470357.384 m 178 | const brng = delta.bearing; // 45.109° 179 | const elev = delta.elevation; // -2.1198° 180 | test('dist', function() { delta.toString(3).should.equal('[N:331730.863,E:332998.501,D:17398.304]'); }); 181 | test('dist', function() { dist.toFixed(3).should.equal('470357.384'); }); 182 | test('brng', function() { brng.toFixed(3).should.equal('45.109'); }); 183 | test('elev', function() { elev.toFixed(4).should.equal('-2.1198'); }); 184 | }); 185 | describe('Example 2: B and delta to C', function() { 186 | // const n = new Nvector(1, 2, 3, 400, LatLon.datums.WGS72); // [0.267,0.535,0.802,400.000] 187 | // const b = n.toLatLon(); // 53.301°N, 063.435°E +400.000m 188 | // const delta = new Ned(3000, 2000, 100); // [N:3000,E:2000,D:100] 189 | // const c = b.destinationPoint(delta); // 53.328°N, 063.465°E +299.138m 190 | // test('c', function() { c.toString('d', 3, 3).should.equal('53.328°N, 063.465°E +299.151m'); }); 191 | // TODO: fails with h=301.019m - to do with yaw=10, pitch=20, roll=30? 192 | }); 193 | describe('Example 3: ECEF-vector to geodetic latitude', function() { 194 | const c = new Cartesian(0.9*6371e3, -1.0*6371e3, 1.1*6371e3); 195 | const p = c.toLatLon(); // 39.379°N, 048.013°W +4702059.834m 196 | test('p', function() { p.toString('d', 3, 3).should.equal('39.379°N, 048.013°W +4702059.834m'); }); 197 | }); 198 | describe('Example 4: Geodetic latitude to ECEF-vector', function() { 199 | const p = new LatLon(1, 2, 3); 200 | const c = p.toCartesian(); // [6373290.277,222560.201,110568.827] 201 | test('c', function() { c.toString(3).should.equal('[6373290.277,222560.201,110568.827]'); }); 202 | }); 203 | }); 204 | -------------------------------------------------------------------------------- /test/os-gridref-tests.js: -------------------------------------------------------------------------------- 1 | /* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 2 | /* Geodesy Test Harness - os-gridref (c) Chris Veness 2014-2021 */ 3 | /* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 4 | 5 | import OsGridRef, { LatLon, Dms } from '../osgridref.js'; 6 | 7 | if (typeof window == 'undefined') { // node 8 | const { default: chai } = await import('chai'); 9 | global.should = chai.should(); 10 | } 11 | 12 | describe('os-gridref', function() { 13 | const test = it; // just an alias 14 | Dms.separator = ''; // tests are easier without any DMS separator 15 | 16 | describe('@examples', function() { 17 | test('Constructor', () => new OsGridRef(651409, 313177).should.deep.equal({ easting: 651409, northing: 313177 })); 18 | test('toLatLon', () => new OsGridRef(651409.903, 313177.270).toLatLon().toString('dms', 3).should.equal('52°39′28.723″N, 001°42′57.787″E')); 19 | test('toLatLon OSGB36', () => new OsGridRef(651409.903, 313177.270).toLatLon(LatLon.datums.OSGB36).toString('dms', 3).should.equal('52°39′27.253″N, 001°43′04.518″E')); 20 | test('parse', () => OsGridRef.parse('TG 51409 13177').should.deep.equal({ easting: 651409, northing: 313177 })); 21 | test('toString', () => new OsGridRef(651409, 313177).toString(8).should.equal('TG 5140 1317')); 22 | test('toString', () => new OsGridRef(651409, 313177).toString(0).should.equal('651409,313177')); 23 | }); 24 | 25 | describe('@examples LatLon', function() { 26 | test('toOsGrid', () => new LatLon(52.65798, 1.71605).toOsGrid().toString().should.equal('TG 51409 13177')); 27 | test('toOsGrid', () => new LatLon(52.65757, 1.71791, 0, LatLon.datums.OSGB36).toOsGrid().toString().should.equal('TG 51409 13177')); 28 | }); 29 | 30 | describe('constructor fail', function() { 31 | test('Invalid northing', () => should.Throw(function() { new OsGridRef(0, 1301e3); }, Error, 'invalid northing ‘1301000’')); 32 | test('Invalid easting', () => should.Throw(function() { new OsGridRef(701e3, 0); }, Error, 'invalid easting ‘701000’')); 33 | test('texts', () => should.Throw(function() { new OsGridRef('e', 'n'); }, Error, 'invalid easting ‘e’')); 34 | }); 35 | 36 | describe('parse fail', function() { 37 | test('text', () => should.Throw(function() { OsGridRef.parse('Cambridge'); }, Error, 'invalid grid reference ‘Cambridge’')); 38 | test('outside range', () => should.Throw(function() { OsGridRef.parse('AA 1 2'); }, Error, 'invalid grid reference ‘AA 1 2’')); 39 | test('unbalanced numerics', () => should.Throw(function() { OsGridRef.parse('SV 1 20'); }, Error, 'invalid grid reference ‘SV 1 20’')); 40 | }); 41 | 42 | describe('toString fail', function() { 43 | test('1bad precision', () => should.Throw(function() { new OsGridRef(651409, 313177).toString(20); }, Error, 'invalid precision ‘20’')); 44 | }); 45 | 46 | describe('Caister water tower', function() { 47 | // OS Guide to coordinate systems in Great Britain C.1, C.2; Caister water tower 48 | 49 | const osgb = LatLon.parse('52°39′27.2531″N, 1°43′4.5177″E', 0, LatLon.datums.OSGB36); 50 | const gridref = osgb.toOsGrid(); 51 | test('C1 E', () => gridref.easting.toFixed(3).should.equal('651409.903')); 52 | test('C1 N', () => gridref.northing.toFixed(3).should.equal('313177.270')); 53 | const osgb2 = gridref.toLatLon(LatLon.datums.OSGB36); 54 | test('C1 round-trip', () => osgb2.toString('dms', 4).should.equal('52°39′27.2531″N, 001°43′04.5177″E')); 55 | 56 | const gridrefʹ = new OsGridRef(651409.903, 313177.270); 57 | const osgbʹ = gridrefʹ.toLatLon(LatLon.datums.OSGB36); 58 | test('C2', () => osgbʹ.toString('dms', 4).should.equal('52°39′27.2531″N, 001°43′04.5177″E')); 59 | const gridref2 = osgb.toOsGrid(); 60 | test('C2 E round-trip', () => gridref2.easting.toFixed(3).should.equal('651409.903')); 61 | test('C2 N round-trip', () => gridref2.northing.toFixed(3).should.equal('313177.270')); 62 | 63 | test('parse 100km origin', () => OsGridRef.parse('SU00').toString().should.equal('SU 00000 00000')); 64 | test('parse 100km origin', () => OsGridRef.parse('SU 0 0').toString().should.equal('SU 00000 00000')); 65 | test('parse no whitespace', () => OsGridRef.parse('SU387148').toString().should.equal('SU 38700 14800')); 66 | test('parse 6-digit', () => OsGridRef.parse('SU 387 148').toString().should.equal('SU 38700 14800')); 67 | test('parse 10-digit', () => OsGridRef.parse('SU 38700 14800').toString().should.equal('SU 38700 14800')); 68 | test('parse numeric', () => OsGridRef.parse('438700,114800').toString().should.equal('SU 38700 14800')); 69 | }); 70 | 71 | describe('DG round-trip', function() { 72 | const dgGridRef = OsGridRef.parse('TQ 44359 80653'); 73 | 74 | // round-tripping OSGB36 works perfectly 75 | const dgOsgb = dgGridRef.toLatLon(LatLon.datums.OSGB36); 76 | test('DG round-trip OSGB36', function () { 77 | dgGridRef.toString().should.equal(dgOsgb.toOsGrid().toString()); 78 | }); 79 | test('DG round-trip OSGB36 numeric', function () { 80 | dgOsgb.toOsGrid().toString(0).should.equal('544359,180653'); 81 | }); 82 | 83 | // reversing Helmert transform (OSGB->WGS->OSGB) introduces small error (≈ 3mm in UK), so WGS84 84 | // round-trip is not quite perfect: test needs to incorporate 3mm error to pass 85 | const dgWgs = dgGridRef.toLatLon(); // default is WGS84 86 | dgWgs.height = 0; 87 | test('DG round-trip WGS84 numeric', function () { 88 | dgWgs.toOsGrid().toString(0).should.equal('544358.997,180653'); 89 | }); 90 | }); 91 | 92 | describe('extremities', function() { 93 | test('LE>', () => OsGridRef.parse('SW 34240 25290').toLatLon().toString().should.equal('50.0682°N, 005.7152°W')); 94 | test('LE<', () => new LatLon(50.0682, -5.7152).toOsGrid().toString().should.equal('SW 34240 25290')); 95 | test('NF>', () => OsGridRef.parse('TR 39859 69616').toLatLon().toString().should.equal('51.3749°N, 001.4451°E')); 96 | test('NF<', () => new LatLon(51.3749, 1.4451).toOsGrid().toString().should.equal('TR 39859 69616')); 97 | test('HP>', () => OsGridRef.parse('HY 45153 09450').toLatLon().toString().should.equal('58.9687°N, 002.9555°W')); 98 | test('HP<', () => new LatLon(58.9687, -2.9555).toOsGrid().toString().should.equal('HY 45153 09450')); 99 | test('RH>', () => OsGridRef.parse('HU 30497 83497').toLatLon().toString().should.equal('60.5339°N, 001.4461°W')); 100 | test('RH<', () => new LatLon(60.5339, -1.4461).toOsGrid().toString().should.equal('HU 30497 83497')); 101 | }); 102 | 103 | describe('Extra-UK lat/lon fail', function() { 104 | test('0,0', () => should.Throw(function() { new LatLon(0.00, 0.00).toOsGrid(); }, Error, 'invalid northing ‘-5527598.33’ from (-0.004833,0.000890).toOsGrid()')); 105 | test('Dublin', () => should.Throw(function() { new LatLon(53.35, 6.26).toOsGrid(); }, Error, 'invalid easting ‘949400.51’ from (53.349579,6.262431).toOsGrid()')); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /test/spherical-errors.js: -------------------------------------------------------------------------------- 1 | /* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 2 | /* Maximum & RMS errors of haversine (spherical) distance calculations against Vincenty */ 3 | /* */ 4 | /* Temperate zones 24°–66° latitude, 0°–90° longitude. */ 5 | /* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 6 | 7 | import LatLonS from '../latlon-spherical.js'; 8 | import LatLonE from '../latlon-ellipsoidal-vincenty.js'; 9 | 10 | const latMin = 24; 11 | const latMax = 66; 12 | 13 | // ---- inverse 14 | 15 | const errors = []; 16 | for (let lat1=latMin; lat1<=latMax; lat1+=6) { // temperate latitudes in 6° increments 17 | for (let lat2=lat1; lat2 Math.max(pre, cur)); 33 | const rmsErrInv = Math.sqrt(errors.reduce((pre, cur) => pre + cur*cur)/errors.length); 34 | 35 | console.info('max inv error', (maxErrInv*100).toFixed(2)+'%'); 36 | console.info('rms inv error', (rmsErrInv*100).toFixed(2)+'%'); 37 | 38 | // compare errors for distance below 1km? 39 | 40 | // ---- direct (up to 100km) 41 | 42 | errors.length = 0; 43 | for (let lat=latMin; lat<=latMax; lat+=6) { // temperate latitudes in 6° increments 44 | for (let brng=0; brng<360; brng+=10) { // 360° of bearings in 10° increments 45 | const p1Sph = new LatLonS(lat, 0); 46 | const p1Ell = new LatLonE(lat, 0); 47 | 48 | for (let d=1e3; d<100e3; d+= 1e3) { // 1..100km in 1km increments 49 | const destSph = p1Sph.destinationPoint(d, brng); 50 | const destEll = p1Ell.destinationPoint(d, brng); 51 | const diff = destEll.distanceTo(new LatLonE(destSph.lat, destSph.lon)); 52 | 53 | errors.push((diff) / d); 54 | } 55 | } 56 | } 57 | 58 | 59 | const maxErrDir = errors.reduce((pre, cur) => Math.max(pre, cur)); 60 | const rmsErrDir = Math.sqrt(errors.reduce((pre, cur) => pre + cur*cur)/errors.length); 61 | 62 | console.info('max dir error', (maxErrDir*100).toFixed(2)+'%'); 63 | console.info('rms dir error', (rmsErrDir*100).toFixed(2)+'%'); 64 | -------------------------------------------------------------------------------- /test/vector3d-tests.js: -------------------------------------------------------------------------------- 1 | /* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 2 | /* Geodesy Test Harness - vector3d (c) Chris Veness 2019-2021 */ 3 | /* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 4 | 5 | import Vector3d from '../vector3d.js'; 6 | 7 | if (typeof window == 'undefined') { // node 8 | const { default: chai } = await import('chai'); 9 | global.should = chai.should(); 10 | } 11 | 12 | describe('os-gridref', function() { 13 | const test = it; // just an alias 14 | 15 | describe('Examples', function() { 16 | test('Constructor', () => new Vector3d(0.267, 0.535, 0.802).should.deep.equal({ x: 0.267, y: 0.535, z: 0.802 })); 17 | }); 18 | 19 | describe('constructor fail', function() { 20 | test('texts', () => should.Throw(function() { new Vector3d('x', 'y', 'z'); }, TypeError, 'invalid vector [x,y,z]')); 21 | }); 22 | 23 | describe('methods', function() { 24 | const v123 = new Vector3d(1, 2, 3); 25 | const v321 = new Vector3d(3, 2, 1); 26 | test('plus', () => v123.plus(v321).should.deep.equal(new Vector3d(4, 4, 4))); 27 | test('minus', () => v123.minus(v321).should.deep.equal(new Vector3d(-2, 0, 2))); 28 | test('times', () => v123.times(2).should.deep.equal(new Vector3d(2, 4, 6))); 29 | test('times str', () => v123.times('2').should.deep.equal(new Vector3d(2, 4, 6))); 30 | test('dividedBy', () => v123.dividedBy(2).should.deep.equal(new Vector3d(0.5, 1, 1.5))); 31 | test('dot', () => v123.dot(v321).should.equal(10)); 32 | test('cross', () => v123.cross(v321).should.deep.equal(new Vector3d(-4, 8, -4))); 33 | test('negate', () => v123.negate().should.deep.equal(new Vector3d(-1, -2, -3))); 34 | test('length', () => v123.length.should.equal(3.7416573867739413)); 35 | test('unit', () => v123.unit().toString().should.equal('[0.267,0.535,0.802]')); 36 | test('angleTo', () => v123.angleTo(v321).toDegrees().toFixed(3).should.equal('44.415')); 37 | test('angleTo +', () => v123.angleTo(v321, v123.cross(v321)).toDegrees().toFixed(3).should.equal('44.415')); 38 | test('angleTo -', () => v123.angleTo(v321, v321.cross(v123)).toDegrees().toFixed(3).should.equal('-44.415')); 39 | test('angleTo 0', () => v123.angleTo(v321, v123).toDegrees().toFixed(3).should.equal('44.415')); 40 | test('rotateAround', () => v123.rotateAround(new Vector3d(0, 0, 1), 90).toString().should.equal('[-0.535,0.267,0.802]')); 41 | test('toString', () => v123.toString().should.equal('[1.000,2.000,3.000]')); 42 | test('toString', () => v123.toString(6).should.equal('[1.000000,2.000000,3.000000]')); 43 | }); 44 | 45 | describe('fails', function() { 46 | const v123 = new Vector3d(1, 2, 3); 47 | const v321 = new Vector3d(3, 2, 1); 48 | test('plus', () => should.Throw(function() { v123.plus(1); }, TypeError, 'v is not Vector3d object')); 49 | test('minus', () => should.Throw(function() { v123.minus(1); }, TypeError, 'v is not Vector3d object')); 50 | test('times', () => should.Throw(function() { v123.times('x'); }, TypeError, 'invalid scalar value ‘x’')); 51 | test('dividedBy', () => should.Throw(function() { v123.dividedBy('x'); }, TypeError, 'invalid scalar value ‘x’')); 52 | test('dot', () => should.Throw(function() { v123.dot(1); }, TypeError, 'v is not Vector3d object')); 53 | test('cross', () => should.Throw(function() { v123.cross(1); }, TypeError, 'v is not Vector3d object')); 54 | test('angleTo', () => should.Throw(function() { v123.angleTo(1); }, TypeError, 'v is not Vector3d object')); 55 | test('angleTo', () => should.Throw(function() { v123.angleTo(v321, 'x'); }, TypeError, 'n is not Vector3d object')); 56 | test('rotateAround', () => should.Throw(function() { v123.rotateAround(1); }, TypeError, 'axis is not Vector3d object')); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /vector3d.js: -------------------------------------------------------------------------------- 1 | /* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 2 | /* Vector handling functions (c) Chris Veness 2011-2019 */ 3 | /* MIT Licence */ 4 | /* www.movable-type.co.uk/scripts/geodesy-library.html#vector3d */ 5 | /* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 6 | 7 | 8 | /** 9 | * Library of 3-d vector manipulation routines. 10 | * 11 | * @module vector3d 12 | */ 13 | 14 | 15 | /* Vector3d - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 16 | 17 | 18 | /** 19 | * Functions for manipulating generic 3-d vectors. 20 | * 21 | * Functions return vectors as return results, so that operations can be chained. 22 | * 23 | * @example 24 | * const v = v1.cross(v2).dot(v3) // ≡ v1×v2⋅v3 25 | */ 26 | class Vector3d { 27 | 28 | /** 29 | * Creates a 3-d vector. 30 | * 31 | * @param {number} x - X component of vector. 32 | * @param {number} y - Y component of vector. 33 | * @param {number} z - Z component of vector. 34 | * 35 | * @example 36 | * import Vector3d from '/js/geodesy/vector3d.js'; 37 | * const v = new Vector3d(0.267, 0.535, 0.802); 38 | */ 39 | constructor(x, y, z) { 40 | if (isNaN(x) || isNaN(y) || isNaN(z)) throw new TypeError(`invalid vector [${x},${y},${z}]`); 41 | 42 | this.x = Number(x); 43 | this.y = Number(y); 44 | this.z = Number(z); 45 | } 46 | 47 | 48 | /** 49 | * Length (magnitude or norm) of ‘this’ vector. 50 | * 51 | * @returns {number} Magnitude of this vector. 52 | */ 53 | get length() { 54 | return Math.sqrt(this.x * this.x + this.y * this.y + this.z * this.z); 55 | } 56 | 57 | 58 | /** 59 | * Adds supplied vector to ‘this’ vector. 60 | * 61 | * @param {Vector3d} v - Vector to be added to this vector. 62 | * @returns {Vector3d} Vector representing sum of this and v. 63 | */ 64 | plus(v) { 65 | if (!(v instanceof Vector3d)) throw new TypeError('v is not Vector3d object'); 66 | 67 | return new Vector3d(this.x + v.x, this.y + v.y, this.z + v.z); 68 | } 69 | 70 | 71 | /** 72 | * Subtracts supplied vector from ‘this’ vector. 73 | * 74 | * @param {Vector3d} v - Vector to be subtracted from this vector. 75 | * @returns {Vector3d} Vector representing difference between this and v. 76 | */ 77 | minus(v) { 78 | if (!(v instanceof Vector3d)) throw new TypeError('v is not Vector3d object'); 79 | 80 | return new Vector3d(this.x - v.x, this.y - v.y, this.z - v.z); 81 | } 82 | 83 | 84 | /** 85 | * Multiplies ‘this’ vector by a scalar value. 86 | * 87 | * @param {number} x - Factor to multiply this vector by. 88 | * @returns {Vector3d} Vector scaled by x. 89 | */ 90 | times(x) { 91 | if (isNaN(x)) throw new TypeError(`invalid scalar value ‘${x}’`); 92 | 93 | return new Vector3d(this.x * x, this.y * x, this.z * x); 94 | } 95 | 96 | 97 | /** 98 | * Divides ‘this’ vector by a scalar value. 99 | * 100 | * @param {number} x - Factor to divide this vector by. 101 | * @returns {Vector3d} Vector divided by x. 102 | */ 103 | dividedBy(x) { 104 | if (isNaN(x)) throw new TypeError(`invalid scalar value ‘${x}’`); 105 | 106 | return new Vector3d(this.x / x, this.y / x, this.z / x); 107 | } 108 | 109 | 110 | /** 111 | * Multiplies ‘this’ vector by the supplied vector using dot (scalar) product. 112 | * 113 | * @param {Vector3d} v - Vector to be dotted with this vector. 114 | * @returns {number} Dot product of ‘this’ and v. 115 | */ 116 | dot(v) { 117 | if (!(v instanceof Vector3d)) throw new TypeError('v is not Vector3d object'); 118 | 119 | return this.x * v.x + this.y * v.y + this.z * v.z; 120 | } 121 | 122 | 123 | /** 124 | * Multiplies ‘this’ vector by the supplied vector using cross (vector) product. 125 | * 126 | * @param {Vector3d} v - Vector to be crossed with this vector. 127 | * @returns {Vector3d} Cross product of ‘this’ and v. 128 | */ 129 | cross(v) { 130 | if (!(v instanceof Vector3d)) throw new TypeError('v is not Vector3d object'); 131 | 132 | const x = this.y * v.z - this.z * v.y; 133 | const y = this.z * v.x - this.x * v.z; 134 | const z = this.x * v.y - this.y * v.x; 135 | 136 | return new Vector3d(x, y, z); 137 | } 138 | 139 | 140 | /** 141 | * Negates a vector to point in the opposite direction. 142 | * 143 | * @returns {Vector3d} Negated vector. 144 | */ 145 | negate() { 146 | return new Vector3d(-this.x, -this.y, -this.z); 147 | } 148 | 149 | 150 | /** 151 | * Normalizes a vector to its unit vector 152 | * – if the vector is already unit or is zero magnitude, this is a no-op. 153 | * 154 | * @returns {Vector3d} Normalised version of this vector. 155 | */ 156 | unit() { 157 | const norm = this.length; 158 | if (norm == 1) return this; 159 | if (norm == 0) return this; 160 | 161 | const x = this.x / norm; 162 | const y = this.y / norm; 163 | const z = this.z / norm; 164 | 165 | return new Vector3d(x, y, z); 166 | } 167 | 168 | 169 | /** 170 | * Calculates the angle between ‘this’ vector and supplied vector atan2(|p₁×p₂|, p₁·p₂) (or if 171 | * (extra-planar) ‘n’ supplied then atan2(n·p₁×p₂, p₁·p₂). 172 | * 173 | * @param {Vector3d} v - Vector whose angle is to be determined from ‘this’ vector. 174 | * @param {Vector3d} [n] - Plane normal: if supplied, angle is signed +ve if this->v is 175 | * clockwise looking along n, -ve in opposite direction. 176 | * @returns {number} Angle (in radians) between this vector and supplied vector (in range 0..π 177 | * if n not supplied, range -π..+π if n supplied). 178 | */ 179 | angleTo(v, n=undefined) { 180 | if (!(v instanceof Vector3d)) throw new TypeError('v is not Vector3d object'); 181 | if (!(n instanceof Vector3d || n == undefined)) throw new TypeError('n is not Vector3d object'); 182 | 183 | // q.v. stackoverflow.com/questions/14066933#answer-16544330, but n·p₁×p₂ is numerically 184 | // ill-conditioned, so just calculate sign to apply to |p₁×p₂| 185 | 186 | // if n·p₁×p₂ is -ve, negate |p₁×p₂| 187 | const sign = n==undefined || this.cross(v).dot(n)>=0 ? 1 : -1; 188 | 189 | const sinθ = this.cross(v).length * sign; 190 | const cosθ = this.dot(v); 191 | 192 | return Math.atan2(sinθ, cosθ); 193 | } 194 | 195 | 196 | /** 197 | * Rotates ‘this’ point around an axis by a specified angle. 198 | * 199 | * @param {Vector3d} axis - The axis being rotated around. 200 | * @param {number} angle - The angle of rotation (in degrees). 201 | * @returns {Vector3d} The rotated point. 202 | */ 203 | rotateAround(axis, angle) { 204 | if (!(axis instanceof Vector3d)) throw new TypeError('axis is not Vector3d object'); 205 | 206 | const θ = angle.toRadians(); 207 | 208 | // en.wikipedia.org/wiki/Rotation_matrix#Rotation_matrix_from_axis_and_angle 209 | // en.wikipedia.org/wiki/Quaternions_and_spatial_rotation#Quaternion-derived_rotation_matrix 210 | const p = this.unit(); 211 | const a = axis.unit(); 212 | 213 | const s = Math.sin(θ); 214 | const c = Math.cos(θ); 215 | const t = 1-c; 216 | const x = a.x, y = a.y, z = a.z; 217 | 218 | const r = [ // rotation matrix for rotation about supplied axis 219 | [ t*x*x + c, t*x*y - s*z, t*x*z + s*y ], 220 | [ t*x*y + s*z, t*y*y + c, t*y*z - s*x ], 221 | [ t*x*z - s*y, t*y*z + s*x, t*z*z + c ], 222 | ]; 223 | 224 | // multiply r × p 225 | const rp = [ 226 | r[0][0]*p.x + r[0][1]*p.y + r[0][2]*p.z, 227 | r[1][0]*p.x + r[1][1]*p.y + r[1][2]*p.z, 228 | r[2][0]*p.x + r[2][1]*p.y + r[2][2]*p.z, 229 | ]; 230 | const p2 = new Vector3d(rp[0], rp[1], rp[2]); 231 | 232 | return p2; 233 | // qv en.wikipedia.org/wiki/Rodrigues'_rotation_formula... 234 | } 235 | 236 | 237 | /** 238 | * String representation of vector. 239 | * 240 | * @param {number} [dp=3] - Number of decimal places to be used. 241 | * @returns {string} Vector represented as [x,y,z]. 242 | */ 243 | toString(dp=3) { 244 | return `[${this.x.toFixed(dp)},${this.y.toFixed(dp)},${this.z.toFixed(dp)}]`; 245 | } 246 | 247 | } 248 | 249 | 250 | // Extend Number object with methods to convert between degrees & radians 251 | Number.prototype.toRadians = function() { return this * Math.PI / 180; }; 252 | Number.prototype.toDegrees = function() { return this * 180 / Math.PI; }; 253 | 254 | /* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ 255 | 256 | export default Vector3d; 257 | --------------------------------------------------------------------------------