From 2b000d31dc94ff4be0203a5dc3b22365882b5692 Mon Sep 17 00:00:00 2001 From: alexcojocaru Date: Mon, 13 Nov 2023 16:10:43 -0800 Subject: [PATCH] change the algorithm to get more exact calculations --- README.md | 33 +- src/index.js | 254 +++++----- test/index.test.js | 1204 ++++++++++++++++++++++++-------------------- 3 files changed, 793 insertions(+), 698 deletions(-) diff --git a/README.md b/README.md index f3e4d45..02b44f1 100644 --- a/README.md +++ b/README.md @@ -15,18 +15,15 @@ as well as a `summary` property set to `gradient`. See the [example below](#feature-collection-example) for details. The elevation grade levels are defined -[here](https://github.com/alexcojocaru/geo-data-exchange/blob/master/src/index.js#L45-L56). - -Since the elevation coordinate on geo points is usually not very accurate, -the elevations are normalized before points are grouped; -the normalization process allows a certain degree of configuration via -[options](#transformation-options). -After grouping, the elevation interpolation is applied, if enabled via options: -each point which lacks the elevation coordinate -will have its elevation set using a process which looks at the elevation -of its closest neighbours with elevation in the track and tries to estimate the elevation -on the current point, considering a constant gradient between these points - -this is not an accurate technique, and its results could be very far from reality. +[here](https://github.com/alexcojocaru/geo-data-exchange/blob/master/src/index.js#L24-L35). + +Since it is quite possible that some points on the GPX track do not have an elevation +coordinate, the track grades are calculated using only the points with elevation. +After this, the elevation interpolation is applied (if enabled via options) as follows: +given a point _B_ without elevation between two points _A_ and _C_ with elevation, +the algorithm calculates the gradient _GR_ between _A_ and _C_, and interpolates the elevation +for _B_, so that the gradient between _A_ and _B_ is _GR_ and between _B_ and _C_ is also _GR_. +This is not an accurate technique, and its results could be far from reality. This is an example of how such a FeatureCollection looks like. More examples of such feature collections can be found in the @@ -72,8 +69,8 @@ looks like. More examples of such feature collections can be found in the }] ``` -The initial goal of this project was to make possible the generation of feature collections, using -the elevation grade level as grouping criterion. +The initial goal of this project was to enable the generation of feature collections, +using the elevation grade level for grouping. Here is an example of using such a feature collection to mark various grade levels on an elevation graph: heightgraph @@ -91,18 +88,18 @@ First, add this module as a dependency to your project: ``` $ npm install alexcojocaru/geo-data-exchange ``` -or, if you want a specific version/commit/branch (e.g. v1.1.0): +or, if you want a specific version/commit/branch (e.g. v2.1.0): ``` -$ npm install alexcojocaru/geo-data-exchange#v1.1.0 +$ npm install alexcojocaru/geo-data-exchange#v2.1.0 ``` The main function is `buildGeojsonFeatures(latLngs, options)` -(documentation [here](https://github.com/alexcojocaru/geo-data-exchange/blob/master/src/index.js#L41-L79)), +(documentation [here](https://github.com/alexcojocaru/geo-data-exchange/blob/master/src/index.js#L20-L57)), exposed externally as `exports.buildGeojsonFeatures`, which takes an array of Leaflet LatLng `objects`, as well as an optional `options` object. There is a set of default transformation options -(documentation [here](https://github.com/alexcojocaru/geo-data-exchange/blob/master/src/index.js#L10-L38)), +(documentation [here](https://github.com/alexcojocaru/geo-data-exchange/blob/master/src/index.js#L10-L17)), exposed externally as `exports.defaultOptions`, which could be used as prototype for creating custom options to pass to the transformation function. diff --git a/src/index.js b/src/index.js index c23b935..62251cc 100644 --- a/src/index.js +++ b/src/index.js @@ -8,27 +8,6 @@ exports.internal = internal; var defaultOptions = { - // the track will be broken up into this many segments, - // for the purpose of normalizing the elevation across each segment; - // a high segment count, with each segment long enough, is the best choice; - // too short segments results in poor normalization, due to inacurate altitudes for - // points close to each other; - // too few segments results in poor gradient, - // due to ignoring the altitude on points in the middle; - // min value is 10 - segments: 200, - - // the minimum distance (in meters) between points which we consider - // for the purpose of calculating altitudes and gradients; - // this comes into play for short routes, in order to make sure - // we still have enough of a distance for each segment to normalize over; - // for long routes, the segment distance will be longer than this, - // thus increasing the accuracy; - // too short of a segment would result in poor normalization, - // for there won't be enough points on each segment to get a good altitude aproximation; - // min value is 50 - minSegmentDistance: 200, - // whether or not to compensate for the bug in Leaflet.Heightgraph // which makes the chart rendering to break if the track contains points // without an elevation/altitude coordinate; @@ -72,59 +51,18 @@ * } * } * - * @param {LatLng[]} latLngs - an array of LatLng objects, guaranteed to be not empty + * @param {LatLng[]} latLngs - an array of LatLng objects, guaranteed not to be empty * @param options - a set of options for building the GeoJSON feature collection; * it defaults to defaultOptions if not provided */ function buildGeojsonFeatures(latLngs, options) { var _options = typeof(options) === 'undefined' ? defaultOptions : options; - var segmentsCount = _options.segmentsCount || defaultOptions.segments; - if (segmentsCount < 10) { - segmentsCount = 10; - } - - var minSegmentDistance = _options.minSegmentDistance || defaultOptions.minSegmentDistance; - if (minSegmentDistance < 50) { - minSegmentDistance = 50; - } - var interpolate = typeof(_options.interpolateElevation) === 'undefined' ? defaultOptions.interpolateElevation : _options.interpolateElevation; - // since the altitude coordinate on geo points is not very reliable, let's normalize it - // by taking into account only the altitude on points at a given min distance - var totalDistance = _calculateDistance(latLngs); - var bufferMinDistance = Math.max(totalDistance / segmentsCount, minSegmentDistance); - - var segments = _partitionByMinDistance(latLngs, bufferMinDistance); - - var features = []; - - // this is going to be initialized in the first loop, no need to initialize now - var currentFeature; - - // undefined is fine, as it will be different to the current gradient in the first loop - var previousGradient; - - segments.forEach(function(segment) { - var currentGradient = _calculateGradient(segment); - - if (currentGradient == previousGradient) { - // the gradient hasn't changed, we can append this segment to the last feature; - // since the segment contains, at index 0, - // the last point on the current feature, add only points from index 1 onward - _addPointsToFeature(currentFeature, segment.slice(1), interpolate); - } else { - // the gradient has changed; create a new feature - currentFeature = _buildFeature(segment, currentGradient, interpolate); - features.push(currentFeature); - } - - // reset to prepare for the next iteration - previousGradient = currentGradient; - }); + var features = _buildFeatures(latLngs, interpolate); return [ { @@ -141,70 +79,117 @@ exports.buildGeojsonFeatures = buildGeojsonFeatures; /** - * Given the list of latLng points, partition them into segments - * at least _minDistance__ meters long, - * where the first and last points on each segment always have a valid altitude. - * NOTE: Given that some of the given points might not have a valid altitude, - * the first point(s) in the first buffer, as well as the last point(s) - * in the last buffer, might not have a valid altitude. + * Convert the list of LatLng points to a list of elevation features. + * + * @param {LatLng[]} latLngs - an array of LatLng objects, guaranteed not to be null + * @param Boolean interpolate - whether to interpolate the altitude on points without it */ - function _partitionByMinDistance(latLngs, minDistance) { - var segments = []; + function _buildFeatures(latLngs, interpolate) { + var features = []; - // temporary buffer where we add points - // until the distance between them is at least minDistance - var buffer = []; + if (latLngs.length === 0) { + return features; + } - // push all points up to (and including) the first one with a valid altitude - var index = 0; - for (; index < latLngs.length; index++) { - var latLng = latLngs[index]; - buffer.push(latLng); - if (typeof latLng.alt !== "undefined") { - break; - } + var latLngAlts = _filterCoordinatesWithAltitude(latLngs); + + if (latLngAlts.length < 2) { + features.push(_buildFeature(latLngs, _calculateGradient([]), interpolate)); + return features; + } + + // make a feature with all points without altitude at the start of the list + if (latLngAlts[0].index > 0) { + var points = latLngs.slice(0, latLngAlts[0].index + 1); // include this point + features.push(_buildFeature(points, _calculateGradient([]), interpolate)); } - // since the segments are used for gradient calculation (hence alt is needed), - // consider 0 length so far; - // that's because all points so far, except for the last one, don't have an altitude - var bufferDistance = 0; - - // since index was already used, start at the next one - for (index = index + 1; index < latLngs.length; index++) { - var latLng = latLngs[index]; - buffer.push(latLng); // the buffer contains at least 2 points by now - bufferDistance = - bufferDistance + - // never negative - buffer[buffer.length - 1].distanceTo(buffer[buffer.length - 2]); - - // if we reached the tipping point, add the buffer to segments, then flush it; - // if this point doesn't have a valid alt, continue to the next one - if (bufferDistance >= minDistance && typeof latLng.alt !== "undefined") { - segments.push(buffer); - // re-init the buffer with the last point from the previous buffer - buffer = [buffer[buffer.length - 1]]; - bufferDistance = 0; + var previousGradient = _calculateGradient(latLngAlts.slice(0, 2).map((lla) => lla.point)); + var startIndex = 0; + for (var i = 2; i < latLngAlts.length; i++) { + var gradient = _calculateGradient(latLngAlts.slice(i-1, i+1).map((lla) => lla.point)); + + if (previousGradient != gradient) { + var startOfFeature = latLngAlts[startIndex]; + var endOfFeature = latLngAlts[i-1]; + var points = latLngs.slice(startOfFeature.index, endOfFeature.index + 1); + features.push(_buildFeature(points, previousGradient, interpolate)); + + previousGradient = gradient; + startIndex = i - 1; } } - // if the buffer is not empty, add all points from it (except for the first one) - // to the last segment - if (buffer.length > 0) { - if (segments.length === 0) { - segments.push(buffer); - } else { - var lastSegment = segments[segments.length - 1]; - for (var i = 1; i < buffer.length; i++) { - lastSegment.push(buffer[i]); + var lastLatLngAlt = latLngAlts.slice(-1)[0]; + + // make a new feature until the last point with altitude + // (because the trailing points without altitude need a separate feature with 0 gradient) + var points = latLngs.slice(latLngAlts[startIndex].index, lastLatLngAlt.index + 1); + features.push(_buildFeature(points, previousGradient, interpolate)); + + // make a new feature with the trailing points which don't have an altitude + if (lastLatLngAlt.index < latLngs.length - 1) { + var trailingPoints = latLngs.slice(lastLatLngAlt.index, latLngs.length); + features.push(_buildFeature(trailingPoints, _calculateGradient([]), interpolate)); + } + + return features; + } + internal._buildFeatures = _buildFeatures; + + /** + * Return a new array with only the points with altitude, + * mapped to their index in the initial array. + * The points without altitude, and the points which fall within fuzzy range of each other, + * are not included. + */ + function _filterCoordinatesWithAltitude(latLngs) { + var latLngAlts = []; + + for (var i = 0; i < latLngs.length; i++) { + var point = latLngs[i]; + if (_hasAltitude(point) == false) { + continue; + } + + if (latLngAlts.length > 0) { + var last = latLngAlts.slice(-1)[0]; + if (_isInFuzzyRange(last.point, point)) { + continue; } } + + latLngAlts.push({ + point: point, + index: i + }); } - return segments; - }; - internal._partitionByMinDistance = _partitionByMinDistance; + return latLngAlts; + } + internal._filterCoordinatesWithAltitude = _filterCoordinatesWithAltitude; + + /** + * Return true if the given point has an altitude coordinate, false otherwise. + */ + function _hasAltitude(point) { + return typeof point.alt !== "undefined"; + } + internal._hasAltitude = _hasAltitude; + + /** + * Return true if the second point is within fuzzy range of the first point. + * + * https://www2.jpl.nasa.gov/srtm/ + * https://wiki.openstreetmap.org/wiki/SRTM + * + * @param LatLng reference - the reference point + * @param LatLng point - the point to test for fuzzy range + */ + function _isInFuzzyRange(reference, point) { + return _calculateDistance([reference, point]) < 30; + } + internal._isInFuzzyRange = _isInFuzzyRange; /** * Calculate the distance between all LatLng points in the given array. @@ -231,7 +216,7 @@ // find the index of the first point with a valid altitude var firstIndex = -1; for (var i = 0; i < latLngs.length; i++) { - if (typeof latLngs[i].alt !== "undefined") { + if (_hasAltitude(latLngs[i])) { firstIndex = i; break; } @@ -244,7 +229,7 @@ // find the index of the last point with a valid altitude var lastIndex = -1; for (var i = latLngs.length - 1; i > firstIndex; i--) { - if (typeof latLngs[i].alt !== "undefined") { + if (_hasAltitude(latLngs[i])) { lastIndex = i; break; } @@ -268,29 +253,13 @@ }; internal._calculateGradient = _calculateGradient; - /** - * Add the given array of LatLng points to the end of the provided feature. - */ - function _addPointsToFeature(feature, latLngs, interpolate) { - var latLngsWithElevation = _interpolateElevation(latLngs, interpolate); - latLngsWithElevation.forEach(function(point) { - var coordinate = [point.lng, point.lat, point.alt]; - feature.geometry.coordinates.push(coordinate); - }); - }; - function _buildFeature(latLngs, gradient, interpolate) { var latLngsWithElevation = _interpolateElevation(latLngs, interpolate); - var coordinates = []; - latLngsWithElevation.forEach(function(latLng) { - coordinates.push([latLng.lng, latLng.lat, latLng.alt]); - }); - return { type: "Feature", geometry: { type: "LineString", - coordinates: coordinates + coordinates: latLngsWithElevation.map((p) => [p.lng, p.lat, p.alt]) }, properties: { attributeType: gradient @@ -320,7 +289,7 @@ // find points without elevation and set it var previousIndex = -1; // keep track of the index of the previous point with elevation for (var i = 0; i < result.length; i++) { - if (typeof result[i].alt !== "undefined") { + if (_hasAltitude(result[i])) { previousIndex = i; continue; } @@ -336,7 +305,7 @@ // look up the next point with ele var nextIndex = i + 1; for (; nextIndex < result.length; nextIndex++) { - if (typeof result[nextIndex].alt !== "undefined") { + if (_hasAltitude(result[nextIndex])) { break; } } @@ -349,7 +318,8 @@ // fix the elevation on all points in the current series _interpolateElevationSublist(result, previousIndex + 1, nextIndex - 1); - // finally, since we've fixed the current series, skip to the next point + // finally, since we've fixed the current series, + // skip to the next point with elevation i = nextIndex - 1; } @@ -366,14 +336,14 @@ */ function _interpolateElevationStart(latLngs) { // if the list is empty, or the first point has elevation, there's nothing to do - if (latLngs.length == 0 || typeof latLngs[0].alt !== "undefined") { + if (latLngs.length == 0 || _hasAltitude(latLngs[0])) { return; } var index = 0; // the index of the first point with elevation var alt = 0; // the elevation of the first point with such coordinate for (; index < latLngs.length; index++) { - if (typeof latLngs[index].alt !== "undefined") { + if (_hasAltitude(latLngs[index])) { alt = latLngs[index].alt; break; } @@ -394,14 +364,14 @@ */ function _interpolateElevationEnd(latLngs) { // if the list is empty, or the last point has elevation, there's nothing to do - if (latLngs.length == 0 || typeof latLngs[latLngs.length - 1].alt !== "undefined") { + if (latLngs.length == 0 || _hasAltitude(latLngs[latLngs.length - 1])) { return; } var index = latLngs.length - 1; // the index of the last point with elevation var alt = 0; // the elevation of the last point with such coordinate for (; index >= 0; index--) { - if (typeof latLngs[index].alt !== "undefined") { + if (_hasAltitude(latLngs[index])) { alt = latLngs[index].alt; break; } diff --git a/test/index.test.js b/test/index.test.js index 1978639..ffcb1f3 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -3,171 +3,19 @@ require("leaflet"); describe("geo-data-exchange", () => { - test("build features - empty points list", () => { - var latLngs = []; - - var result = exchange.buildGeojsonFeatures(latLngs); - - expect(result).toEqual( - [{ - type: "FeatureCollection", - features: [], - properties: { - Creator: "github.com/alexcojocaru/geo-data-exchange", - records: 0, - summary: "gradient" - } - }] - ); - }); - - test("build features - no options provided", () => { - var latLngs = [ - L.latLng(1.111, 2.221, 1), - L.latLng(1.113, 2.223), - // the altitude on the above will not get interpolated, - // hence the point will count for distance calculation - L.latLng(1.115, 2.225, 5), - // > 200m so far; current gradient is 0; restart with the last - L.latLng(1.117, 2.227, 70) - // > 200m so far; new segment, for the current gradient is 5 - ]; - expect(exchange.buildGeojsonFeatures(latLngs)) - .toEqual([{ - "features": [ - { - "geometry": { - "coordinates": [ - [2.221, 1.111, 1], - [2.223, 1.113, undefined], - [2.225, 1.115, 5] - ], - "type": "LineString" - }, - "properties": { - "attributeType": 0 - }, - "type": "Feature" - }, - { - "geometry": { - "coordinates": [ - [2.225, 1.115, 5], - [2.227, 1.117, 70] - ], - "type": "LineString" - }, - "properties": { - "attributeType": 5 - }, - "type": "Feature" - } - ], - "properties": { - "Creator": "github.com/alexcojocaru/geo-data-exchange", - "records": 2, - "summary": "gradient" - }, - "type": "FeatureCollection" - }]); - }); - - test("build features - all options provided", () => { - var latLngs = [ - L.latLng(1.108, 2.218), - // the altitude on this will get interpolated to 8 - L.latLng(1.110, 2.220, 8), - // > 300m so far, but previous point has interpolated altitude; no new segment - L.latLng(1.113, 2.223, 10), - // > 300m so far; new segment; gradient = 0; restart with last point - L.latLng(1.116, 2.226), - // < 300m so far; the elevation will get interpolated to 760 - L.latLng(1.117, 2.227, 1010), - // > 300m so far; new segment; gradient = 5; restart with last point - L.latLng(1.127, 2.237, 1011) - // > 300m so far; new segment; gradient = 0 - ]; - var options = { - segments: 100, - minSegmentDistance: 300, - interpolateElevation: true - }; - var result = exchange.buildGeojsonFeatures(latLngs, options); - - expect(result).toEqual([{ - "features": [ - { - "geometry": { - "coordinates": [ - [2.218, 1.108, 8], - [2.220, 1.110, 8], - [2.223, 1.113, 10] - ], - "type": "LineString" - }, - "properties": { - "attributeType": 0 - }, - "type": "Feature" - }, - { - "geometry": { - "coordinates": [ - [2.223, 1.113, 10], - [2.226, 1.116, 760], - [2.227, 1.117, 1010] - ], - "type": "LineString" - }, - "properties": { - "attributeType": 5 - }, - "type": "Feature" - }, - { - "geometry": { - "coordinates": [ - [2.227, 1.117, 1010], - [2.237, 1.127, 1011], - ], - "type": "LineString" - }, - "properties": { - "attributeType": 0 - }, - "type": "Feature" - } - ], - "properties": { - "Creator": "github.com/alexcojocaru/geo-data-exchange", - "records": 3, - "summary": "gradient" - }, - "type": "FeatureCollection" - }]); - }); - - test("build features - some options provided", () => { - var latLngs = [ - L.latLng(1.108, 2.218), - // the altitude on this will get interpolated to 8 - L.latLng(1.110, 2.220, 8), - // > 200m so far, but previous point has interpolated altitude; no new segment - L.latLng(1.112, 2.222) - ]; - var options = { - interpolateElevation: true - }; - var result = exchange.buildGeojsonFeatures(latLngs, options); - - expect(result).toEqual([{ + test("build geo json features - missing options", () => { + expect( + exchange.buildGeojsonFeatures([ + L.latLng(1, 11), + L.latLng(2, 22, 222) + ]) + ).toEqual([{ "features": [ { "geometry": { "coordinates": [ - [2.218, 1.108, 8], - [2.220, 1.110, 8], - [2.222, 1.112, 8] + [11, 1, undefined], + [22, 2, 222] ], "type": "LineString" }, @@ -186,29 +34,23 @@ describe("geo-data-exchange", () => { }]); }); - test("build features - enforce minSegmentDistance", () => { - var latLngs = [ - L.latLng(1.108, 2.218, 8), - L.latLng(1.1085, 2.2185, 8), - // 10m apart (> 10m, but < 50 min enforced), no new segment - L.latLng(1.110, 2.220, 10), - // > 50m so far, new segment; gradient = 0 - L.latLng(1.112, 2.222, 20) - // > 50m so far, new segment; gradient = 1 - ]; - var options = { - minSegmentDistance: 10 - }; - var result = exchange.buildGeojsonFeatures(latLngs, options); - - expect(result).toEqual([{ + test("build geo json features - interpolate elevation", () => { + expect( + exchange.buildGeojsonFeatures( + [ + L.latLng(1, 11), + L.latLng(2, 22, 222), + L.latLng(2.1, 22.1, 2220), + ], + { interpolateElevation: true } + ) + ).toEqual([{ "features": [ { "geometry": { "coordinates": [ - [2.218, 1.108, 8], - [2.2185, 1.1085, 8], - [2.220, 1.110, 10] + [11, 1, 222], + [22, 2, 222] ], "type": "LineString" }, @@ -220,13 +62,13 @@ describe("geo-data-exchange", () => { { "geometry": { "coordinates": [ - [2.220, 1.110, 10], - [2.222, 1.112, 20] + [22, 2, 222], + [22.1, 2.1, 2220] ], "type": "LineString" }, "properties": { - "attributeType": 1 + "attributeType": 4 }, "type": "Feature" } @@ -240,396 +82,682 @@ describe("geo-data-exchange", () => { }]); }); - test("build features - union segments with same gradient", () => { - var latLngs = [ - L.latLng(1.108, 2.218, 8), - L.latLng(1.110, 2.220, 9), - // > 200m so far, new segment; gradient = 0 - L.latLng(1.112, 2.222, 12), - // > 200m so far, new segment; gradient = 0 - L.latLng(1.114, 2.224, 20), - // > 200m so far, new segment; gradient = 1 - L.latLng(1.117, 2.227, 22) - // > 200m so far, new segment; gradient = 0 - ]; - var result = exchange.buildGeojsonFeatures(latLngs); + test("build features - empty points list", () => { + expect(exchange.internal._buildFeatures([], false)).toEqual([]); + }); - expect(result).toEqual([{ - "features": [ - { - "geometry": { - "coordinates": [ - [2.218, 1.108, 8], - [2.220, 1.110, 9], - [2.222, 1.112, 12] - ], - "type": "LineString" - }, - "properties": { - "attributeType": 0 - }, - "type": "Feature" + test("build features - one point list", () => { + expect( + exchange.internal._buildFeatures( + [ + L.latLng(1, 11) + ], + false + ) + ).toEqual([ + { + "geometry": { + "coordinates": [ + [11, 1, undefined] + ], + "type": "LineString" }, - { - "geometry": { - "coordinates": [ - [2.222, 1.112, 12], - [2.224, 1.114, 20] - ], - "type": "LineString" - }, - "properties": { - "attributeType": 1 - }, - "type": "Feature" + "properties": { + "attributeType": 0 }, - { - "geometry": { - "coordinates": [ - [2.224, 1.114, 20], - [2.227, 1.117, 22] - ], - "type": "LineString" - }, - "properties": { - "attributeType": 0 - }, - "type": "Feature" - } - ], - "properties": { - "Creator": "github.com/alexcojocaru/geo-data-exchange", - "records": 3, - "summary": "gradient" - }, - "type": "FeatureCollection" - }]); + "type": "Feature" + } + ]); }); - // - // Partition by min distance - // - - test("partition by min distance - no points", () => { - expect(exchange.internal._partitionByMinDistance([], 100)).toEqual([]); + test("build features - one point list - interpolate", () => { + expect( + exchange.internal._buildFeatures( + [ + L.latLng(1, 11) + ], + true + ) + ).toEqual([ + { + "geometry": { + "coordinates": [ + [11, 1, 0] + ], + "type": "LineString" + }, + "properties": { + "attributeType": 0 + }, + "type": "Feature" + } + ]); }); - test("partition by min distance - less than min distance, all points without altitude", () => { - var latLngs = [ - L.latLng(1.11111, 2.22221), - L.latLng(1.11112, 2.22222), - L.latLng(1.11113, 2.22223), - L.latLng(1.11114, 2.22224) - ]; - // the points are about 3m apart - expect(exchange.internal._partitionByMinDistance(latLngs, 100)) - .toEqual([ - [ - L.latLng(1.11111, 2.22221), - L.latLng(1.11112, 2.22222), - L.latLng(1.11113, 2.22223), - L.latLng(1.11114, 2.22224) - ] - ]); + test("build features - all points without altitude", () => { + expect( + exchange.internal._buildFeatures( + [ + L.latLng(1, 11), + L.latLng(2, 22) + ], + false + ) + ).toEqual([ + { + "geometry": { + "coordinates": [ + [11, 1, undefined], + [22, 2, undefined] + ], + "type": "LineString" + }, + "properties": { + "attributeType": 0 + }, + "type": "Feature" + } + ]); }); - test("partition by min distance - more than min distance, all points without altitude", () => { - var latLngs = [ - L.latLng(1.111, 2.221), - L.latLng(1.112, 2.222), - L.latLng(1.113, 2.223), - L.latLng(1.114, 2.224), - L.latLng(1.115, 2.225) - ]; - // the points are about 630m apart - expect(exchange.internal._partitionByMinDistance(latLngs, 200)) - .toEqual([ - [ - L.latLng(1.111, 2.221), - L.latLng(1.112, 2.222), - L.latLng(1.113, 2.223), - L.latLng(1.114, 2.224), - L.latLng(1.115, 2.225) - ] - ]); + test("build features - single point with altitude", () => { + expect( + exchange.internal._buildFeatures( + [ + L.latLng(1, 11), + L.latLng(2, 22), + L.latLng(3, 33, 333) + ], + false + ) + ).toEqual([ + { + "geometry": { + "coordinates": [ + [11, 1, undefined], + [22, 2, undefined], + [33, 3, 333] + ], + "type": "LineString" + }, + "properties": { + "attributeType": 0 + }, + "type": "Feature" + } + ]); }); - test("partition by min distance - less than min distance, start points without altitude", () => { - var latLngs = [ - L.latLng(1.1111, 2.2221), - L.latLng(1.1112, 2.2222, 10), - L.latLng(1.1113, 2.2223, 11), - L.latLng(1.1114, 2.2224, 12) - ]; - // the points are about 30m apart - expect(exchange.internal._partitionByMinDistance(latLngs, 100)) - .toEqual([ - [ - L.latLng(1.1111, 2.2221), - L.latLng(1.1112, 2.2222, 10), - L.latLng(1.1113, 2.2223, 11), - L.latLng(1.1114, 2.2224, 12) - ] - ]); + test("build features - single point with altitude - interpolate", () => { + expect( + exchange.internal._buildFeatures( + [ + L.latLng(1, 11), + L.latLng(3, 33, 333) + ], + true + ) + ).toEqual([ + { + "geometry": { + "coordinates": [ + [11, 1, 333], + [33, 3, 333] + ], + "type": "LineString" + }, + "properties": { + "attributeType": 0 + }, + "type": "Feature" + } + ]); }); - test("partition by min distance - less than min distance, end points without altitude", () => { - var latLngs = [ - L.latLng(1.1111, 2.2221, 9), - L.latLng(1.1112, 2.2222, 10), - L.latLng(1.1113, 2.2223, 11), - L.latLng(1.1114, 2.2224) - ]; - // the points are about 30m apart - expect(exchange.internal._partitionByMinDistance(latLngs, 100)) - .toEqual([ - [ - L.latLng(1.1111, 2.2221, 9), - L.latLng(1.1112, 2.2222, 10), - L.latLng(1.1113, 2.2223, 11), - L.latLng(1.1114, 2.2224) - ] - ]); + test("build features - starting and ending points without altitude", () => { + expect( + exchange.internal._buildFeatures( + [ + L.latLng(1, 11), + L.latLng(2, 22, 222), + L.latLng(2.1, 22.1, 2220), + L.latLng(3, 33), + ], + false + ) + ).toEqual([ + { + "geometry": { + "coordinates": [ + [11, 1, undefined], + [22, 2, 222] + ], + "type": "LineString" + }, + "properties": { + "attributeType": 0 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [22, 2, 222], + [22.1, 2.1, 2220] + ], + "type": "LineString" + }, + "properties": { + "attributeType": 4 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [22.1, 2.1, 2220], + [33, 3, undefined] + ], + "type": "LineString" + }, + "properties": { + "attributeType": 0 + }, + "type": "Feature" + } + ]); }); - test("partition by min distance - less than min distance, some points without altitude", () => { - var latLngs = [ - L.latLng(1.1111, 2.2221, 9), - L.latLng(1.1112, 2.2222), - L.latLng(1.1113, 2.2223), - L.latLng(1.1114, 2.2224, 12), - L.latLng(1.1115, 2.2225, 13) - ]; - // the points are about 30m apart - expect(exchange.internal._partitionByMinDistance(latLngs, 100)) - .toEqual([ - [ - L.latLng(1.1111, 2.2221, 9), - L.latLng(1.1112, 2.2222), - L.latLng(1.1113, 2.2223), - L.latLng(1.1114, 2.2224, 12), - L.latLng(1.1115, 2.2225, 13) - ] - ]); + test("build features - starting and ending points without altitude - interpolate", () => { + expect( + exchange.internal._buildFeatures( + [ + L.latLng(1, 11), + L.latLng(2, 22, 222), + L.latLng(2.1, 22.1, 2220), + L.latLng(3, 33) + ], + true + ) + ).toEqual([ + { + "geometry": { + "coordinates": [ + [11, 1, 222], + [22, 2, 222] + ], + "type": "LineString" + }, + "properties": { + "attributeType": 0 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [22, 2, 222], + [22.1, 2.1, 2220] + ], + "type": "LineString" + }, + "properties": { + "attributeType": 4 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [22.1, 2.1, 2220], + [33, 3, 2220] + ], + "type": "LineString" + }, + "properties": { + "attributeType": 0 + }, + "type": "Feature" + } + ]); }); - test("partition by min distance - less than min distance, all points with altitude", () => { - var latLngs = [ - L.latLng(1.1111, 2.2221, 9), - L.latLng(1.1112, 2.2222, 10), - L.latLng(1.1113, 2.2223, 11), - L.latLng(1.1114, 2.2224, 12) - ]; - // the points are about 30m apart - expect(exchange.internal._partitionByMinDistance(latLngs, 100)) - .toEqual([ - [ - L.latLng(1.1111, 2.2221, 9), - L.latLng(1.1112, 2.2222, 10), - L.latLng(1.1113, 2.2223, 11), - L.latLng(1.1114, 2.2224, 12) - ] - ]); + test("build features - single gradient - no intermediate points without altitude", () => { + expect( + exchange.internal._buildFeatures( + [ + L.latLng(1, 11, 111), + L.latLng(1.01, 11.01, 211), + L.latLng(1.02, 11.02, 311) + ], + false + ) + ).toEqual([ + { + "geometry": { + "coordinates": [ + [11, 1, 111], + [11.01, 1.01, 211], + [11.02, 1.02, 311] + ], + "type": "LineString" + }, + "properties": { + "attributeType": 2 + }, + "type": "Feature" + } + ]); }); - test("partition by min distance - more than min distance, start points without altitude", () => { - var latLngs = [ - L.latLng(1.111, 2.221), - L.latLng(1.112, 2.222), - L.latLng(1.113, 2.223, 13), - L.latLng(1.114, 2.224, 14), - L.latLng(1.115, 2.225, 15), - // distance so far is > 200m; reset and restart with last - L.latLng(1.116, 2.226, 16), - L.latLng(1.117, 2.227, 17), - L.latLng(1.118, 2.228, 18) - // distance so far is > 200; reset - ]; - expect(exchange.internal._partitionByMinDistance(latLngs, 200)) - .toEqual([ - [ - L.latLng(1.111, 2.221), - L.latLng(1.112, 2.222), - L.latLng(1.113, 2.223, 13), - L.latLng(1.114, 2.224, 14), - L.latLng(1.115, 2.225, 15) + test("build features - single gradient - intermediate points without altitude", () => { + expect( + exchange.internal._buildFeatures( + [ + L.latLng(1, 11, 111), + L.latLng(1.002, 11.002), + L.latLng(1.005, 11.005), + L.latLng(1.01, 11.01, 211), + L.latLng(1.015, 11.015), + L.latLng(1.02, 11.02, 311), + ], + false + ) + ).toEqual([ + { + "geometry": { + "coordinates": [ + [11, 1, 111], + [11.002, 1.002, undefined], + [11.005, 1.005, undefined], + [11.01, 1.01, 211], + [11.015, 1.015, undefined], + [11.02, 1.02, 311] ], - [ - L.latLng(1.115, 2.225, 15), - L.latLng(1.116, 2.226, 16), - L.latLng(1.117, 2.227, 17), - L.latLng(1.118, 2.228, 18) - ] - ]); + "type": "LineString" + }, + "properties": { + "attributeType": 2 + }, + "type": "Feature" + } + ]); }); - test("partition by min distance - more than min distance, too many start points without altitude", () => { - var latLngs = [ - L.latLng(1.111, 2.221), - L.latLng(1.112, 2.222), - L.latLng(1.113, 2.223), - L.latLng(1.114, 2.224), - L.latLng(1.115, 2.225, 15), - L.latLng(1.116, 2.226, 16), - L.latLng(1.117, 2.227, 17), - L.latLng(1.118, 2.228, 18) - ]; - // distance is much more than 500m, but < 500m between points with altitude - expect(exchange.internal._partitionByMinDistance(latLngs, 500)) - .toEqual([ - [ - L.latLng(1.111, 2.221), - L.latLng(1.112, 2.222), - L.latLng(1.113, 2.223), - L.latLng(1.114, 2.224), - L.latLng(1.115, 2.225, 15), - L.latLng(1.116, 2.226, 16), - L.latLng(1.117, 2.227, 17), - L.latLng(1.118, 2.228, 18) - ] - ]); + test("build features - single gradient - intermediate points without altitude - interpolate", () => { + expect( + exchange.internal._buildFeatures( + [ + L.latLng(1, 11, 111), + L.latLng(1.002, 11.002), + L.latLng(1.005, 11.005), + L.latLng(1.01, 11.01, 211) + ], + true + ) + ).toEqual([ + { + "geometry": { + "coordinates": [ + [11, 1, 111], + [11.002, 1.002, 131], + [11.005, 1.005, 161], + [11.01, 1.01, 211] + ], + "type": "LineString" + }, + "properties": { + "attributeType": 2 + }, + "type": "Feature" + } + ]); }); - test("partition by min distance - more than min distance, end points without altitude", () => { - var latLngs = [ - L.latLng(1.111, 2.221, 11), - L.latLng(1.112, 2.222, 12), - L.latLng(1.113, 2.223, 13), - // distance so far is > 200m; reset and restart with last - L.latLng(1.114, 2.224, 14), - L.latLng(1.115, 2.225, 15), - // distance so far is > 200m; reset - L.latLng(1.116, 2.226), - L.latLng(1.117, 2.227) - // last 2 points will get appended to the last segment - ]; - expect(exchange.internal._partitionByMinDistance(latLngs, 200)) - .toEqual([ - [ - L.latLng(1.111, 2.221, 11), - L.latLng(1.112, 2.222, 12), - L.latLng(1.113, 2.223, 13) + test("build features - multi gradients - no intermediate points without altitude", () => { + expect( + exchange.internal._buildFeatures( + [ + L.latLng(1, 11, 111), + L.latLng(1.01, 11.01, 211), + L.latLng(1.02, 11.02, 411), + L.latLng(1.03, 11.03, 611) + ], + false + ) + ).toEqual([ + { + "geometry": { + "coordinates": [ + [11, 1, 111], + [11.01, 1.01, 211] ], - [ - L.latLng(1.113, 2.223, 13), - L.latLng(1.114, 2.224, 14), - L.latLng(1.115, 2.225, 15), - L.latLng(1.116, 2.226), - L.latLng(1.117, 2.227) - ] - ]); + "type": "LineString" + }, + "properties": { + "attributeType": 2 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [11.01, 1.01, 211], + [11.02, 1.02, 411], + [11.03, 1.03, 611] + ], + "type": "LineString" + }, + "properties": { + "attributeType": 4 + }, + "type": "Feature" + } + ]); }); - test("partition by min distance - more than min distance, too many end points without altitude", () => { - var latLngs = [ - L.latLng(1.111, 2.221, 11), - L.latLng(1.112, 2.222, 12), - L.latLng(1.113, 2.223, 13), - L.latLng(1.114, 2.224, 14), - L.latLng(1.115, 2.225), - L.latLng(1.116, 2.226), - L.latLng(1.117, 2.227) - ]; - // distance is much more than 500m, but < 500m between points with altitude - expect(exchange.internal._partitionByMinDistance(latLngs, 500)) - .toEqual([ - [ - L.latLng(1.111, 2.221, 11), - L.latLng(1.112, 2.222, 12), - L.latLng(1.113, 2.223, 13), - L.latLng(1.114, 2.224, 14), - L.latLng(1.115, 2.225), - L.latLng(1.116, 2.226), - L.latLng(1.117, 2.227) - ] - ]); + test("build features - multi gradients - intermediate points without altitude", () => { + expect( + exchange.internal._buildFeatures( + [ + L.latLng(1, 11, 111), + L.latLng(1.002, 11.002), + L.latLng(1.004, 11.004), + L.latLng(1.01, 11.01, 211), + L.latLng(1.015, 11.015), + L.latLng(1.02, 11.02, 411), + ], + false + ) + ).toEqual([ + { + "geometry": { + "coordinates": [ + [11, 1, 111], + [11.002, 1.002, undefined], + [11.004, 1.004, undefined], + [11.01, 1.01, 211] + ], + "type": "LineString" + }, + "properties": { + "attributeType": 2 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [11.01, 1.01, 211], + [11.015, 1.015, undefined], + [11.02, 1.02, 411] + ], + "type": "LineString" + }, + "properties": { + "attributeType": 4 + }, + "type": "Feature" + } + ]); }); - test("partition by min distance - more than min distance, some points without altitude", () => { - var latLngs = [ - L.latLng(1.111, 2.221, 11), - L.latLng(1.112, 2.222, 12), - // < 200m so far - L.latLng(1.113, 2.223), - // > 200m so far, but still < 200m between points with altitude - L.latLng(1.114, 2.224, 14), - // > 200m, all good; reset and restart with last - L.latLng(1.115, 2.225), - // < 200m so far - L.latLng(1.116, 2.226, 16) - // > 200m so far; reset and restart with last - ]; - expect(exchange.internal._partitionByMinDistance(latLngs, 200)) - .toEqual([ - [ - L.latLng(1.111, 2.221, 11), - L.latLng(1.112, 2.222, 12), - L.latLng(1.113, 2.223), - L.latLng(1.114, 2.224, 14) + test("build features - multi gradients - intermediate points without altitude - interpolate", () => { + expect( + exchange.internal._buildFeatures( + [ + L.latLng(1, 11, 111), + L.latLng(1.002, 11.002), + L.latLng(1.004, 11.004), + L.latLng(1.01, 11.01, 211), + L.latLng(1.015, 11.015), + L.latLng(1.02, 11.02, 411), + ], + true + ) + ).toEqual([ + { + "geometry": { + "coordinates": [ + [11, 1, 111], + [11.002, 1.002, 131], + [11.004, 1.004, 151], + [11.01, 1.01, 211] ], - [ - L.latLng(1.114, 2.224, 14), - L.latLng(1.115, 2.225), - L.latLng(1.116, 2.226, 16) - ] - ]); + "type": "LineString" + }, + "properties": { + "attributeType": 2 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [11.01, 1.01, 211], + [11.015, 1.015, 311], + [11.02, 1.02, 411] + ], + "type": "LineString" + }, + "properties": { + "attributeType": 4 + }, + "type": "Feature" + } + ]); }); - test("partition by min distance - more than min distance, all points with altitude, exact fit", () => { - var latLngs = [ - L.latLng(1.111, 2.221, 11), - L.latLng(1.112, 2.222, 12), - // < 200m so far - L.latLng(1.113, 2.223, 13), - // > 200m so far; reset and restart with the last - L.latLng(1.114, 2.224, 14), - // < 200m - L.latLng(1.115, 2.225, 15) - // > 200m; reset - ]; - expect(exchange.internal._partitionByMinDistance(latLngs, 200)) - .toEqual([ - [ - L.latLng(1.111, 2.221, 11), - L.latLng(1.112, 2.222, 12), - L.latLng(1.113, 2.223, 13) + test("build features - realistic scenario", () => { + expect( + exchange.internal._buildFeatures( + [ + L.latLng(0.999, 10.999), + L.latLng(1, 11, 111), + L.latLng(1.002, 11.002), + L.latLng(1.004, 11.004), + L.latLng(1.01, 11.01, 211), + L.latLng(1.015, 11.015), + L.latLng(1.02, 11.02, 411), + L.latLng(1.03, 11.03, 311), + L.latLng(1.04, 11.04, 311), + L.latLng(1.05, 11.05, 511), + L.latLng(1.06, 11.06), + ], + true + ) + ).toEqual([ + { + "geometry": { + "coordinates": [ + [10.999, 0.999, 111], + [11, 1, 111] ], - [ - L.latLng(1.113, 2.223, 13), - L.latLng(1.114, 2.224, 14), - L.latLng(1.115, 2.225, 15) - ] - ]); + "type": "LineString" + }, + "properties": { + "attributeType": 0 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [11, 1, 111], + [11.002, 1.002, 131], + [11.004, 1.004, 151], + [11.01, 1.01, 211] + ], + "type": "LineString" + }, + "properties": { + "attributeType": 2 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [11.01, 1.01, 211], + [11.015, 1.015, 311], + [11.02, 1.02, 411] + ], + "type": "LineString" + }, + "properties": { + "attributeType": 4 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [11.02, 1.02, 411], + [11.03, 1.03, 311] + ], + "type": "LineString" + }, + "properties": { + "attributeType": -2 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [11.03, 1.03, 311], + [11.04, 1.04, 311] + ], + "type": "LineString" + }, + "properties": { + "attributeType": 0 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [11.04, 1.04, 311], + [11.05, 1.05, 511] + ], + "type": "LineString" + }, + "properties": { + "attributeType": 4 + }, + "type": "Feature" + }, + { + "geometry": { + "coordinates": [ + [11.05, 1.05, 511], + [11.06, 1.06, 511] + ], + "type": "LineString" + }, + "properties": { + "attributeType": 0 + }, + "type": "Feature" + }, + ]); }); - test("partition by min distance - more than min distance, all points with altitude, extra trailing points", () => { - var latLngs = [ - L.latLng(1.111, 2.221, 11), - L.latLng(1.112, 2.222, 12), - // < 200m so far - L.latLng(1.113, 2.223, 13), - // > 200m so far; reset and restart with last - L.latLng(1.114, 2.224, 14), - // < 200m - L.latLng(1.115, 2.225, 15), - // > 200m; reset and restart with last - L.latLng(1.116, 2.226, 16) - // < 200m; since it's last point, append it to last segment - ]; - expect(exchange.internal._partitionByMinDistance(latLngs, 200)) - .toEqual([ - [ - L.latLng(1.111, 2.221, 11), - L.latLng(1.112, 2.222, 12), - L.latLng(1.113, 2.223, 13) - ], - [ - L.latLng(1.113, 2.223, 13), - L.latLng(1.114, 2.224, 14), - L.latLng(1.115, 2.225, 15), - L.latLng(1.116, 2.226, 16) - ] - ]); + // + // Filter coordinates with altitude + // + test("filter coordinates with altitude", () => { + expect(exchange.internal._filterCoordinatesWithAltitude([])).toEqual([]); + + expect( + exchange.internal._filterCoordinatesWithAltitude([ + L.latLng(1, 2) + ]) + ).toEqual([]); + + expect( + exchange.internal._filterCoordinatesWithAltitude([ + L.latLng(1, 1), + L.latLng(2, 2, 2), + L.latLng(3, 3) + ]) + ).toEqual([ + { point: L.latLng(2, 2, 2), index: 1 } + ]); + + expect( + exchange.internal._filterCoordinatesWithAltitude([ + L.latLng(1, 1), + L.latLng(2, 2, 2), + L.latLng(3, 3), + L.latLng(4, 4, 4), + L.latLng(5, 5) + ]) + ).toEqual([ + { point: L.latLng(2, 2, 2), index: 1 }, + { point: L.latLng(4, 4, 4), index: 3 } + ]); + + expect( + exchange.internal._filterCoordinatesWithAltitude([ + L.latLng(1, 1), + L.latLng(2, 2, 2), + L.latLng(2.0001, 2.0002, 2), // in fuzzy range + L.latLng(3, 3), + L.latLng(4, 4, 4), + L.latLng(5, 5) + ]) + ).toEqual([ + { point: L.latLng(2, 2, 2), index: 1 }, + { point: L.latLng(4, 4, 4), index: 4 } + ]); + + expect( + exchange.internal._filterCoordinatesWithAltitude([ + L.latLng(2, 2, 2), + L.latLng(2.0001, 2.0001, 2), // in fuzzy range + L.latLng(2.0001, 2.0002, 2), // in fuzzy range + ]) + ).toEqual([ + { point: L.latLng(2, 2, 2), index: 0 } + ]); + }); + + // + // Has altitude + // + test("has altitude", () => { + expect(exchange.internal._hasAltitude(L.latLng(1, 1))).toBe(false); + expect(exchange.internal._hasAltitude(L.latLng(1, 1, 1))).toBe(true); + }); + + // + // Is in fuzzy range + // + test("is in fuzzy range", () => { + // 20m + expect(exchange.internal._isInFuzzyRange( + L.latLng(1, 2), + L.latLng(1.0001, 2.0001)) + ).toBe(true); + + // 30m + expect(exchange.internal._isInFuzzyRange( + L.latLng(1, 2), + L.latLng(1.0003, 2)) + ).toBe(false); + + // 40m + expect(exchange.internal._isInFuzzyRange( + L.latLng(1.0001, 2.0001), + L.latLng(1.0003, 2.0004)) + ).toBe(false); + + expect(exchange.internal._isInFuzzyRange( + L.latLng(1, 1), + L.latLng(2, 2)) + ).toBe(false); }); //