From 21de7ad87d6d4cc77b1119ea878a256f9091f09a Mon Sep 17 00:00:00 2001 From: Matthew Bloch Date: Fri, 28 Jul 2023 00:12:43 -0400 Subject: [PATCH] v0.6.39 --- CHANGELOG.md | 3 + REFERENCE.md | 14 ++- package-lock.json | 4 +- package.json | 2 +- .../mapshaper-expression-utils.mjs | 6 +- src/geom/mapshaper-dms.mjs | 92 ++++++++++++++----- src/join/mapshaper-point-point-join.mjs | 10 ++ src/utils/mapshaper-logging.mjs | 3 +- test/dms-test.mjs | 74 ++++++++++++++- 9 files changed, 176 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c30cffae..2c70816b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +v0.6.39 +* Added `format_dms()` and `parse_dms()` functions to `-each` expressions, for working with DMS-formatted coordinates. + v0.6.38 * Added `-symbols opacity=` option. diff --git a/REFERENCE.md b/REFERENCE.md index 944e07fe..9fde35ba 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -558,7 +558,19 @@ Apply a JavaScript expression to each feature in a layer. Data properties are av `target=` Layer to target. + +**Utility functions** +Several utility functions are available within expressions. + +- `format_dms(coord [, fmt])` Format a latitude or longitude coordinate as DMS. The optional second argument is for custom formats. Examples: `[-]DDDMM.MMMMM` `DdMmSs [EW]` `DD° MM′ SS.SSSSS″ [NS]` +- `parse_dms(string [, fmt])` Parse a DMS string to a numerical coordinate. The optional second argument gives the format to use for parsing. +- `round(number [, decimals])` Optional second argument gives the number of decimals to use. + + **Properties of `this`** + +The `this` object, available within expressions, has properties relating to the geometry and attribute data of a feature. + *Properties are read-only unless otherwise indicated.* All layer types @@ -594,7 +606,7 @@ Polygon, polyline and point layers **Note:** Centroids are calculated for the largest ring of multi-part polygons, and do not account for holes. -**Note:** Most geometric properties are calculated using planar geometry. Exceptions are the areas of unprojected polygons and the lengths of unprojected polylines. These calculations use spherical geometry. +**Note:** Most geometric properties are calculated using planar geometry. Exceptions are the areas of unprojected polygons and the lengths of unprojected polylines. These calculations use spherical, not ellipsoidal geometry, so are not as accurate as the equivalent calculations in a GIS application. **Examples** diff --git a/package-lock.json b/package-lock.json index 92114695..d8f2ed6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mapshaper", - "version": "0.6.38", + "version": "0.6.39", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "mapshaper", - "version": "0.6.38", + "version": "0.6.39", "license": "MPL-2.0", "dependencies": { "@placemarkio/tokml": "^0.3.3", diff --git a/package.json b/package.json index a7388375..17fb92a7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mapshaper", - "version": "0.6.38", + "version": "0.6.39", "description": "A tool for editing vector datasets for mapping and GIS.", "keywords": [ "shapefile", diff --git a/src/expressions/mapshaper-expression-utils.mjs b/src/expressions/mapshaper-expression-utils.mjs index 2915d8f0..ae1c4c35 100644 --- a/src/expressions/mapshaper-expression-utils.mjs +++ b/src/expressions/mapshaper-expression-utils.mjs @@ -1,7 +1,7 @@ import utils from '../utils/mapshaper-utils'; import { blend } from '../color/blending'; import { roundToDigits2 } from '../geom/mapshaper-rounding'; - +import { formatDMS, parseDMS } from '../geom/mapshaper-dms'; export function cleanExpression(exp) { // workaround for problem in GNU Make v4: end-of-line backslashes inside @@ -14,7 +14,9 @@ export function addFeatureExpressionUtils(env) { round: roundToDigits2, int_median: interpolated_median, sprintf: utils.format, - blend: blend + blend: blend, + format_dms: formatDMS, + parse_dms: parseDMS }); } diff --git a/src/geom/mapshaper-dms.mjs b/src/geom/mapshaper-dms.mjs index 2d571006..e568431c 100644 --- a/src/geom/mapshaper-dms.mjs +++ b/src/geom/mapshaper-dms.mjs @@ -2,53 +2,99 @@ import { stop } from '../utils/mapshaper-logging'; // Parse a formatted value in DMS DM or D to a numeric value. Returns NaN if unparsable. // Delimiters: degrees: D|d|°; minutes: '; seconds: " -export function parseDMS(str) { - var rxp = /^([nsew+-]?)([0-9.]+)[d°]? ?([0-9.]*)['′]? ?([0-9.]*)["″]? ?([nsew]?)$/i; +export function parseDMS(str, fmt) { + var defaultRxp = /^(?[nsew+-]?)(?[0-9.]+)[d°]? ?(?[0-9.]*)['′]? ?(?[0-9.]*)["″]? ?(?[nsew]?)$/i; + var rxp = fmt ? getParseRxp(fmt) : defaultRxp; var match = rxp.exec(str.trim()); var d = NaN; var deg, min, sec, inv; if (match) { - deg = match[2] || '0'; - min = match[3] || '0'; - sec = match[4] || '0'; + deg = match.groups.d || '0'; + min = match.groups.m || '0'; + sec = match.groups.s || '0'; d = (+deg) + (+min) / 60 + (+sec) / 3600; - if (/[sw-]/i.test(match[1]) || /[sw]/i.test(match[5])) { + if (/[sw-]/i.test(match.groups.prefix || '') || /[sw]/i.test(match.groups.suffix || '')) { d = -d; } } return d; } +var cache = {}; +function getParseRxp(fmt) { + if (fmt in cache) return cache[fmt]; + var rxp = fmt; + rxp = rxp.replace('[-]', '(?-)?'); // optional - + rxp = rxp.replace(/\[[NSEW, +-]{2,}\]/, '(?$&)'); + rxp = rxp.replace(/(MM?)(\.M+)?/, (m, g1, g2) => { + var s = g1.length == 1 ? '[0-9]+' : '[0-9][0-9]'; + if (g2) s += `\\.[0-9]+`; + return `(?${s})`; + }); + rxp = rxp.replace(/(SS?)(\.S+)?/, (m, g1, g2) => { + var s = g1.length == 1 ? '[0-9]+' : '[0-9][0-9]'; + if (g2) s += `\\.[0-9]+`; + return `(?${s})`; + }); + rxp = rxp.replace(/D+/, '(?[0-9]+)'); + rxp = '^' + rxp + '$'; + try { + // TODO: make sure all DMS codes have been matched + cache[fmt] = new RegExp(rxp); + } catch(e) { + stop('Invalid DMS format string:', fmt); + } + return cache[fmt]; +} + +function formatNumber(val, integers, decimals) { + var str = val.toFixed(decimals); + var parts = str.split('.'); + if (parts.length > 0) { + parts[0] = parts[0].padStart(integers, '0'); + str = parts.join('.'); + } + return str; +} + +function getDecimals(fmt) { + var match = /S\.(S+)/.exec(fmt) || /M\.(M+)/.exec(fmt) || null; + return match ? match[1].length : 0; +} + +// TODO: support DD.DDDDD export function formatDMS(coord, fmt) { if (!fmt) fmt = '[-]D°M\'S.SSS'; var str = fmt; - var dms = /(D+)[^M]*(M+)[^S]*(S+)/.exec(fmt); - if (!dms) { + var dstr, mstr, sstr; + var match = /(D+)[^M]*(M+)[^S[\]]*(S+)?/.exec(fmt); + if (!match) { stop('Invalid DMS format string:', fmt); } - var decimalsMatch = /S\.(S+)/.exec(fmt); - var decimals = decimalsMatch ? decimalsMatch[1].length : 0; + var gotSeconds = !!match[3]; + var decimals = getDecimals(fmt); + var integers = gotSeconds ? match[3].length : match[2].length; var RES = Math.pow(10, decimals); - var CONV = 3600 * RES; + var CONV = gotSeconds ? 3600 * RES : 60 * RES; var r = Math.floor(Math.abs(coord) * CONV + 0.5); - var sstr = ((r / RES) % 60).toFixed(decimals); - var sparts = sstr.split('.'); - if (sparts.length > 0) { - sparts[0] = sparts[0].padStart(dms[3].length, '0'); - sstr = sparts.join('.'); + var lastPart = formatNumber((r / RES) % 60, integers, decimals); + if (gotSeconds) { + r = Math.floor(r / (RES * 60)); + sstr = lastPart; + mstr = String(r % 60).padStart(match[2].length, '0'); + } else { + r = Math.floor(r / RES); + mstr = lastPart; + sstr = ''; } - r = Math.floor(r / (RES * 60)); - var dstr = String(Math.floor(r / 60)).padStart(dms[1].length, '0'); - var mstr = String(r % 60).padStart(dms[2].length, '0'); - // console.log(deg, dstr, min, mstr, sec, sstr, dms[1].length, dms[2].length, dms[3].length) - + dstr = String(Math.floor(r / 60)).padStart(match[1].length, '0'); str = str.replace(/\[-\]/, s => coord < 0 ? '-' : ''); str = str.replace(/\[[+-]+\]/, s => coord < 0 ? '-' : '+'); str = str.replace(/\[[NS, ]+\]/, s => coord < 0 ? 'S' : 'N'); str = str.replace(/\[[EW, ]+\]/, s => coord < 0 ? 'W' : 'E'); str = str.replace(/D+/, dstr); - str = str.replace(/M+/, mstr); - str = str.replace(/S+(\.S+)?/, sstr); + str = str.replace(/M+(\.M+)?/, mstr); + if (gotSeconds) str = str.replace(/S+(\.S+)?/, sstr); return str; } diff --git a/src/join/mapshaper-point-point-join.mjs b/src/join/mapshaper-point-point-join.mjs index 62bad521..b2632f07 100644 --- a/src/join/mapshaper-point-point-join.mjs +++ b/src/join/mapshaper-point-point-join.mjs @@ -18,3 +18,13 @@ function getPointToPointFunction(targetLyr, srcLyr, crs, opts) { return matches.length > 0 ? matches : null; }; } + +function getNearestPointFunction(targetLyr, srcLyr, crs, opts) { + +} + +function getInverseNearestPointFunction(targetLyr, srcLyr, crs, opts) { + + +} + diff --git a/src/utils/mapshaper-logging.mjs b/src/utils/mapshaper-logging.mjs index 3b1726a4..99c1347f 100644 --- a/src/utils/mapshaper-logging.mjs +++ b/src/utils/mapshaper-logging.mjs @@ -51,7 +51,8 @@ export function error() { // Handle an error caused by invalid input or misuse of API export function stop() { - _stop.apply(null, utils.toArray(arguments)); + // _stop.apply(null, utils.toArray(arguments)); + _stop.apply(null, messageArgs(arguments)); } export function interrupt() { diff --git a/test/dms-test.mjs b/test/dms-test.mjs index f39dcaf0..73fd8438 100644 --- a/test/dms-test.mjs +++ b/test/dms-test.mjs @@ -7,6 +7,58 @@ var formatDMS = api.internal.formatDMS; describe('mapshaper-dms.js', function () { + describe('roundtrip tests', function() { + function tests(fmt, isLon) { + function test(coord) { + var dms = formatDMS(coord, fmt); + var coord2 = parseDMS(dms, fmt); + var dms2 = formatDMS(coord2, fmt); + // console.log(dms, dms2) + assert.equal(dms, dms2); + } + + it(fmt, function() { + test(0); + test(89.99452); + test(-89.99452); + test(-3.03539843345); + test(-37.49999999999); + test(10.00000001); + test(10.0000001); + test(10.000001); + test(10.00001); + test(10.0001); + test(10.001); + test(10.005); + test(10.049); + if (isLon) { + test(180); + test(-180); + test(-179.999); + test(-100.45140975209458); + } + var n = 0; // 100000; + while (n--) { + test((Math.random() - 0.5) * 180); + } + }) + } + + tests('[-]DDMMSS'); + tests('DD° MM′ SS.SSSSS″ [N S]'); + tests('[+-]DDMM.MMMMM'); + tests('DDD° MM\' SS.S"'); + }); + + + describe('format_dms() expression function', function() { + it ('test1', async function() { + var data = 'lat,lon\n' + + + }) + }) + describe('formatDMS()', function() { // verified by var fmt1 = 'DDMMSS[WE]'; @@ -18,9 +70,9 @@ describe('mapshaper-dms.js', function () { assert.equal(formatDMS(-179.99999, fmt1), '1800000W') }); - var fmt2 = 'DDMMSS.SSS'; + var fmt2 = '[-]DDMMSS.SSS'; it(fmt2, function() { - assert.equal(formatDMS(32.0451, fmt2), '320242.360') + assert.equal(formatDMS(-32.0451, fmt2), '-320242.360') }) var fmt3 = '[+-]DdMmSs'; @@ -28,8 +80,21 @@ describe('mapshaper-dms.js', function () { assert.equal(formatDMS(32.0451, fmt3), '+32d2m42s') assert.equal(formatDMS(-32.0451, fmt3), '-32d2m42s') }) + + var fmt4 = 'DD° MM′ SS.SSSSS″ [N S]'; + it(fmt4, function() { + assert.equal(formatDMS(149.128684, fmt4), "149° 07′ 43.26240″ N") + assert.equal(formatDMS(-35.282000, fmt4), "35° 16′ 55.20000″ S") + }) + + var fmt5 = 'DDD° MM.MMM\' [SN]'; + it(fmt5, function() { + assert.equal(formatDMS(32.30642, fmt5), "032° 18.385' N") + assert.equal(formatDMS(-122.61458, fmt5), "122° 36.875' S") + }) }) + describe('parseDMS()', function () { // references for format variations // https://www.maptools.com/tutorials/lat_lon/formats @@ -76,6 +141,11 @@ describe('mapshaper-dms.js', function () { assert.equal(parseDMS('+122.61458°'), 122.61458) }); + var fmt1 = '[-]DDDMMSS.SSS'; + it(fmt1, function() { + assert.equal(parseDMS('0d0m0sE', 'DdMmSs[EW]'), 0); + }) + it('invalid DMS values', function () { assert(isNaN(parseDMS('0x'))); assert(isNaN(parseDMS('')));