@sports-alliance/sports-lib
Version:
A Library to for importing / exporting and processing GPX, TCX, FIT and JSON files from services such as Strava, Movescount, Garmin, Polar etc
477 lines (476 loc) • 20.6 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.RoutePreviewUtilities = exports.ROUTE_PREVIEW_DEFAULT_MAX_POINTS_PER_ROUTE = exports.ROUTE_PREVIEW_DEFAULT_MAX_POINTS_PER_SEGMENT = exports.ROUTE_PREVIEW_POLYLINE_PRECISION = exports.ROUTE_PREVIEW_ENCODING = exports.ROUTE_PREVIEW_VERSION = void 0;
exports.simplifyCoordinatePairsVisvalingamWhyatt = simplifyCoordinatePairsVisvalingamWhyatt;
exports.encodeRoutePolyline5 = encodeRoutePolyline5;
exports.decodeRoutePolyline5 = decodeRoutePolyline5;
exports.buildRoutePreviewBounds = buildRoutePreviewBounds;
exports.mergeRoutePreviewBounds = mergeRoutePreviewBounds;
const polyline_codec_1 = require("@googlemaps/polyline-codec");
exports.ROUTE_PREVIEW_VERSION = 1;
exports.ROUTE_PREVIEW_ENCODING = 'polyline5';
exports.ROUTE_PREVIEW_POLYLINE_PRECISION = 5;
exports.ROUTE_PREVIEW_DEFAULT_MAX_POINTS_PER_SEGMENT = 300;
exports.ROUTE_PREVIEW_DEFAULT_MAX_POINTS_PER_ROUTE = 1200;
class RoutePreviewUtilities {
static buildRouteFilePreview(routeFile, options = {}) {
const maxPointsPerSegment = this.normalizePointLimit(options.maxPointsPerSegment, exports.ROUTE_PREVIEW_DEFAULT_MAX_POINTS_PER_SEGMENT);
const maxPointsPerRoute = this.normalizePointLimit(options.maxPointsPerRoute, exports.ROUTE_PREVIEW_DEFAULT_MAX_POINTS_PER_ROUTE);
const sources = this.resolveRouteSources(routeFile)
.filter(source => source.validPoints.length >= 2);
if (!sources.length || maxPointsPerRoute < 2) {
return null;
}
const targetCounts = this.allocateSegmentTargetCounts(sources.map(source => source.validPoints.length), maxPointsPerSegment, maxPointsPerRoute);
const segments = sources.reduce((result, source, index) => {
var _a, _b;
const targetCount = targetCounts[index] || 0;
if (targetCount < 2) {
return result;
}
const simplifiedPoints = this.simplifyPolyline(source.validPoints, { maxPoints: targetCount });
if (simplifiedPoints.length < 2) {
return result;
}
const encodedPolyline = encodeRoutePolyline5(simplifiedPoints);
if (!encodedPolyline) {
return result;
}
result.push(this.removeUndefined({
id: source.id,
name: (_a = source.name) !== null && _a !== void 0 ? _a : null,
activityType: (_b = source.activityType) !== null && _b !== void 0 ? _b : null,
sourcePointCount: source.sourcePointCount,
pointCount: simplifiedPoints.length,
encodedPolyline,
bounds: buildRoutePreviewBounds(simplifiedPoints)
}));
return result;
}, []);
if (!segments.length) {
return null;
}
return this.removeUndefined({
version: exports.ROUTE_PREVIEW_VERSION,
encoding: exports.ROUTE_PREVIEW_ENCODING,
precision: exports.ROUTE_PREVIEW_POLYLINE_PRECISION,
sourcePointCount: sources.reduce((sum, source) => sum + source.sourcePointCount, 0),
pointCount: segments.reduce((sum, segment) => sum + segment.pointCount, 0),
bounds: mergeRoutePreviewBounds(segments.map(segment => segment.bounds)),
segments
});
}
static normalizeCoordinates(coordinates) {
if (!Array.isArray(coordinates)) {
return [];
}
return coordinates
.map(point => ({
latitudeDegrees: toFiniteNumber(point === null || point === void 0 ? void 0 : point.latitudeDegrees),
longitudeDegrees: toFiniteNumber(point === null || point === void 0 ? void 0 : point.longitudeDegrees)
}))
.filter((point) => (point.latitudeDegrees !== null
&& point.longitudeDegrees !== null
&& point.latitudeDegrees >= -90
&& point.latitudeDegrees <= 90
&& point.longitudeDegrees >= -180
&& point.longitudeDegrees <= 180
&& (point.latitudeDegrees !== 0 || point.longitudeDegrees !== 0)));
}
static simplifyPolyline(coordinates, options = {}) {
const points = this.normalizeCoordinates(coordinates);
if (points.length <= 2) {
return points;
}
const targetPointCount = this.resolveSimplificationTarget(points.length, options);
if (targetPointCount >= points.length) {
return points;
}
return this.runVisvalingamWhyatt(points, targetPointCount);
}
static resolveRouteSources(routeFile) {
if (!routeFile) {
return [];
}
if (typeof routeFile.getRoutes === 'function') {
return (routeFile.getRoutes() || []).map(route => this.routeClassToSource(route));
}
const previewRouteFile = routeFile;
const routes = Array.isArray(previewRouteFile.routes) ? previewRouteFile.routes : [];
return routes.map(route => this.routeJsonToSource(route));
}
static routeClassToSource(route) {
var _a, _b, _c;
const points = ((_a = route.getPointData) === null || _a === void 0 ? void 0 : _a.call(route)) || [];
return {
id: ((_b = route.getID) === null || _b === void 0 ? void 0 : _b.call(route)) || undefined,
name: (_c = route.name) !== null && _c !== void 0 ? _c : null,
activityType: route.activityType ? `${route.activityType}` : null,
sourcePointCount: points.length,
validPoints: this.normalizeCoordinates(points)
};
}
static routeJsonToSource(route) {
var _a;
const points = Array.isArray(route.points) ? route.points : [];
return {
id: route.id,
name: (_a = route.name) !== null && _a !== void 0 ? _a : null,
activityType: route.activityType ? `${route.activityType}` : null,
sourcePointCount: points.length,
validPoints: this.normalizeCoordinates(points)
};
}
static allocateSegmentTargetCounts(sourceCounts, maxPointsPerSegment, maxPointsPerRoute) {
const rawTargets = sourceCounts.map(count => Math.min(count, maxPointsPerSegment));
const rawTotal = rawTargets.reduce((sum, count) => sum + count, 0);
if (rawTotal <= maxPointsPerRoute) {
return rawTargets;
}
const minimumTotal = rawTargets.length * 2;
if (minimumTotal > maxPointsPerRoute) {
let remaining = maxPointsPerRoute;
return rawTargets.map(() => {
if (remaining >= 2) {
remaining -= 2;
return 2;
}
return 0;
});
}
const extraBudget = maxPointsPerRoute - minimumTotal;
const rawExtras = rawTargets.map(target => Math.max(0, target - 2));
const rawExtraTotal = rawExtras.reduce((sum, count) => sum + count, 0);
if (rawExtraTotal <= 0) {
return rawTargets.map(() => 2);
}
const allocations = rawExtras.map((extra, index) => {
const exact = (extra / rawExtraTotal) * extraBudget;
const floor = Math.floor(exact);
return {
index,
count: 2 + floor,
remainder: exact - floor,
max: rawTargets[index]
};
});
let distributedTotal = allocations.reduce((sum, allocation) => sum + allocation.count, 0);
allocations
.sort((left, right) => right.remainder - left.remainder || left.index - right.index)
.forEach((allocation) => {
if (distributedTotal >= maxPointsPerRoute || allocation.count >= allocation.max) {
return;
}
allocation.count += 1;
distributedTotal += 1;
});
return allocations
.sort((left, right) => left.index - right.index)
.map(allocation => allocation.count);
}
static resolveSimplificationTarget(inputPointCount, options) {
const maxPoints = Number.isFinite(options.maxPoints)
? Math.max(2, Math.floor(options.maxPoints))
: inputPointCount;
const keepRatio = Number.isFinite(options.keepRatio) && options.keepRatio > 0
? Math.min(1, options.keepRatio)
: 1;
const minPointsToKeep = Number.isFinite(options.minPointsToKeep)
? Math.max(2, Math.floor(options.minPointsToKeep))
: 2;
const ratioTarget = Math.round(inputPointCount * keepRatio);
return Math.min(inputPointCount, Math.max(minPointsToKeep, Math.min(maxPoints, ratioTarget)));
}
static runVisvalingamWhyatt(points, targetPointCount) {
const length = points.length;
const previous = Array.from({ length }, (_, index) => index - 1);
const next = Array.from({ length }, (_, index) => index + 1);
next[length - 1] = -1;
const removed = Array(length).fill(false);
const versions = Array(length).fill(0);
const heap = new AreaMinHeap();
for (let index = 1; index < length - 1; index += 1) {
heap.push({
index,
area: calculateTriangleArea(points[previous[index]], points[index], points[next[index]]),
version: versions[index]
});
}
let remaining = length;
while (remaining > targetPointCount && heap.size > 0) {
const candidate = heap.pop();
if (!candidate || removed[candidate.index] || candidate.version !== versions[candidate.index]) {
continue;
}
const previousIndex = previous[candidate.index];
const nextIndex = next[candidate.index];
if (previousIndex < 0 || nextIndex < 0) {
continue;
}
removed[candidate.index] = true;
next[previousIndex] = nextIndex;
previous[nextIndex] = previousIndex;
remaining -= 1;
[previousIndex, nextIndex].forEach((neighborIndex) => {
if (neighborIndex <= 0 || neighborIndex >= length - 1 || removed[neighborIndex]) {
return;
}
versions[neighborIndex] += 1;
heap.push({
index: neighborIndex,
area: calculateTriangleArea(points[previous[neighborIndex]], points[neighborIndex], points[next[neighborIndex]]),
version: versions[neighborIndex]
});
});
}
return points.filter((_point, index) => !removed[index]);
}
static normalizePointLimit(value, fallback) {
if (!Number.isFinite(value)) {
return fallback;
}
return Math.max(2, Math.floor(value));
}
static removeUndefined(value) {
Object.keys(value).forEach((key) => {
if (value[key] === undefined) {
delete value[key];
}
});
return value;
}
}
exports.RoutePreviewUtilities = RoutePreviewUtilities;
function simplifyCoordinatePairsVisvalingamWhyatt(coordinates, options = {}) {
const coordinatePairs = normalizeCoordinatePairs(coordinates);
const inputPointCount = coordinatePairs.length;
const minInputPoints = Number.isFinite(options.minInputPoints)
? Math.max(0, Math.floor(options.minInputPoints))
: 0;
if (inputPointCount < 3 || inputPointCount < minInputPoints) {
return {
coordinates: coordinatePairs,
inputPointCount,
outputPointCount: inputPointCount,
simplified: false
};
}
const targetPointCount = resolveCoordinatePairSimplificationTarget(inputPointCount, options);
if (targetPointCount >= inputPointCount) {
return {
coordinates: coordinatePairs,
inputPointCount,
outputPointCount: inputPointCount,
simplified: false
};
}
const simplifiedCoordinates = runVisvalingamWhyattCoordinatePairs(coordinatePairs, targetPointCount);
return {
coordinates: simplifiedCoordinates,
inputPointCount,
outputPointCount: simplifiedCoordinates.length,
simplified: simplifiedCoordinates.length < inputPointCount
};
}
function encodeRoutePolyline5(points) {
const normalizedPoints = RoutePreviewUtilities.normalizeCoordinates(points);
if (!normalizedPoints.length) {
return '';
}
return (0, polyline_codec_1.encode)(normalizedPoints.map(point => [point.latitudeDegrees, point.longitudeDegrees]), exports.ROUTE_PREVIEW_POLYLINE_PRECISION);
}
function decodeRoutePolyline5(encodedPolyline) {
if (typeof encodedPolyline !== 'string' || encodedPolyline.length === 0) {
return [];
}
try {
return RoutePreviewUtilities.normalizeCoordinates((0, polyline_codec_1.decode)(encodedPolyline, exports.ROUTE_PREVIEW_POLYLINE_PRECISION)
.map(([latitudeDegrees, longitudeDegrees]) => ({ latitudeDegrees, longitudeDegrees })));
}
catch (_error) {
return [];
}
}
function buildRoutePreviewBounds(points) {
const normalizedPoints = RoutePreviewUtilities.normalizeCoordinates(points);
if (!normalizedPoints.length) {
return undefined;
}
return normalizedPoints.reduce((bounds, point) => ({
minLatitudeDegrees: Math.min(bounds.minLatitudeDegrees, point.latitudeDegrees),
maxLatitudeDegrees: Math.max(bounds.maxLatitudeDegrees, point.latitudeDegrees),
minLongitudeDegrees: Math.min(bounds.minLongitudeDegrees, point.longitudeDegrees),
maxLongitudeDegrees: Math.max(bounds.maxLongitudeDegrees, point.longitudeDegrees)
}), {
minLatitudeDegrees: normalizedPoints[0].latitudeDegrees,
maxLatitudeDegrees: normalizedPoints[0].latitudeDegrees,
minLongitudeDegrees: normalizedPoints[0].longitudeDegrees,
maxLongitudeDegrees: normalizedPoints[0].longitudeDegrees
});
}
function mergeRoutePreviewBounds(boundsList) {
const validBounds = (boundsList || []).filter((bounds) => !!bounds);
if (!validBounds.length) {
return undefined;
}
return validBounds.reduce((merged, bounds) => ({
minLatitudeDegrees: Math.min(merged.minLatitudeDegrees, bounds.minLatitudeDegrees),
maxLatitudeDegrees: Math.max(merged.maxLatitudeDegrees, bounds.maxLatitudeDegrees),
minLongitudeDegrees: Math.min(merged.minLongitudeDegrees, bounds.minLongitudeDegrees),
maxLongitudeDegrees: Math.max(merged.maxLongitudeDegrees, bounds.maxLongitudeDegrees)
}), validBounds[0]);
}
function toFiniteNumber(value) {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : null;
}
if (typeof value === 'string' && value.trim()) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : null;
}
return null;
}
function normalizeCoordinatePairs(coordinates) {
if (!Array.isArray(coordinates)) {
return [];
}
return coordinates
.map((coordinate) => {
if (!Array.isArray(coordinate) || coordinate.length < 2) {
return null;
}
const first = toFiniteNumber(coordinate[0]);
const second = toFiniteNumber(coordinate[1]);
return first === null || second === null ? null : [first, second];
})
.filter((coordinate) => !!coordinate);
}
function resolveCoordinatePairSimplificationTarget(inputPointCount, options) {
const maxPoints = Number.isFinite(options.maxPoints)
? Math.max(2, Math.floor(options.maxPoints))
: inputPointCount;
const keepRatio = Number.isFinite(options.keepRatio) && options.keepRatio > 0
? Math.min(1, options.keepRatio)
: 1;
const minPointsToKeep = Number.isFinite(options.minPointsToKeep)
? Math.max(2, Math.floor(options.minPointsToKeep))
: 2;
const ratioTarget = Math.round(inputPointCount * keepRatio);
return Math.min(inputPointCount, Math.max(minPointsToKeep, Math.min(maxPoints, ratioTarget)));
}
function runVisvalingamWhyattCoordinatePairs(coordinates, targetPointCount) {
const length = coordinates.length;
const previous = Array.from({ length }, (_, index) => index - 1);
const next = Array.from({ length }, (_, index) => index + 1);
next[length - 1] = -1;
const removed = Array(length).fill(false);
const versions = Array(length).fill(0);
const heap = new AreaMinHeap();
for (let index = 1; index < length - 1; index += 1) {
heap.push({
index,
area: calculateCoordinatePairTriangleArea(coordinates[previous[index]], coordinates[index], coordinates[next[index]]),
version: versions[index]
});
}
let remaining = length;
while (remaining > targetPointCount && heap.size > 0) {
const candidate = heap.pop();
if (!candidate || removed[candidate.index] || candidate.version !== versions[candidate.index]) {
continue;
}
const previousIndex = previous[candidate.index];
const nextIndex = next[candidate.index];
if (previousIndex < 0 || nextIndex < 0) {
continue;
}
removed[candidate.index] = true;
next[previousIndex] = nextIndex;
previous[nextIndex] = previousIndex;
remaining -= 1;
[previousIndex, nextIndex].forEach((neighborIndex) => {
if (neighborIndex <= 0 || neighborIndex >= length - 1 || removed[neighborIndex]) {
return;
}
versions[neighborIndex] += 1;
heap.push({
index: neighborIndex,
area: calculateCoordinatePairTriangleArea(coordinates[previous[neighborIndex]], coordinates[neighborIndex], coordinates[next[neighborIndex]]),
version: versions[neighborIndex]
});
});
}
return coordinates.filter((_coordinate, index) => !removed[index]);
}
function calculateTriangleArea(first, second, third) {
return Math.abs(((first.longitudeDegrees * (second.latitudeDegrees - third.latitudeDegrees))
+ (second.longitudeDegrees * (third.latitudeDegrees - first.latitudeDegrees))
+ (third.longitudeDegrees * (first.latitudeDegrees - second.latitudeDegrees))) / 2);
}
function calculateCoordinatePairTriangleArea(first, second, third) {
return Math.abs(((first[0] * (second[1] - third[1]))
+ (second[0] * (third[1] - first[1]))
+ (third[0] * (first[1] - second[1]))) / 2);
}
class AreaMinHeap {
constructor() {
this.nodes = [];
}
get size() {
return this.nodes.length;
}
push(node) {
this.nodes.push(node);
this.bubbleUp(this.nodes.length - 1);
}
pop() {
if (!this.nodes.length) {
return undefined;
}
const root = this.nodes[0];
const last = this.nodes.pop();
if (last && this.nodes.length) {
this.nodes[0] = last;
this.bubbleDown(0);
}
return root;
}
bubbleUp(index) {
let current = index;
while (current > 0) {
const parent = Math.floor((current - 1) / 2);
if (this.compare(this.nodes[current], this.nodes[parent]) >= 0) {
break;
}
this.swap(current, parent);
current = parent;
}
}
bubbleDown(index) {
let current = index;
while (true) {
const left = (current * 2) + 1;
const right = left + 1;
let smallest = current;
if (left < this.nodes.length && this.compare(this.nodes[left], this.nodes[smallest]) < 0) {
smallest = left;
}
if (right < this.nodes.length && this.compare(this.nodes[right], this.nodes[smallest]) < 0) {
smallest = right;
}
if (smallest === current) {
break;
}
this.swap(current, smallest);
current = smallest;
}
}
compare(left, right) {
return left.area - right.area || left.index - right.index;
}
swap(left, right) {
const temp = this.nodes[left];
this.nodes[left] = this.nodes[right];
this.nodes[right] = temp;
}
}