├── .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 | [](https://app.travis-ci.com/github/chrisveness/geodesy)
5 | [](https://coveralls.io/github/chrisveness/geodesy)
6 | [](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 |