From c32bd55172ecad48721d94e2dec42528ba0ec32b Mon Sep 17 00:00:00 2001 From: vcoppe Date: Thu, 18 Apr 2024 19:15:01 +0200 Subject: [PATCH] gpx stats --- gpx/src/gpx.ts | 229 ++++++++++++++++++++++++++++++++++++++++++- gpx/src/types.ts | 2 +- gpx/test/gpx.test.ts | 2 +- gpx/test/io.test.ts | 4 +- 4 files changed, 232 insertions(+), 5 deletions(-) diff --git a/gpx/src/gpx.ts b/gpx/src/gpx.ts index 35658a2a..fd0e1ad3 100644 --- a/gpx/src/gpx.ts +++ b/gpx/src/gpx.ts @@ -13,6 +13,8 @@ abstract class GPXTreeElement> { abstract isLeaf(): boolean; abstract getChildren(): T[]; + abstract computeStatistics(): GPXStatistics; + abstract reverse(originalNextTimestamp?: Date, newPreviousTimestamp?: Date): void; abstract getStartTimestamp(): Date; @@ -27,6 +29,16 @@ abstract class GPXTreeNode> extends GPXTreeElement return false; } + computeStatistics(): GPXStatistics { + let statistics = new GPXStatistics(); + + for (let child of this.getChildren()) { + statistics.mergeWith(child.computeStatistics()); + } + + return statistics; + } + reverse(originalNextTimestamp?: Date, newPreviousTimestamp?: Date): void { const children = this.getChildren(); @@ -73,6 +85,7 @@ export class GPXFile extends GPXTreeNode{ metadata: Metadata; wpt: Waypoint[]; trk: Track[]; + statistics: GPXStatistics; constructor(gpx: GPXFileType | GPXFile) { super(); @@ -80,6 +93,8 @@ export class GPXFile extends GPXTreeNode{ this.metadata = cloneJSON(gpx.metadata); this.wpt = gpx.wpt.map((waypoint) => new Waypoint(waypoint)); this.trk = gpx.trk.map((track) => new Track(track)); + + this.statistics = this.computeStatistics(); } getChildren(): Track[] { @@ -151,12 +166,146 @@ export class Track extends GPXTreeNode { // A class that represents a TrackSegment in a GPX file export class TrackSegment extends GPXTreeLeaf { trkpt: TrackPoint[]; + trkptStatistics: TrackPointStatistics; constructor(segment: TrackSegmentType | TrackSegment) { super(); this.trkpt = segment.trkpt.map((point) => new TrackPoint(point)); } + computeStatistics(): GPXStatistics { + let statistics = new GPXStatistics(); + let trkptStatistics: TrackPointStatistics = { + distance: [], + time: [], + speed: [], + elevation: { + smoothed: [], + gain: [], + loss: [], + }, + slope: [], + }; + + trkptStatistics.elevation.smoothed = this.computeSmoothedElevation(); + trkptStatistics.slope = this.computeSlope(); + + const points = this.trkpt; + for (let i = 0; i < points.length; i++) { + + // distance + let dist = 0; + if (i > 0) { + dist = distance(points[i - 1].getCoordinates(), points[i].getCoordinates()); + + statistics.distance.total += dist; + } + + trkptStatistics.distance.push(statistics.distance.total); + + // elevation + if (i > 0) { + const ele = trkptStatistics.elevation.smoothed[i] - trkptStatistics.elevation.smoothed[i - 1]; + if (ele > 0) { + statistics.elevation.gain += ele; + } else { + statistics.elevation.loss -= ele; + } + } + + trkptStatistics.elevation.gain.push(statistics.elevation.gain); + trkptStatistics.elevation.loss.push(statistics.elevation.loss); + + // time + if (points[0].time !== undefined && points[i].time !== undefined) { + const time = points[i].time.getTime() - points[0].time.getTime(); + + trkptStatistics.time.push(time); + } + + // speed + let speed = 0; + if (i > 0 && points[i - 1].time !== undefined && points[i].time !== undefined) { + const time = points[i].time.getTime() - points[i - 1].time.getTime(); + speed = dist / time; + + if (speed > 0.1) { + statistics.distance.moving += dist; + statistics.time.moving += time; + } + } + + trkptStatistics.speed.push(speed); + } + + statistics.time.total = trkptStatistics.time[trkptStatistics.time.length - 1]; + statistics.speed.total = statistics.distance.total / statistics.time.total; + statistics.speed.moving = statistics.distance.moving / statistics.time.moving; + + this.trkptStatistics = trkptStatistics; + + return statistics; + } + + computeSmoothedElevation(): number[] { + const ELEVATION_SMOOTHING_DISTANCE_THRESHOLD = 100; + + let smoothed = []; + + const points = this.trkpt; + for (var i = 0; i < points.length; i++) { + let weightedSum = 0; + let totalWeight = 0; + + for (let j = 0; ; j++) { + let left = i - j, right = i + j + 1; + let contributed = false; + for (let k of [left, right]) { + let dist = distance(points[i].getCoordinates(), points[k].getCoordinates()); + if (dist > ELEVATION_SMOOTHING_DISTANCE_THRESHOLD) { + break; + } + + let weight = ELEVATION_SMOOTHING_DISTANCE_THRESHOLD - dist; + weightedSum += points[j].ele * weight; + totalWeight += weight; + contributed = true; + } + + if (!contributed) { + break; + } + } + + smoothed.push(weightedSum / totalWeight); + } + + return smoothed; + } + + computeSlope(): number[] { + let slope = []; + + const SLOPE_DISTANCE_THRESHOLD = 100; + + const points = this.trkpt; + + let start = 0, end = 0, windowDistance = 0; + for (var i = 0; i < points.length; i++) { + while (start < i && distance(points[start].getCoordinates(), points[i].getCoordinates()) > SLOPE_DISTANCE_THRESHOLD) { + windowDistance -= distance(points[start].getCoordinates(), points[start + 1].getCoordinates()); + start++; + } + while (end + 1 < points.length && distance(points[i].getCoordinates(), points[end + 1].getCoordinates()) <= SLOPE_DISTANCE_THRESHOLD) { + windowDistance += distance(points[end].getCoordinates(), points[end + 1].getCoordinates()); + end++; + } + slope[i] = windowDistance > 1e-3 ? 100 * (points[end].ele - points[start].ele) / windowDistance : 0; + } + + return slope; + } + reverse(originalNextTimestamp: Date | undefined, newPreviousTimestamp: Date | undefined): void { if (originalNextTimestamp !== undefined && newPreviousTimestamp !== undefined) { let originalEndTimestamp = this.getEndTimestamp(); @@ -189,7 +338,7 @@ export class TrackSegment extends GPXTreeLeaf { type: "Feature", geometry: { type: "LineString", - coordinates: this.trkpt.map((point) => [point.attributes.lon, point.attributes.lat]) + coordinates: this.trkpt.map((point) => [point.attributes.lng, point.attributes.lat]) }, properties: {} }; @@ -214,6 +363,10 @@ export class TrackPoint { } this.extensions = cloneJSON(point.extensions); } + + getCoordinates(): Coordinates { + return this.attributes; + } }; export class Waypoint { @@ -240,4 +393,78 @@ export class Waypoint { this.sym = waypoint.sym; this.type = waypoint.type; } +} + +class GPXStatistics { + distance: { + moving: number; + total: number; + }; + time: { + moving: number; + total: number; + }; + speed: { + moving: number; + total: number; + }; + elevation: { + gain: number; + loss: number; + }; + + constructor() { + this.distance = { + moving: 0, + total: 0, + }; + this.time = { + moving: 0, + total: 0, + }; + this.speed = { + moving: 0, + total: 0, + }; + this.elevation = { + gain: 0, + loss: 0, + }; + } + + mergeWith(other: GPXStatistics): void { + this.distance.total += other.distance.total; + this.distance.moving += other.distance.moving; + + this.time.total += other.time.total; + this.time.moving += other.time.moving; + + this.speed.moving = this.distance.moving / this.time.moving; + this.speed.total = this.distance.total / this.time.total; + + this.elevation.gain += other.elevation.gain; + this.elevation.loss += other.elevation.loss; + } +} + +type TrackPointStatistics = { + distance: number[], + time: number[], + speed: number[], + elevation: { + smoothed: number[], + gain: number[], + loss: number[], + }, + slope: number[], +} + +const earthRadius = 6371008.8; +function distance(coord1: Coordinates, coord2: Coordinates): number { + const rad = Math.PI / 180; + const lat1 = coord1.lat * rad; + const lat2 = coord2.lat * rad; + const a = Math.sin(lat1) * Math.sin(lat2) + Math.cos(lat1) * Math.cos(lat2) * Math.cos((coord2.lng - coord1.lng) * rad); + const maxMeters = earthRadius * Math.acos(Math.min(a, 1)); + return maxMeters; } \ No newline at end of file diff --git a/gpx/src/types.ts b/gpx/src/types.ts index 0b7cf642..4f66b56b 100644 --- a/gpx/src/types.ts +++ b/gpx/src/types.ts @@ -42,7 +42,7 @@ export type WaypointType = { export type Coordinates = { lat: number; - lon: number; + lng: number; }; export type TrackType = { diff --git a/gpx/test/gpx.test.ts b/gpx/test/gpx.test.ts index 54c60f22..71b99dd0 100644 --- a/gpx/test/gpx.test.ts +++ b/gpx/test/gpx.test.ts @@ -48,7 +48,7 @@ describe('GPX operations', () => { const reversedPoint = reversedSegment.trkpt[originalSegment.trkpt.length - k - 1]; expect(reversedPoint.attributes.lat).toBe(originalPoint.attributes.lat); - expect(reversedPoint.attributes.lon).toBe(originalPoint.attributes.lon); + expect(reversedPoint.attributes.lng).toBe(originalPoint.attributes.lng); expect(reversedPoint.ele).toBe(originalPoint.ele); expect(reversed.getEndTimestamp().getTime() - reversedPoint.time.getTime()).toBe(originalPoint.time.getTime() - original.getStartTimestamp().getTime()); diff --git a/gpx/test/io.test.ts b/gpx/test/io.test.ts index a08281e1..5739cdcf 100644 --- a/gpx/test/io.test.ts +++ b/gpx/test/io.test.ts @@ -32,7 +32,7 @@ describe("Parsing", () => { } expect(segment.trkpt[0].attributes.lat).toBe(50.790867); - expect(segment.trkpt[0].attributes.lon).toBe(4.404968); + expect(segment.trkpt[0].attributes.lng).toBe(4.404968); expect(segment.trkpt[0].ele).toBe(109.0); }); @@ -76,7 +76,7 @@ describe("Parsing", () => { const waypoint = result.wpt[0]; expect(waypoint.attributes.lat).toBe(50.7836710064975); - expect(waypoint.attributes.lon).toBe(4.410764082658738); + expect(waypoint.attributes.lng).toBe(4.410764082658738); expect(waypoint.ele).toBe(122.0); expect(waypoint.name).toBe("Waypoint"); expect(waypoint.cmt).toBe("Comment");