diff --git a/gpx/src/gpx.ts b/gpx/src/gpx.ts index 0a0aff9db..ef82f91e4 100644 --- a/gpx/src/gpx.ts +++ b/gpx/src/gpx.ts @@ -1,4 +1,5 @@ import { ramerDouglasPeucker } from './simplify'; +import { GPXStatistics, GPXStatisticsGroup, TrackPointLocalStatistics } from './statistics'; import { Coordinates, GPXFileAttributes, @@ -36,7 +37,6 @@ export abstract class GPXTreeElement> { abstract getNumberOfTrackPoints(): number; abstract getStartTimestamp(): Date | undefined; abstract getEndTimestamp(): Date | undefined; - abstract getStatistics(): GPXStatistics; abstract getSegments(): TrackSegment[]; abstract getTrackPoints(): TrackPoint[]; @@ -76,14 +76,6 @@ abstract class GPXTreeNode> extends GPXTreeElement return this.children[this.children.length - 1].getEndTimestamp(); } - getStatistics(): GPXStatistics { - let statistics = new GPXStatistics(); - for (let child of this.children) { - statistics.mergeWith(child.getStatistics()); - } - return statistics; - } - getSegments(): TrackSegment[] { return this.children.flatMap((child) => child.getSegments()); } @@ -208,8 +200,16 @@ export class GPXFile extends GPXTreeNode { }); } + getStatistics(): GPXStatisticsGroup { + let statistics = new GPXStatisticsGroup(); + this.forEachSegment((segment) => { + statistics.add(segment.getStatistics()); + }); + return statistics; + } + getStyle(defaultColor?: string): MergedLineStyles { - return this.trk + const style = this.trk .map((track) => track.getStyle()) .reduce( (acc, style) => { @@ -219,8 +219,6 @@ export class GPXFile extends GPXTreeNode { !acc.color.includes(style['gpx_style:color']) ) { acc.color.push(style['gpx_style:color']); - } else if (defaultColor && !acc.color.includes(defaultColor)) { - acc.color.push(defaultColor); } if ( style && @@ -244,6 +242,10 @@ export class GPXFile extends GPXTreeNode { width: [], } ); + if (style.color.length === 0 && defaultColor) { + style.color.push(defaultColor); + } + return style; } clone(): GPXFile { @@ -818,7 +820,9 @@ export class TrackSegment extends GPXTreeLeaf { _computeStatistics(): GPXStatistics { let statistics = new GPXStatistics(); + statistics.global.length = this.trkpt.length; statistics.local.points = this.trkpt.slice(0); + statistics.local.data = this.trkpt.map(() => new TrackPointLocalStatistics()); const points = this.trkpt; for (let i = 0; i < points.length; i++) { @@ -830,19 +834,18 @@ export class TrackSegment extends GPXTreeLeaf { statistics.global.distance.total += dist; } - statistics.local.distance.total.push(statistics.global.distance.total); + statistics.local.data[i].distance.total = statistics.global.distance.total; // time if (points[i].time === undefined) { - statistics.local.time.total.push(0); + statistics.local.data[i].time.total = 0; } else { if (statistics.global.time.start === undefined) { statistics.global.time.start = points[i].time; } statistics.global.time.end = points[i].time; - statistics.local.time.total.push( - (points[i].time.getTime() - statistics.global.time.start.getTime()) / 1000 - ); + statistics.local.data[i].time.total = + (points[i].time.getTime() - statistics.global.time.start.getTime()) / 1000; } // speed @@ -857,8 +860,8 @@ export class TrackSegment extends GPXTreeLeaf { } } - statistics.local.distance.moving.push(statistics.global.distance.moving); - statistics.local.time.moving.push(statistics.global.time.moving); + statistics.local.data[i].distance.moving = statistics.global.distance.moving; + statistics.local.data[i].time.moving = statistics.global.time.moving; // bounds statistics.global.bounds.southWest.lat = Math.min( @@ -958,13 +961,22 @@ export class TrackSegment extends GPXTreeLeaf { ? statistics.global.distance.moving / (statistics.global.time.moving / 3600) : 0; - statistics.local.speed = timeWindowSmoothing(points, 10000, (start, end) => - points[start].time && points[end].time - ? (3600 * - (statistics.local.distance.total[end] - - statistics.local.distance.total[start])) / - Math.max((points[end].time.getTime() - points[start].time.getTime()) / 1000, 1) - : undefined + timeWindowSmoothing( + points, + 10000, + (start, end) => + points[start].time && points[end].time + ? (3600 * + (statistics.local.data[end].distance.total - + statistics.local.data[start].distance.total)) / + Math.max( + (points[end].time.getTime() - points[start].time.getTime()) / 1000, + 1 + ) + : undefined, + (value, index) => { + statistics.local.data[index].speed = value; + } ); return statistics; @@ -984,53 +996,65 @@ export class TrackSegment extends GPXTreeLeaf { let cumulEle = 0; let currentStart = start; let currentEnd = start; - let smoothedEle = distanceWindowSmoothing(start, end + 1, statistics, 0.1, (s, e) => { - for (let i = currentStart; i < s; i++) { - cumulEle -= this.trkpt[i].ele ?? 0; + let prevSmoothedEle = 0; + distanceWindowSmoothing( + start, + end + 1, + statistics, + 0.1, + (s, e) => { + for (let i = currentStart; i < s; i++) { + cumulEle -= this.trkpt[i].ele ?? 0; + } + for (let i = currentEnd; i <= e; i++) { + cumulEle += this.trkpt[i].ele ?? 0; + } + currentStart = s; + currentEnd = e + 1; + return cumulEle / (e - s + 1); + }, + (smoothedEle, j) => { + if (j === start) { + smoothedEle = this.trkpt[start].ele ?? 0; + prevSmoothedEle = smoothedEle; + } else if (j === end) { + smoothedEle = this.trkpt[end].ele ?? 0; + } + const ele = smoothedEle - prevSmoothedEle; + if (ele > 0) { + statistics.global.elevation.gain += ele; + } else if (ele < 0) { + statistics.global.elevation.loss -= ele; + } + prevSmoothedEle = smoothedEle; + if (j < end) { + statistics.local.data[j].elevation.gain = statistics.global.elevation.gain; + statistics.local.data[j].elevation.loss = statistics.global.elevation.loss; + } } - for (let i = currentEnd; i <= e; i++) { - cumulEle += this.trkpt[i].ele ?? 0; - } - currentStart = s; - currentEnd = e + 1; - return cumulEle / (e - s + 1); - }); - smoothedEle[0] = this.trkpt[start].ele ?? 0; - smoothedEle[smoothedEle.length - 1] = this.trkpt[end].ele ?? 0; - - for (let j = start; j < end; j++) { - statistics.local.elevation.gain.push(statistics.global.elevation.gain); - statistics.local.elevation.loss.push(statistics.global.elevation.loss); - - const ele = smoothedEle[j - start + 1] - smoothedEle[j - start]; - if (ele > 0) { - statistics.global.elevation.gain += ele; - } else if (ele < 0) { - statistics.global.elevation.loss -= ele; - } - } + ); + } + if (statistics.global.length > 0) { + statistics.local.data[statistics.global.length - 1].elevation.gain = + statistics.global.elevation.gain; + statistics.local.data[statistics.global.length - 1].elevation.loss = + statistics.global.elevation.loss; } - statistics.local.elevation.gain.push(statistics.global.elevation.gain); - statistics.local.elevation.loss.push(statistics.global.elevation.loss); - let slope = []; - let length = []; for (let i = 0; i < simplified.length - 1; i++) { let start = simplified[i].point._data.index; let end = simplified[i + 1].point._data.index; let dist = - statistics.local.distance.total[end] - statistics.local.distance.total[start]; + statistics.local.data[end].distance.total - + statistics.local.data[start].distance.total; let ele = (simplified[i + 1].point.ele ?? 0) - (simplified[i].point.ele ?? 0); - for (let j = start; j < end + (i + 1 === simplified.length - 1 ? 1 : 0); j++) { - slope.push((0.1 * ele) / dist); - length.push(dist); + statistics.local.data[j].slope.segment = (0.1 * ele) / dist; + statistics.local.data[j].slope.length = dist; } } - statistics.local.slope.segment = slope; - statistics.local.slope.length = length; - statistics.local.slope.at = distanceWindowSmoothing( + distanceWindowSmoothing( 0, this.trkpt.length, statistics, @@ -1038,8 +1062,12 @@ export class TrackSegment extends GPXTreeLeaf { (start, end) => { const ele = this.trkpt[end].ele - this.trkpt[start].ele || 0; const dist = - statistics.local.distance.total[end] - statistics.local.distance.total[start]; + statistics.local.data[end].distance.total - + statistics.local.data[start].distance.total; return dist > 0 ? (0.1 * ele) / dist : 0; + }, + (value, index) => { + statistics.local.data[index].slope.at = value; } ); } @@ -1289,13 +1317,7 @@ export class TrackSegment extends GPXTreeLeaf { ) { let og = getOriginal(this); // Read as much as possible from the original object because it is faster let statistics = og._computeStatistics(); - let trkpt = withArtificialTimestamps( - og.trkpt, - totalTime, - lastPoint, - startTime, - statistics.local.slope.at - ); + let trkpt = withArtificialTimestamps(og.trkpt, totalTime, lastPoint, startTime, statistics); this.trkpt = freeze(trkpt); // Pre-freeze the array, faster as well } @@ -1304,6 +1326,7 @@ export class TrackSegment extends GPXTreeLeaf { } } +const emptyExtensions: Record = {}; export class TrackPoint { [immerable] = true; @@ -1398,7 +1421,7 @@ export class TrackPoint { this.extensions['gpxtpx:TrackPointExtension'] && this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions'] ? this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions'] - : {}; + : emptyExtensions; } toTrackPointType(exclude: string[] = []): TrackPointType { @@ -1619,305 +1642,6 @@ export class Waypoint { } } -export class GPXStatistics { - global: { - distance: { - moving: number; - total: number; - }; - time: { - start: Date | undefined; - end: Date | undefined; - moving: number; - total: number; - }; - speed: { - moving: number; - total: number; - }; - elevation: { - gain: number; - loss: number; - }; - bounds: { - southWest: Coordinates; - northEast: Coordinates; - }; - atemp: { - avg: number; - count: number; - }; - hr: { - avg: number; - count: number; - }; - cad: { - avg: number; - count: number; - }; - power: { - avg: number; - count: number; - }; - extensions: Record>; - }; - local: { - points: TrackPoint[]; - distance: { - moving: number[]; - total: number[]; - }; - time: { - moving: number[]; - total: number[]; - }; - speed: number[]; - elevation: { - gain: number[]; - loss: number[]; - }; - slope: { - at: number[]; - segment: number[]; - length: number[]; - }; - }; - - constructor() { - this.global = { - distance: { - moving: 0, - total: 0, - }, - time: { - start: undefined, - end: undefined, - moving: 0, - total: 0, - }, - speed: { - moving: 0, - total: 0, - }, - elevation: { - gain: 0, - loss: 0, - }, - bounds: { - southWest: { - lat: 90, - lon: 180, - }, - northEast: { - lat: -90, - lon: -180, - }, - }, - atemp: { - avg: 0, - count: 0, - }, - hr: { - avg: 0, - count: 0, - }, - cad: { - avg: 0, - count: 0, - }, - power: { - avg: 0, - count: 0, - }, - extensions: {}, - }; - this.local = { - points: [], - distance: { - moving: [], - total: [], - }, - time: { - moving: [], - total: [], - }, - speed: [], - elevation: { - gain: [], - loss: [], - }, - slope: { - at: [], - segment: [], - length: [], - }, - }; - } - - mergeWith(other: GPXStatistics): void { - this.local.points = this.local.points.concat(other.local.points); - - this.local.distance.total = this.local.distance.total.concat( - other.local.distance.total.map((distance) => distance + this.global.distance.total) - ); - this.local.distance.moving = this.local.distance.moving.concat( - other.local.distance.moving.map((distance) => distance + this.global.distance.moving) - ); - this.local.time.total = this.local.time.total.concat( - other.local.time.total.map((time) => time + this.global.time.total) - ); - this.local.time.moving = this.local.time.moving.concat( - other.local.time.moving.map((time) => time + this.global.time.moving) - ); - this.local.elevation.gain = this.local.elevation.gain.concat( - other.local.elevation.gain.map((gain) => gain + this.global.elevation.gain) - ); - this.local.elevation.loss = this.local.elevation.loss.concat( - other.local.elevation.loss.map((loss) => loss + this.global.elevation.loss) - ); - - this.local.speed = this.local.speed.concat(other.local.speed); - this.local.slope.at = this.local.slope.at.concat(other.local.slope.at); - this.local.slope.segment = this.local.slope.segment.concat(other.local.slope.segment); - this.local.slope.length = this.local.slope.length.concat(other.local.slope.length); - - this.global.distance.total += other.global.distance.total; - this.global.distance.moving += other.global.distance.moving; - - this.global.time.start = - this.global.time.start !== undefined && other.global.time.start !== undefined - ? new Date( - Math.min(this.global.time.start.getTime(), other.global.time.start.getTime()) - ) - : (this.global.time.start ?? other.global.time.start); - this.global.time.end = - this.global.time.end !== undefined && other.global.time.end !== undefined - ? new Date( - Math.max(this.global.time.end.getTime(), other.global.time.end.getTime()) - ) - : (this.global.time.end ?? other.global.time.end); - - this.global.time.total += other.global.time.total; - this.global.time.moving += other.global.time.moving; - - this.global.speed.moving = - this.global.time.moving > 0 - ? this.global.distance.moving / (this.global.time.moving / 3600) - : 0; - this.global.speed.total = - this.global.time.total > 0 - ? this.global.distance.total / (this.global.time.total / 3600) - : 0; - - this.global.elevation.gain += other.global.elevation.gain; - this.global.elevation.loss += other.global.elevation.loss; - - this.global.bounds.southWest.lat = Math.min( - this.global.bounds.southWest.lat, - other.global.bounds.southWest.lat - ); - this.global.bounds.southWest.lon = Math.min( - this.global.bounds.southWest.lon, - other.global.bounds.southWest.lon - ); - this.global.bounds.northEast.lat = Math.max( - this.global.bounds.northEast.lat, - other.global.bounds.northEast.lat - ); - this.global.bounds.northEast.lon = Math.max( - this.global.bounds.northEast.lon, - other.global.bounds.northEast.lon - ); - - this.global.atemp.avg = - (this.global.atemp.count * this.global.atemp.avg + - other.global.atemp.count * other.global.atemp.avg) / - Math.max(1, this.global.atemp.count + other.global.atemp.count); - this.global.atemp.count += other.global.atemp.count; - this.global.hr.avg = - (this.global.hr.count * this.global.hr.avg + - other.global.hr.count * other.global.hr.avg) / - Math.max(1, this.global.hr.count + other.global.hr.count); - this.global.hr.count += other.global.hr.count; - this.global.cad.avg = - (this.global.cad.count * this.global.cad.avg + - other.global.cad.count * other.global.cad.avg) / - Math.max(1, this.global.cad.count + other.global.cad.count); - this.global.cad.count += other.global.cad.count; - this.global.power.avg = - (this.global.power.count * this.global.power.avg + - other.global.power.count * other.global.power.avg) / - Math.max(1, this.global.power.count + other.global.power.count); - this.global.power.count += other.global.power.count; - Object.keys(other.global.extensions).forEach((extension) => { - if (this.global.extensions[extension] === undefined) { - this.global.extensions[extension] = {}; - } - Object.keys(other.global.extensions[extension]).forEach((value) => { - if (this.global.extensions[extension][value] === undefined) { - this.global.extensions[extension][value] = 0; - } - this.global.extensions[extension][value] += - other.global.extensions[extension][value]; - }); - }); - } - - slice(start: number, end: number): GPXStatistics { - if (start < 0) { - start = 0; - } else if (start >= this.local.points.length) { - return new GPXStatistics(); - } - if (end < start) { - return new GPXStatistics(); - } else if (end >= this.local.points.length) { - end = this.local.points.length - 1; - } - - let statistics = new GPXStatistics(); - - statistics.local.points = this.local.points.slice(start, end + 1); - - statistics.global.distance.total = - this.local.distance.total[end] - this.local.distance.total[start]; - statistics.global.distance.moving = - this.local.distance.moving[end] - this.local.distance.moving[start]; - - statistics.global.time.start = this.local.points[start].time; - statistics.global.time.end = this.local.points[end].time; - - statistics.global.time.total = this.local.time.total[end] - this.local.time.total[start]; - statistics.global.time.moving = this.local.time.moving[end] - this.local.time.moving[start]; - - statistics.global.speed.moving = - statistics.global.time.moving > 0 - ? statistics.global.distance.moving / (statistics.global.time.moving / 3600) - : 0; - statistics.global.speed.total = - statistics.global.time.total > 0 - ? statistics.global.distance.total / (statistics.global.time.total / 3600) - : 0; - - statistics.global.elevation.gain = - this.local.elevation.gain[end] - this.local.elevation.gain[start]; - statistics.global.elevation.loss = - this.local.elevation.loss[end] - this.local.elevation.loss[start]; - - statistics.global.bounds.southWest.lat = this.global.bounds.southWest.lat; - statistics.global.bounds.southWest.lon = this.global.bounds.southWest.lon; - statistics.global.bounds.northEast.lat = this.global.bounds.northEast.lat; - statistics.global.bounds.northEast.lon = this.global.bounds.northEast.lon; - - statistics.global.atemp = this.global.atemp; - statistics.global.hr = this.global.hr; - statistics.global.cad = this.global.cad; - statistics.global.power = this.global.power; - - return statistics; - } -} - const earthRadius = 6371008.8; export function distance( coord1: TrackPoint | Coordinates, @@ -1951,9 +1675,9 @@ export function getElevationDistanceFunction(statistics: GPXStatistics) { if (point1.ele === undefined || point2.ele === undefined || point3.ele === undefined) { return 0; } - let x1 = statistics.local.distance.total[point1._data.index] * 1000; - let x2 = statistics.local.distance.total[point2._data.index] * 1000; - let x3 = statistics.local.distance.total[point3._data.index] * 1000; + let x1 = statistics.local.data[point1._data.index].distance.total * 1000; + let x2 = statistics.local.data[point2._data.index].distance.total * 1000; + let x3 = statistics.local.data[point3._data.index].distance.total * 1000; let y1 = point1.ele; let y2 = point2.ele; let y3 = point3.ele; @@ -1972,10 +1696,9 @@ function windowSmoothing( right: number, distance: (index1: number, index2: number) => number, window: number, - compute: (start: number, end: number) => number -): number[] { - let result = []; - + compute: (start: number, end: number) => number, + callback: (value: number, index: number) => void +): void { let start = left; for (var i = left; i < right; i++) { while (start + 1 < i && distance(start, i) > window) { @@ -1985,10 +1708,8 @@ function windowSmoothing( while (end < right && distance(i, end) <= window) { end++; } - result.push(compute(start, end - 1)); + callback(compute(start, end - 1), i); } - - return result; } function distanceWindowSmoothing( @@ -1996,30 +1717,35 @@ function distanceWindowSmoothing( right: number, statistics: GPXStatistics, window: number, - compute: (start: number, end: number) => number -): number[] { - return windowSmoothing( + compute: (start: number, end: number) => number, + callback: (value: number, index: number) => void +): void { + windowSmoothing( left, right, (index1, index2) => - statistics.local.distance.total[index2] - statistics.local.distance.total[index1], + statistics.local.data[index2].distance.total - + statistics.local.data[index1].distance.total, window, - compute + compute, + callback ); } function timeWindowSmoothing( points: TrackPoint[], window: number, - compute: (start: number, end: number) => number -): number[] { - return windowSmoothing( + compute: (start: number, end: number) => number, + callback: (value: number, index: number) => void +): void { + windowSmoothing( 0, points.length, (index1, index2) => points[index2].time?.getTime() - points[index1].time?.getTime() || 2 * window, window, - compute + compute, + callback ); } @@ -2071,14 +1797,14 @@ function withArtificialTimestamps( totalTime: number, lastPoint: TrackPoint | undefined, startTime: Date, - slope: number[] + statistics: GPXStatistics ): TrackPoint[] { let weight = []; let totalWeight = 0; for (let i = 0; i < points.length - 1; i++) { let dist = distance(points[i].getCoordinates(), points[i + 1].getCoordinates()); - let w = dist * (0.5 + 1 / (1 + Math.exp(-0.2 * slope[i]))); + let w = dist * (0.5 + 1 / (1 + Math.exp(-0.2 * statistics.local.data[i].slope.at))); weight.push(w); totalWeight += w; } diff --git a/gpx/src/index.ts b/gpx/src/index.ts index 1d79b1bc7..984e6893b 100644 --- a/gpx/src/index.ts +++ b/gpx/src/index.ts @@ -1,4 +1,5 @@ export * from './gpx'; +export * from './statistics'; export { Coordinates, LineStyleExtension, WaypointType } from './types'; export { parseGPX, buildGPX } from './io'; export * from './simplify'; diff --git a/gpx/src/statistics.ts b/gpx/src/statistics.ts new file mode 100644 index 000000000..f93d99188 --- /dev/null +++ b/gpx/src/statistics.ts @@ -0,0 +1,391 @@ +import { TrackPoint } from './gpx'; +import { Coordinates } from './types'; + +export class GPXGlobalStatistics { + length: number; + distance: { + moving: number; + total: number; + }; + time: { + start: Date | undefined; + end: Date | undefined; + moving: number; + total: number; + }; + speed: { + moving: number; + total: number; + }; + elevation: { + gain: number; + loss: number; + }; + bounds: { + southWest: Coordinates; + northEast: Coordinates; + }; + atemp: { + avg: number; + count: number; + }; + hr: { + avg: number; + count: number; + }; + cad: { + avg: number; + count: number; + }; + power: { + avg: number; + count: number; + }; + extensions: Record>; + + constructor() { + this.length = 0; + this.distance = { + moving: 0, + total: 0, + }; + this.time = { + start: undefined, + end: undefined, + moving: 0, + total: 0, + }; + this.speed = { + moving: 0, + total: 0, + }; + this.elevation = { + gain: 0, + loss: 0, + }; + this.bounds = { + southWest: { + lat: 90, + lon: 180, + }, + northEast: { + lat: -90, + lon: -180, + }, + }; + this.atemp = { + avg: 0, + count: 0, + }; + this.hr = { + avg: 0, + count: 0, + }; + this.cad = { + avg: 0, + count: 0, + }; + this.power = { + avg: 0, + count: 0, + }; + this.extensions = {}; + } + + mergeWith(other: GPXGlobalStatistics): void { + this.length += other.length; + + this.distance.total += other.distance.total; + this.distance.moving += other.distance.moving; + + this.time.start = + this.time.start !== undefined && other.time.start !== undefined + ? new Date(Math.min(this.time.start.getTime(), other.time.start.getTime())) + : (this.time.start ?? other.time.start); + this.time.end = + this.time.end !== undefined && other.time.end !== undefined + ? new Date(Math.max(this.time.end.getTime(), other.time.end.getTime())) + : (this.time.end ?? other.time.end); + + this.time.total += other.time.total; + this.time.moving += other.time.moving; + + this.speed.moving = + this.time.moving > 0 ? this.distance.moving / (this.time.moving / 3600) : 0; + this.speed.total = this.time.total > 0 ? this.distance.total / (this.time.total / 3600) : 0; + + this.elevation.gain += other.elevation.gain; + this.elevation.loss += other.elevation.loss; + + this.bounds.southWest.lat = Math.min(this.bounds.southWest.lat, other.bounds.southWest.lat); + this.bounds.southWest.lon = Math.min(this.bounds.southWest.lon, other.bounds.southWest.lon); + this.bounds.northEast.lat = Math.max(this.bounds.northEast.lat, other.bounds.northEast.lat); + this.bounds.northEast.lon = Math.max(this.bounds.northEast.lon, other.bounds.northEast.lon); + + this.atemp.avg = + (this.atemp.count * this.atemp.avg + other.atemp.count * other.atemp.avg) / + Math.max(1, this.atemp.count + other.atemp.count); + this.atemp.count += other.atemp.count; + this.hr.avg = + (this.hr.count * this.hr.avg + other.hr.count * other.hr.avg) / + Math.max(1, this.hr.count + other.hr.count); + this.hr.count += other.hr.count; + this.cad.avg = + (this.cad.count * this.cad.avg + other.cad.count * other.cad.avg) / + Math.max(1, this.cad.count + other.cad.count); + this.cad.count += other.cad.count; + this.power.avg = + (this.power.count * this.power.avg + other.power.count * other.power.avg) / + Math.max(1, this.power.count + other.power.count); + this.power.count += other.power.count; + + Object.keys(other.extensions).forEach((extension) => { + if (this.extensions[extension] === undefined) { + this.extensions[extension] = {}; + } + Object.keys(other.extensions[extension]).forEach((value) => { + if (this.extensions[extension][value] === undefined) { + this.extensions[extension][value] = 0; + } + this.extensions[extension][value] += other.extensions[extension][value]; + }); + }); + } +} + +export class TrackPointLocalStatistics { + distance: { + moving: number; + total: number; + }; + time: { + moving: number; + total: number; + }; + speed: number; + elevation: { + gain: number; + loss: number; + }; + slope: { + at: number; + segment: number; + length: number; + }; + + constructor() { + this.distance = { + moving: 0, + total: 0, + }; + this.time = { + moving: 0, + total: 0, + }; + this.speed = 0; + this.elevation = { + gain: 0, + loss: 0, + }; + this.slope = { + at: 0, + segment: 0, + length: 0, + }; + } +} + +export class GPXLocalStatistics { + points: TrackPoint[]; + data: TrackPointLocalStatistics[]; + + constructor() { + this.points = []; + this.data = []; + } +} + +export type TrackPointWithLocalStatistics = { + trkpt: TrackPoint; +} & TrackPointLocalStatistics; + +export class GPXStatistics { + global: GPXGlobalStatistics; + local: GPXLocalStatistics; + + constructor() { + this.global = new GPXGlobalStatistics(); + this.local = new GPXLocalStatistics(); + } + + sliced(start: number, end: number): GPXGlobalStatistics { + if (start < 0) { + start = 0; + } else if (start >= this.global.length) { + return new GPXGlobalStatistics(); + } + + if (end < start) { + return new GPXGlobalStatistics(); + } else if (end >= this.global.length) { + end = this.global.length - 1; + } + + if (start === 0 && end === this.global.length - 1) { + return this.global; + } + + let statistics = new GPXGlobalStatistics(); + + statistics.length = end - start + 1; + + statistics.distance.total = + this.local.data[end].distance.total - this.local.data[start].distance.total; + statistics.distance.moving = + this.local.data[end].distance.moving - this.local.data[start].distance.moving; + + statistics.time.start = this.local.points[start].time; + statistics.time.end = this.local.points[end].time; + + statistics.time.total = this.local.data[end].time.total - this.local.data[start].time.total; + statistics.time.moving = + this.local.data[end].time.moving - this.local.data[start].time.moving; + + statistics.speed.moving = + statistics.time.moving > 0 + ? statistics.distance.moving / (statistics.time.moving / 3600) + : 0; + statistics.speed.total = + statistics.time.total > 0 + ? statistics.distance.total / (statistics.time.total / 3600) + : 0; + + statistics.elevation.gain = + this.local.data[end].elevation.gain - this.local.data[start].elevation.gain; + statistics.elevation.loss = + this.local.data[end].elevation.loss - this.local.data[start].elevation.loss; + + statistics.bounds.southWest.lat = this.global.bounds.southWest.lat; + statistics.bounds.southWest.lon = this.global.bounds.southWest.lon; + statistics.bounds.northEast.lat = this.global.bounds.northEast.lat; + statistics.bounds.northEast.lon = this.global.bounds.northEast.lon; + + statistics.atemp = this.global.atemp; + statistics.hr = this.global.hr; + statistics.cad = this.global.cad; + statistics.power = this.global.power; + + return statistics; + } +} + +export class GPXStatisticsGroup { + private _statistics: GPXStatistics[]; + private _cumulative: GPXGlobalStatistics[]; + private _slice: [number, number] | null = null; + global: GPXGlobalStatistics; + + constructor() { + this._statistics = []; + this._cumulative = [new GPXGlobalStatistics()]; + this.global = new GPXGlobalStatistics(); + } + + add(statistics: GPXStatistics | GPXStatisticsGroup): void { + if (statistics instanceof GPXStatisticsGroup) { + statistics._statistics.forEach((stats) => this._add(stats)); + } else { + this._add(statistics); + } + } + + _add(statistics: GPXStatistics): void { + this._statistics.push(statistics); + const cumulative = new GPXGlobalStatistics(); + cumulative.mergeWith(this._cumulative[this._cumulative.length - 1]); + cumulative.mergeWith(statistics.global); + this._cumulative.push(cumulative); + this.global.mergeWith(statistics.global); + } + + sliced(start: number, end: number): GPXGlobalStatistics { + let sliced = new GPXGlobalStatistics(); + for (let i = 0; i < this._statistics.length; i++) { + const statistics = this._statistics[i]; + const cumulative = this._cumulative[i]; + if (start < cumulative.length + statistics.global.length && end >= cumulative.length) { + const localStart = Math.max(0, start - cumulative.length); + const localEnd = Math.min(statistics.global.length - 1, end - cumulative.length); + sliced.mergeWith(statistics.sliced(localStart, localEnd)); + } + } + return sliced; + } + + getTrackPoint(index: number): TrackPointWithLocalStatistics | undefined { + if (this._slice !== null) { + index += this._slice[0]; + } + for (let i = 0; i < this._statistics.length; i++) { + const statistics = this._statistics[i]; + const cumulative = this._cumulative[i]; + if (index < cumulative.length + statistics.global.length) { + return this._getTrackPoint(cumulative, statistics, index - cumulative.length); + } + } + return undefined; + } + + _getTrackPoint( + cumulative: GPXGlobalStatistics, + statistics: GPXStatistics, + index: number + ): TrackPointWithLocalStatistics { + const point = statistics.local.points[index]; + return { + trkpt: point, + distance: { + moving: statistics.local.data[index].distance.moving + cumulative.distance.moving, + total: statistics.local.data[index].distance.total + cumulative.distance.total, + }, + time: { + moving: statistics.local.data[index].time.moving + cumulative.time.moving, + total: statistics.local.data[index].time.total + cumulative.time.total, + }, + speed: statistics.local.data[index].speed, + elevation: { + gain: statistics.local.data[index].elevation.gain + cumulative.elevation.gain, + loss: statistics.local.data[index].elevation.loss + cumulative.elevation.loss, + }, + slope: { + at: statistics.local.data[index].slope.at, + segment: statistics.local.data[index].slope.segment, + length: statistics.local.data[index].slope.length, + }, + }; + } + + forEachTrackPoint( + callback: ( + point: TrackPoint, + distance: number, + speed: number, + slope: { at: number; segment: number; length: number }, + index: number + ) => void + ): void { + for (let i = 0; i < this._statistics.length; i++) { + const statistics = this._statistics[i]; + const cumulative = this._cumulative[i]; + statistics.local.points.forEach((point, index) => + callback( + point, + cumulative.distance.total + statistics.local.data[index].distance.total, + statistics.local.data[index].speed, + statistics.local.data[index].slope, + cumulative.length + index + ) + ); + } + } +} diff --git a/website/src/lib/components/GPXStatistics.svelte b/website/src/lib/components/GPXStatistics.svelte index 301a70aaf..ecc38577b 100644 --- a/website/src/lib/components/GPXStatistics.svelte +++ b/website/src/lib/components/GPXStatistics.svelte @@ -6,7 +6,7 @@ import { MoveDownRight, MoveUpRight, Ruler, Timer, Zap } from '@lucide/svelte'; import { i18n } from '$lib/i18n.svelte'; - import type { GPXStatistics } from 'gpx'; + import type { GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx'; import type { Readable } from 'svelte/store'; import { settings } from '$lib/logic/settings'; @@ -18,14 +18,14 @@ orientation, panelSize, }: { - gpxStatistics: Readable; - slicedGPXStatistics: Readable<[GPXStatistics, number, number] | undefined>; + gpxStatistics: Readable; + slicedGPXStatistics: Readable<[GPXGlobalStatistics, number, number] | undefined>; orientation: 'horizontal' | 'vertical'; panelSize: number; } = $props(); let statistics = $derived( - $slicedGPXStatistics !== undefined ? $slicedGPXStatistics[0] : $gpxStatistics + $slicedGPXStatistics !== undefined ? $slicedGPXStatistics[0] : $gpxStatistics.global ); @@ -42,15 +42,15 @@ - + - + - + {#if panelSize > 120 || orientation === 'horizontal'} @@ -64,13 +64,9 @@ > - + / - + {/if} @@ -83,9 +79,9 @@ > - + / - + {/if} diff --git a/website/src/lib/components/elevation-profile/ElevationProfile.svelte b/website/src/lib/components/elevation-profile/ElevationProfile.svelte index b9eb9dfe6..df469e107 100644 --- a/website/src/lib/components/elevation-profile/ElevationProfile.svelte +++ b/website/src/lib/components/elevation-profile/ElevationProfile.svelte @@ -18,7 +18,7 @@ Construction, } from '@lucide/svelte'; import type { Readable, Writable } from 'svelte/store'; - import type { GPXStatistics } from 'gpx'; + import type { GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx'; import { settings } from '$lib/logic/settings'; import { i18n } from '$lib/i18n.svelte'; import { ElevationProfile } from '$lib/components/elevation-profile/elevation-profile'; @@ -32,8 +32,8 @@ elevationFill, showControls = true, }: { - gpxStatistics: Readable; - slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>; + gpxStatistics: Readable; + slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>; additionalDatasets: Writable; elevationFill: Writable<'slope' | 'surface' | 'highway' | undefined>; showControls?: boolean; diff --git a/website/src/lib/components/elevation-profile/elevation-profile.ts b/website/src/lib/components/elevation-profile/elevation-profile.ts index f1ca8a6d8..54b398139 100644 --- a/website/src/lib/components/elevation-profile/elevation-profile.ts +++ b/website/src/lib/components/elevation-profile/elevation-profile.ts @@ -23,7 +23,7 @@ import Chart, { import mapboxgl from 'mapbox-gl'; import { get, type Readable, type Writable } from 'svelte/store'; import { map } from '$lib/components/map/map'; -import type { GPXStatistics } from 'gpx'; +import type { GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx'; import { mode } from 'mode-watcher'; import { getHighwayColor, getSlopeColor, getSurfaceColor } from '$lib/assets/colors'; @@ -54,14 +54,14 @@ export class ElevationProfile { private _dragging = false; private _panning = false; - private _gpxStatistics: Readable; - private _slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>; + private _gpxStatistics: Readable; + private _slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>; private _additionalDatasets: Readable; private _elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>; constructor( - gpxStatistics: Readable, - slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>, + gpxStatistics: Readable, + slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>, additionalDatasets: Readable, elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>, canvas: HTMLCanvasElement, @@ -342,7 +342,7 @@ export class ElevationProfile { if (evt.x - rect.left <= this._chart.chartArea.left) { return 0; } else if (evt.x - rect.left >= this._chart.chartArea.right) { - return get(this._gpxStatistics).local.points.length - 1; + return this._chart.data.datasets[0].data.length - 1; } else { return undefined; } @@ -375,7 +375,7 @@ export class ElevationProfile { startIndex = endIndex; } else if (startIndex !== endIndex) { this._slicedGPXStatistics.set([ - get(this._gpxStatistics).slice( + get(this._gpxStatistics).sliced( Math.min(startIndex, endIndex), Math.max(startIndex, endIndex) ), @@ -410,117 +410,89 @@ export class ElevationProfile { velocity: get(velocityUnits), temperature: get(temperatureUnits), }; + + const datasets: Array> = [[], [], [], [], [], []]; + data.forEachTrackPoint((trkpt, distance, speed, slope, index) => { + datasets[0].push({ + x: getConvertedDistance(distance, units.distance), + y: trkpt.ele ? getConvertedElevation(trkpt.ele, units.distance) : 0, + time: trkpt.time, + slope: slope, + extensions: trkpt.getExtensions(), + coordinates: trkpt.getCoordinates(), + index: index, + }); + if (data.global.time.total > 0) { + datasets[1].push({ + x: getConvertedDistance(distance, units.distance), + y: getConvertedVelocity(speed, units.velocity, units.distance), + index: index, + }); + } + if (data.global.hr.count > 0) { + datasets[2].push({ + x: getConvertedDistance(distance, units.distance), + y: trkpt.getHeartRate(), + index: index, + }); + } + if (data.global.cad.count > 0) { + datasets[3].push({ + x: getConvertedDistance(distance, units.distance), + y: trkpt.getCadence(), + index: index, + }); + } + if (data.global.atemp.count > 0) { + datasets[4].push({ + x: getConvertedDistance(distance, units.distance), + y: getConvertedTemperature(trkpt.getTemperature(), units.temperature), + index: index, + }); + } + if (data.global.power.count > 0) { + datasets[5].push({ + x: getConvertedDistance(distance, units.distance), + y: trkpt.getPower(), + index: index, + }); + } + }); + this._chart.data.datasets[0] = { label: i18n._('quantities.elevation'), - data: data.local.points.map((point, index) => { - return { - x: getConvertedDistance(data.local.distance.total[index], units.distance), - y: point.ele ? getConvertedElevation(point.ele, units.distance) : 0, - time: point.time, - slope: { - at: data.local.slope.at[index], - segment: data.local.slope.segment[index], - length: data.local.slope.length[index], - }, - extensions: point.getExtensions(), - coordinates: point.getCoordinates(), - index: index, - }; - }), + data: datasets[0], normalized: true, fill: 'start', order: 1, segment: {}, }; this._chart.data.datasets[1] = { - data: - data.global.time.total > 0 - ? data.local.points.map((point, index) => { - return { - x: getConvertedDistance( - data.local.distance.total[index], - units.distance - ), - y: getConvertedVelocity( - data.local.speed[index], - units.velocity, - units.distance - ), - index: index, - }; - }) - : [], + data: datasets[1], normalized: true, yAxisID: 'yspeed', }; this._chart.data.datasets[2] = { - data: - data.global.hr.count > 0 - ? data.local.points.map((point, index) => { - return { - x: getConvertedDistance( - data.local.distance.total[index], - units.distance - ), - y: point.getHeartRate(), - index: index, - }; - }) - : [], + data: datasets[2], normalized: true, yAxisID: 'yhr', }; this._chart.data.datasets[3] = { - data: - data.global.cad.count > 0 - ? data.local.points.map((point, index) => { - return { - x: getConvertedDistance( - data.local.distance.total[index], - units.distance - ), - y: point.getCadence(), - index: index, - }; - }) - : [], + data: datasets[3], normalized: true, yAxisID: 'ycad', }; this._chart.data.datasets[4] = { - data: - data.global.atemp.count > 0 - ? data.local.points.map((point, index) => { - return { - x: getConvertedDistance( - data.local.distance.total[index], - units.distance - ), - y: getConvertedTemperature(point.getTemperature(), units.temperature), - index: index, - }; - }) - : [], + data: datasets[4], normalized: true, yAxisID: 'yatemp', }; this._chart.data.datasets[5] = { - data: - data.global.power.count > 0 - ? data.local.points.map((point, index) => { - return { - x: getConvertedDistance( - data.local.distance.total[index], - units.distance - ), - y: point.getPower(), - index: index, - }; - }) - : [], + data: datasets[5], normalized: true, yAxisID: 'ypower', }; + this._chart.options.scales!.x!['min'] = 0; this._chart.options.scales!.x!['max'] = getConvertedDistance( data.global.distance.total, @@ -618,10 +590,12 @@ export class ElevationProfile { const gpxStatistics = get(this._gpxStatistics); let startPixel = this._chart.scales.x.getPixelForValue( - getConvertedDistance(gpxStatistics.local.distance.total[startIndex]) + getConvertedDistance( + gpxStatistics.getTrackPoint(startIndex)?.distance.total ?? 0 + ) ); let endPixel = this._chart.scales.x.getPixelForValue( - getConvertedDistance(gpxStatistics.local.distance.total[endIndex]) + getConvertedDistance(gpxStatistics.getTrackPoint(endIndex)?.distance.total ?? 0) ); selectionContext.fillRect( diff --git a/website/src/lib/components/export/Export.svelte b/website/src/lib/components/export/Export.svelte index 30d73822b..d2c7f8af6 100644 --- a/website/src/lib/components/export/Export.svelte +++ b/website/src/lib/components/export/Export.svelte @@ -21,7 +21,7 @@ SquareActivity, } from '@lucide/svelte'; import { i18n } from '$lib/i18n.svelte'; - import { GPXStatistics } from 'gpx'; + import { GPXGlobalStatistics } from 'gpx'; import { ListRootItem } from '$lib/components/file-list/file-list'; import { fileStateCollection } from '$lib/logic/file-state'; import { selection } from '$lib/logic/selection'; @@ -48,24 +48,24 @@ extensions: false, }; } else { - let statistics = $gpxStatistics; + let statistics = $gpxStatistics.global; if (exportState.current === ExportState.ALL) { statistics = Array.from(get(fileStateCollection).values()) .map((file) => file.statistics) .reduce((acc, cur) => { if (cur !== undefined) { - acc.mergeWith(cur.getStatisticsFor(new ListRootItem())); + acc.mergeWith(cur.getStatisticsFor(new ListRootItem()).global); } return acc; - }, new GPXStatistics()); + }, new GPXGlobalStatistics()); } return { - time: statistics.global.time.total === 0, - hr: statistics.global.hr.count === 0, - cad: statistics.global.cad.count === 0, - atemp: statistics.global.atemp.count === 0, - power: statistics.global.power.count === 0, - extensions: Object.keys(statistics.global.extensions).length === 0, + time: statistics.time.total === 0, + hr: statistics.hr.count === 0, + cad: statistics.cad.count === 0, + atemp: statistics.atemp.count === 0, + power: statistics.power.count === 0, + extensions: Object.keys(statistics.extensions).length === 0, }; } }); diff --git a/website/src/lib/components/file-list/FileListNodeLabel.svelte b/website/src/lib/components/file-list/FileListNodeLabel.svelte index d4fe4b2eb..78d970fe3 100644 --- a/website/src/lib/components/file-list/FileListNodeLabel.svelte +++ b/website/src/lib/components/file-list/FileListNodeLabel.svelte @@ -72,17 +72,15 @@ } let style = node.getStyle(defaultColor); - style.color.forEach((c) => { - if (!colors.includes(c)) { - colors.push(c); - } - }); + colors = style.color; } else if (node instanceof Track) { let style = node.getStyle(); - if (style) { - if (style['gpx_style:color'] && !colors.includes(style['gpx_style:color'])) { - colors.push(style['gpx_style:color']); - } + if ( + style && + style['gpx_style:color'] && + !colors.includes(style['gpx_style:color']) + ) { + colors.push(style['gpx_style:color']); } if (colors.length === 0) { let layer = gpxLayers.getLayer(item.getFileId()); diff --git a/website/src/lib/components/map/gpx-layer/distance-markers.ts b/website/src/lib/components/map/gpx-layer/distance-markers.ts index 99db27331..4a7f9d5c7 100644 --- a/website/src/lib/components/map/gpx-layer/distance-markers.ts +++ b/website/src/lib/components/map/gpx-layer/distance-markers.ts @@ -101,23 +101,17 @@ export class DistanceMarkers { getDistanceMarkersGeoJSON(): GeoJSON.FeatureCollection { let statistics = get(gpxStatistics); - let features = []; + let features: GeoJSON.Feature[] = []; let currentTargetDistance = 1; - for (let i = 0; i < statistics.local.distance.total.length; i++) { - if ( - statistics.local.distance.total[i] >= - getConvertedDistanceToKilometers(currentTargetDistance) - ) { + statistics.forEachTrackPoint((trkpt, dist) => { + if (dist >= getConvertedDistanceToKilometers(currentTargetDistance)) { let distance = currentTargetDistance.toFixed(0); let level = levels.find((level) => currentTargetDistance % level === 0) || 1; features.push({ type: 'Feature', geometry: { type: 'Point', - coordinates: [ - statistics.local.points[i].getLongitude(), - statistics.local.points[i].getLatitude(), - ], + coordinates: [trkpt.getLongitude(), trkpt.getLatitude()], }, properties: { distance, @@ -126,7 +120,7 @@ export class DistanceMarkers { } as GeoJSON.Feature); currentTargetDistance += 1; } - } + }); return { type: 'FeatureCollection', diff --git a/website/src/lib/components/map/gpx-layer/start-end-markers.ts b/website/src/lib/components/map/gpx-layer/start-end-markers.ts index a392685ad..cbf5fb217 100644 --- a/website/src/lib/components/map/gpx-layer/start-end-markers.ts +++ b/website/src/lib/components/map/gpx-layer/start-end-markers.ts @@ -34,13 +34,20 @@ export class StartEndMarkers { if (!map_) return; const tool = get(currentTool); - const statistics = get(slicedGPXStatistics)?.[0] ?? get(gpxStatistics); + const statistics = get(gpxStatistics); + const slicedStatistics = get(slicedGPXStatistics); const hidden = get(allHidden); - if (statistics.local.points.length > 0 && tool !== Tool.ROUTING && !hidden) { - this.start.setLngLat(statistics.local.points[0].getCoordinates()).addTo(map_); + if (statistics.global.length > 0 && tool !== Tool.ROUTING && !hidden) { + this.start + .setLngLat( + statistics.getTrackPoint(slicedStatistics?.[1] ?? 0)!.trkpt.getCoordinates() + ) + .addTo(map_); this.end .setLngLat( - statistics.local.points[statistics.local.points.length - 1].getCoordinates() + statistics + .getTrackPoint(slicedStatistics?.[2] ?? statistics.global.length - 1)! + .trkpt.getCoordinates() ) .addTo(map_); } else { diff --git a/website/src/lib/components/toolbar/tools/reduce/utils.svelte.ts b/website/src/lib/components/toolbar/tools/reduce/utils.svelte.ts index ad496ce9c..af11ca8c4 100644 --- a/website/src/lib/components/toolbar/tools/reduce/utils.svelte.ts +++ b/website/src/lib/components/toolbar/tools/reduce/utils.svelte.ts @@ -28,17 +28,15 @@ export class ReducedGPXLayer { update() { const file = this._fileState.file; - const stats = this._fileState.statistics; - if (!file || !stats) { + if (!file) { return; } file.forEachSegment((segment, trackIndex, segmentIndex) => { let segmentItem = new ListTrackSegmentItem(file._data.id, trackIndex, segmentIndex); - let statistics = stats.getStatisticsFor(segmentItem); this._updateSimplified(segmentItem.getFullId(), [ segmentItem, - statistics.local.points.length, - ramerDouglasPeucker(statistics.local.points, minTolerance), + segment.trkpt.length, + ramerDouglasPeucker(segment.trkpt, minTolerance), ]); }); } diff --git a/website/src/lib/components/toolbar/tools/routing/routing-controls.ts b/website/src/lib/components/toolbar/tools/routing/routing-controls.ts index d58ba212a..57fb3be89 100644 --- a/website/src/lib/components/toolbar/tools/routing/routing-controls.ts +++ b/website/src/lib/components/toolbar/tools/routing/routing-controls.ts @@ -793,24 +793,25 @@ export class RoutingControls { replacingDistance += distance(response[i - 1].getCoordinates(), response[i].getCoordinates()) / 1000; } + let startAnchorStats = stats.getTrackPoint(anchors[0].point._data.index)!; + let endAnchorStats = stats.getTrackPoint( + anchors[anchors.length - 1].point._data.index + )!; + let replacedDistance = - stats.local.distance.moving[anchors[anchors.length - 1].point._data.index] - - stats.local.distance.moving[anchors[0].point._data.index]; + endAnchorStats.distance.moving - startAnchorStats.distance.moving; let newDistance = stats.global.distance.moving + replacingDistance - replacedDistance; let newTime = (newDistance / stats.global.speed.moving) * 3600; let remainingTime = stats.global.time.moving - - (stats.local.time.moving[anchors[anchors.length - 1].point._data.index] - - stats.local.time.moving[anchors[0].point._data.index]); + (endAnchorStats.time.moving - startAnchorStats.time.moving); let replacingTime = newTime - remainingTime; if (replacingTime <= 0) { // Fallback to simple time difference - replacingTime = - stats.local.time.total[anchors[anchors.length - 1].point._data.index] - - stats.local.time.total[anchors[0].point._data.index]; + replacingTime = endAnchorStats.time.total - startAnchorStats.time.total; } speed = (replacingDistance / replacingTime) * 3600; @@ -820,9 +821,7 @@ export class RoutingControls { let endIndex = anchors[anchors.length - 1].point._data.index; startTime = new Date( (segment.trkpt[endIndex].time?.getTime() ?? 0) - - (replacingTime + - stats.local.time.total[endIndex] - - stats.local.time.moving[endIndex]) * + (replacingTime + endAnchorStats.time.total - endAnchorStats.time.moving) * 1000 ); } diff --git a/website/src/lib/components/toolbar/tools/scissors/Scissors.svelte b/website/src/lib/components/toolbar/tools/scissors/Scissors.svelte index 2b4e4d833..6b9aef8e6 100644 --- a/website/src/lib/components/toolbar/tools/scissors/Scissors.svelte +++ b/website/src/lib/components/toolbar/tools/scissors/Scissors.svelte @@ -26,12 +26,10 @@ let validSelection = $derived( $selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']) && - $gpxStatistics.local.points.length > 0 + $gpxStatistics.global.length > 0 ); let maxSliderValue = $derived( - validSelection && $gpxStatistics.local.points.length > 0 - ? $gpxStatistics.local.points.length - 1 - : 1 + validSelection && $gpxStatistics.global.length > 0 ? $gpxStatistics.global.length - 1 : 1 ); let sliderValues = $derived([0, maxSliderValue]); let canCrop = $derived(sliderValues[0] != 0 || sliderValues[1] != maxSliderValue); @@ -45,7 +43,7 @@ function updateSlicedGPXStatistics() { if (validSelection && canCrop) { $slicedGPXStatistics = [ - get(gpxStatistics).slice(sliderValues[0], sliderValues[1]), + get(gpxStatistics).sliced(sliderValues[0], sliderValues[1]), sliderValues[0], sliderValues[1], ]; diff --git a/website/src/lib/logic/file-actions.ts b/website/src/lib/logic/file-actions.ts index 0798e333a..8ffd4cbb1 100644 --- a/website/src/lib/logic/file-actions.ts +++ b/website/src/lib/logic/file-actions.ts @@ -215,7 +215,7 @@ export const fileActions = { reverseSelection: () => { if ( !get(selection).hasAnyChildren(new ListRootItem(), true, ['waypoints']) || - get(gpxStatistics).local.points?.length <= 1 + get(gpxStatistics).global.length <= 1 ) { return; } @@ -345,19 +345,20 @@ export const fileActions = { let startTime: Date | undefined = undefined; if (speed !== undefined) { if ( - statistics.local.points.length > 0 && - statistics.local.points[0].time !== undefined + statistics.global.length > 0 && + statistics.getTrackPoint(0)!.trkpt.time !== undefined ) { - startTime = statistics.local.points[0].time; + startTime = statistics.getTrackPoint(0)!.trkpt.time; } else { - let index = statistics.local.points.findIndex( - (point) => point.time !== undefined - ); - if (index !== -1 && statistics.local.points[index].time) { - startTime = new Date( - statistics.local.points[index].time.getTime() - - (1000 * 3600 * statistics.local.distance.total[index]) / speed - ); + for (let i = 0; i < statistics.global.length; i++) { + const point = statistics.getTrackPoint(i)!; + if (point.trkpt.time !== undefined) { + startTime = new Date( + point.trkpt.time.getTime() - + (1000 * 3600 * point.distance.total) / speed + ); + break; + } } } } diff --git a/website/src/lib/logic/statistics-tree.ts b/website/src/lib/logic/statistics-tree.ts index a1ee5869d..6ef8041c3 100644 --- a/website/src/lib/logic/statistics-tree.ts +++ b/website/src/lib/logic/statistics-tree.ts @@ -1,5 +1,5 @@ import { ListItem, ListLevel } from '$lib/components/file-list/file-list'; -import { GPXFile, GPXStatistics, type Track } from 'gpx'; +import { GPXFile, GPXStatistics, GPXStatisticsGroup, type Track } from 'gpx'; export class GPXStatisticsTree { level: ListLevel; @@ -21,35 +21,26 @@ export class GPXStatisticsTree { } } - getStatisticsFor(item: ListItem): GPXStatistics { - let statistics = []; + getStatisticsFor(item: ListItem): GPXStatisticsGroup { + let statistics = new GPXStatisticsGroup(); let id = item.getIdAtLevel(this.level); if (id === undefined || id === 'waypoints') { Object.keys(this.statistics).forEach((key) => { if (this.statistics[key] instanceof GPXStatistics) { - statistics.push(this.statistics[key]); + statistics.add(this.statistics[key]); } else { - statistics.push(this.statistics[key].getStatisticsFor(item)); + statistics.add(this.statistics[key].getStatisticsFor(item)); } }); } else { let child = this.statistics[id]; if (child instanceof GPXStatistics) { - statistics.push(child); + statistics.add(child); } else if (child !== undefined) { - statistics.push(child.getStatisticsFor(item)); + statistics.add(child.getStatisticsFor(item)); } } - if (statistics.length === 0) { - return new GPXStatistics(); - } else if (statistics.length === 1) { - return statistics[0]; - } else { - return statistics.reduce((acc, curr) => { - acc.mergeWith(curr); - return acc; - }, new GPXStatistics()); - } + return statistics; } } export type GPXFileWithStatistics = { file: GPXFile; statistics: GPXStatisticsTree }; diff --git a/website/src/lib/logic/statistics.ts b/website/src/lib/logic/statistics.ts index cf3a09f0d..1193c21a0 100644 --- a/website/src/lib/logic/statistics.ts +++ b/website/src/lib/logic/statistics.ts @@ -1,5 +1,5 @@ import { selection } from '$lib/logic/selection'; -import { GPXStatistics } from 'gpx'; +import { GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx'; import { fileStateCollection, GPXFileState } from '$lib/logic/file-state'; import { ListFileItem, @@ -12,7 +12,7 @@ import { settings } from '$lib/logic/settings'; const { fileOrder } = settings; export class SelectedGPXStatistics { - private _statistics: Writable; + private _statistics: Writable; private _files: Map< string, { @@ -22,18 +22,21 @@ export class SelectedGPXStatistics { >; constructor() { - this._statistics = writable(new GPXStatistics()); + this._statistics = writable(new GPXStatisticsGroup()); this._files = new Map(); selection.subscribe(() => this.update()); fileOrder.subscribe(() => this.update()); } - subscribe(run: (value: GPXStatistics) => void, invalidate?: (value?: GPXStatistics) => void) { + subscribe( + run: (value: GPXStatisticsGroup) => void, + invalidate?: (value?: GPXStatisticsGroup) => void + ) { return this._statistics.subscribe(run, invalidate); } update() { - let statistics = new GPXStatistics(); + let statistics = new GPXStatisticsGroup(); selection.applyToOrderedSelectedItemsFromFile((fileId, level, items) => { let stats = fileStateCollection.getStatistics(fileId); if (stats) { @@ -43,7 +46,7 @@ export class SelectedGPXStatistics { !(item instanceof ListWaypointItem || item instanceof ListWaypointsItem) || first ) { - statistics.mergeWith(stats.getStatisticsFor(item)); + statistics.add(stats.getStatisticsFor(item)); first = false; } }); @@ -76,7 +79,7 @@ export class SelectedGPXStatistics { export const gpxStatistics = new SelectedGPXStatistics(); -export const slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined> = +export const slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined> = writable(undefined); gpxStatistics.subscribe(() => {