UNPKG

@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
"use strict"; 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; } }