diff --git a/.prettierignore b/.prettierignore index d59d9d574..705b6c3cb 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,6 +1,3 @@ -# Ignore files for PNPM, NPM and YARN -pnpm-lock.yaml -package-lock.json -yarn.lock -src/lib/components/ui -*.mdx \ No newline at end of file +website/src/lib/components/ui +website/src/lib/docs/**/*.mdx +**/*.webmanifest \ No newline at end of file diff --git a/LICENSE b/LICENSE index c2ce4a3ae..846b46dd9 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 gpx.studio +Copyright (c) 2026 gpx.studio Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/gpx/package.json b/gpx/package.json index 07b54c037..3f6318aa1 100644 --- a/gpx/package.json +++ b/gpx/package.json @@ -25,7 +25,7 @@ "scripts": { "build": "tsc", "postinstall": "npm run build", - "lint": "prettier --check . && eslint .", - "format": "prettier --write ." + "lint": "prettier --check . --config ../.prettierrc && eslint .", + "format": "prettier --write . --config ../.prettierrc" } } diff --git a/gpx/src/gpx.ts b/gpx/src/gpx.ts index 24051b180..b7ab9da6b 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()); } @@ -148,7 +140,9 @@ export class GPXFile extends GPXTreeNode { }, }, }; - this.wpt = gpx.wpt ? gpx.wpt.map((waypoint) => new Waypoint(waypoint)) : []; + this.wpt = gpx.wpt + ? gpx.wpt.map((waypoint, index) => new Waypoint(waypoint, index)) + : []; this.trk = gpx.trk ? gpx.trk.map((track) => new Track(track)) : []; if (gpx.rte && gpx.rte.length > 0) { this.trk = this.trk.concat(gpx.rte.map((route) => convertRouteToTrack(route))); @@ -186,9 +180,6 @@ export class GPXFile extends GPXTreeNode { segment._data['segmentIndex'] = segmentIndex; }); }); - this.wpt.forEach((waypoint, waypointIndex) => { - waypoint._data['index'] = waypointIndex; - }); } get children(): Array { @@ -209,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) => { @@ -220,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 && @@ -245,6 +242,10 @@ export class GPXFile extends GPXTreeNode { width: [], } ); + if (style.color.length === 0 && defaultColor) { + style.color.push(defaultColor); + } + return style; } clone(): GPXFile { @@ -807,7 +808,7 @@ export class TrackSegment extends GPXTreeLeaf { constructor(segment?: (TrackSegmentType & { _data?: any }) | TrackSegment) { super(); if (segment) { - this.trkpt = segment.trkpt.map((point) => new TrackPoint(point)); + this.trkpt = segment.trkpt.map((point, index) => new TrackPoint(point, index)); if (segment.hasOwnProperty('_data')) { this._data = segment._data; } @@ -819,12 +820,12 @@ export class TrackSegment extends GPXTreeLeaf { _computeStatistics(): GPXStatistics { let statistics = new GPXStatistics(); - statistics.local.points = this.trkpt.map((point) => point); + 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++) { - points[i]._data['index'] = i; - // distance let dist = 0; if (i > 0) { @@ -833,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 @@ -860,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( @@ -961,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; @@ -987,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, @@ -1041,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; } ); } @@ -1292,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 } @@ -1307,6 +1326,7 @@ export class TrackSegment extends GPXTreeLeaf { } } +const emptyExtensions: Record = {}; export class TrackPoint { [immerable] = true; @@ -1317,7 +1337,7 @@ export class TrackPoint { _data: { [key: string]: any } = {}; - constructor(point: (TrackPointType & { _data?: any }) | TrackPoint) { + constructor(point: (TrackPointType & { _data?: any }) | TrackPoint, index?: number) { this.attributes = point.attributes; this.ele = point.ele; this.time = point.time; @@ -1325,6 +1345,9 @@ export class TrackPoint { if (point.hasOwnProperty('_data')) { this._data = point._data; } + if (index !== undefined) { + this._data.index = index; + } } getCoordinates(): Coordinates { @@ -1399,7 +1422,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 { @@ -1469,11 +1492,18 @@ export class TrackPoint { clone(): TrackPoint { return new TrackPoint({ - attributes: cloneJSON(this.attributes), + attributes: { + lat: this.attributes.lat, + lon: this.attributes.lon, + }, ele: this.ele, time: this.time ? new Date(this.time.getTime()) : undefined, - extensions: cloneJSON(this.extensions), - _data: cloneJSON(this._data), + extensions: this.extensions ? cloneJSON(this.extensions) : undefined, + _data: { + index: this._data?.index, + anchor: this._data?.anchor, + zoom: this._data?.zoom, + }, }); } } @@ -1492,7 +1522,7 @@ export class Waypoint { type?: string; _data: { [key: string]: any } = {}; - constructor(waypoint: (WaypointType & { _data?: any }) | Waypoint) { + constructor(waypoint: (WaypointType & { _data?: any }) | Waypoint, index?: number) { this.attributes = waypoint.attributes; this.ele = waypoint.ele; this.time = waypoint.time; @@ -1511,6 +1541,9 @@ export class Waypoint { if (waypoint.hasOwnProperty('_data')) { this._data = waypoint._data; } + if (index !== undefined) { + this._data.index = index; + } } getCoordinates(): Coordinates { @@ -1558,7 +1591,10 @@ export class Waypoint { clone(): Waypoint { return new Waypoint({ - attributes: cloneJSON(this.attributes), + attributes: { + lat: this.attributes.lat, + lon: this.attributes.lon, + }, ele: this.ele, time: this.time ? new Date(this.time.getTime()) : undefined, name: this.name, @@ -1607,305 +1643,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, @@ -1939,9 +1676,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; @@ -1960,10 +1697,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) { @@ -1973,10 +1709,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( @@ -1984,30 +1718,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 ); } @@ -2059,14 +1798,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/simplify.ts b/gpx/src/simplify.ts index d55e5c231..7669b3cd3 100644 --- a/gpx/src/simplify.ts +++ b/gpx/src/simplify.ts @@ -59,13 +59,13 @@ function ramerDouglasPeuckerRecursive( } export function crossarcDistance( - point1: TrackPoint, - point2: TrackPoint, + point1: TrackPoint | Coordinates, + point2: TrackPoint | Coordinates, point3: TrackPoint | Coordinates ): number { return crossarc( - point1.getCoordinates(), - point2.getCoordinates(), + point1 instanceof TrackPoint ? point1.getCoordinates() : point1, + point2 instanceof TrackPoint ? point2.getCoordinates() : point2, point3 instanceof TrackPoint ? point3.getCoordinates() : point3 ); } 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/components.json b/website/components.json index 89bed7d71..f5e3a96a1 100644 --- a/website/components.json +++ b/website/components.json @@ -1,17 +1,17 @@ { - "$schema": "https://shadcn-svelte.com/schema.json", - "style": "default", - "tailwind": { - "css": "src/app.css", - "baseColor": "slate" - }, - "aliases": { - "components": "$lib/components", - "utils": "$lib/utils", - "ui": "$lib/components/ui", - "hooks": "$lib/hooks", - "lib": "$lib" - }, - "typescript": true, - "registry": "https://shadcn-svelte.com/registry" + "$schema": "https://shadcn-svelte.com/schema.json", + "style": "default", + "tailwind": { + "css": "src/app.css", + "baseColor": "slate" + }, + "aliases": { + "components": "$lib/components", + "utils": "$lib/utils", + "ui": "$lib/components/ui", + "hooks": "$lib/hooks", + "lib": "$lib" + }, + "typescript": true, + "registry": "https://shadcn-svelte.com/registry" } diff --git a/website/package-lock.json b/website/package-lock.json index 4c243283e..a4e6d3904 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -14,7 +14,7 @@ "@mapbox/sphericalmercator": "^2.0.1", "@mapbox/tilebelt": "^2.0.2", "@types/mapbox__sphericalmercator": "^1.2.3", - "chart.js": "^4.4.9", + "chart.js": "^4.5.1", "chartjs-plugin-zoom": "^2.2.0", "clsx": "^2.1.1", "dexie": "^4.0.11", @@ -22,7 +22,7 @@ "gpx": "file:../gpx", "immer": "^10.1.1", "jszip": "^3.10.1", - "mapbox-gl": "^3.16.0", + "mapbox-gl": "^3.17.0", "mapillary-js": "^4.1.2", "png.js": "^0.2.1", "sanitize-html": "^2.17.0", @@ -47,7 +47,7 @@ "@types/sortablejs": "^1.15.8", "@typescript-eslint/eslint-plugin": "^8.33.1", "@typescript-eslint/parser": "^8.33.1", - "bits-ui": "^2.12.0", + "bits-ui": "^2.14.4", "eslint": "^9.28.0", "eslint-config-prettier": "^10.1.5", "eslint-plugin-svelte": "^3.9.1", @@ -3241,9 +3241,9 @@ ] }, "node_modules/bits-ui": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.12.0.tgz", - "integrity": "sha512-8NF4ILNyAJlIxDXpl/akGXGBV5QmZAe+8gTfPttM5P6/+LrijumcSfFXY5cr4QkXwTmLA7H5stYpbgJf2XFJvg==", + "version": "2.14.4", + "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.14.4.tgz", + "integrity": "sha512-W6kenhnbd/YVvur+DKkaVJ6GldE53eLewur5AhUCqslYQ0vjZr8eWlOfwZnMiPB+PF5HMVqf61vXBvmyrAmPWg==", "dev": true, "license": "MIT", "dependencies": { @@ -3664,9 +3664,9 @@ } }, "node_modules/chart.js": { - "version": "4.4.9", - "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.9.tgz", - "integrity": "sha512-EyZ9wWKgpAU0fLJ43YAEIF8sr5F2W3LqbS40ZJyHIner2lY14ufqv2VMp69MAiZ2rpwxEUxEhIH/0U3xyRynxg==", + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", "license": "MIT", "dependencies": { "@kurkle/color": "^0.3.0" @@ -6069,12 +6069,14 @@ } }, "node_modules/mapbox-gl": { - "version": "3.16.0", - "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.16.0.tgz", - "integrity": "sha512-rluV1Zp/0oHf1Y9BV+nePRNnKyTdljko3E19CzO5rBqtQaNUYS0ePCMPRtxOuWRwSdKp3f9NWJkOCjemM8nmjw==", + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.17.0.tgz", + "integrity": "sha512-nCrDKRlr5di6xUksUDslNWwxroJ5yv1hT8pyVFtcpWJOOKsYQxF/wOFTMie8oxMnXeFkrz1Tl1TwA1XN1yX0KA==", "license": "SEE LICENSE IN LICENSE.txt", "workspaces": [ "src/style-spec", + "test/build/vite", + "test/build/webpack", "test/build/typings" ], "dependencies": { @@ -6102,7 +6104,6 @@ "pbf": "^4.0.1", "potpack": "^2.0.0", "quickselect": "^3.0.0", - "serialize-to-js": "^3.1.2", "supercluster": "^8.0.1", "tinyqueue": "^3.0.0" } @@ -7634,14 +7635,6 @@ "node": ">=10" } }, - "node_modules/serialize-to-js": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/serialize-to-js/-/serialize-to-js-3.1.2.tgz", - "integrity": "sha512-owllqNuDDEimQat7EPG0tH7JjO090xKNzUtYz6X+Sk2BXDnOCilDdNLwjWeFywG9xkJul1ULvtUQa9O4pUaY0w==", - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/set-cookie-parser": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.0.tgz", diff --git a/website/package.json b/website/package.json index 6457d8896..c03e5d40d 100644 --- a/website/package.json +++ b/website/package.json @@ -10,8 +10,8 @@ "preview": "vite preview", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", - "lint": "prettier --check . && eslint .", - "format": "prettier --write ." + "lint": "prettier --check . --config ../.prettierrc --ignore-path ../.prettierignore --ignore-path ./.gitignore && eslint .", + "format": "prettier --write . --config ../.prettierrc --ignore-path ../.prettierignore --ignore-path ./.gitignore" }, "devDependencies": { "@lucide/svelte": "^0.544.0", @@ -31,7 +31,7 @@ "@types/sortablejs": "^1.15.8", "@typescript-eslint/eslint-plugin": "^8.33.1", "@typescript-eslint/parser": "^8.33.1", - "bits-ui": "^2.12.0", + "bits-ui": "^2.14.4", "eslint": "^9.28.0", "eslint-config-prettier": "^10.1.5", "eslint-plugin-svelte": "^3.9.1", @@ -66,7 +66,7 @@ "@mapbox/sphericalmercator": "^2.0.1", "@mapbox/tilebelt": "^2.0.2", "@types/mapbox__sphericalmercator": "^1.2.3", - "chart.js": "^4.4.9", + "chart.js": "^4.5.1", "chartjs-plugin-zoom": "^2.2.0", "clsx": "^2.1.1", "dexie": "^4.0.11", @@ -74,7 +74,7 @@ "gpx": "file:../gpx", "immer": "^10.1.1", "jszip": "^3.10.1", - "mapbox-gl": "^3.16.0", + "mapbox-gl": "^3.17.0", "mapillary-js": "^4.1.2", "png.js": "^0.2.1", "sanitize-html": "^2.17.0", diff --git a/website/src/app.css b/website/src/app.css index d93b269f9..a3300b90e 100644 --- a/website/src/app.css +++ b/website/src/app.css @@ -1,126 +1,126 @@ -@import "tailwindcss"; -@import "tw-animate-css"; - +@import 'tailwindcss'; +@import 'tw-animate-css'; + @custom-variant dark (&:is(.dark *)); - + :root { - --background: hsl(0 0% 100%) /* <- Wrap in HSL */; - --foreground: hsl(240 10% 3.9%); - --muted: hsl(240 4.8% 95.9%); - --muted-foreground: hsl(240 3.8% 46.1%); - --popover: hsl(0 0% 100%); - --popover-foreground: hsl(240 10% 3.9%); - --card: hsl(0 0% 100%); - --card-foreground: hsl(240 10% 3.9%); - --border: hsl(240 5.9% 90%); - --input: hsl(240 5.9% 90%); - --primary: hsl(240 5.9% 10%); - --primary-foreground: hsl(0 0% 98%); - --secondary: hsl(240 4.8% 95.9%); - --secondary-foreground: hsl(240 5.9% 10%); - --accent: hsl(240 4.8% 95.9%); - --accent-foreground: hsl(240 5.9% 10%); - --destructive: hsl(0 72.2% 50.6%); - --destructive-foreground: hsl(0 0% 98%); - --ring: hsl(240 10% 3.9%); - --sidebar: hsl(0 0% 98%); - --sidebar-foreground: hsl(240 5.3% 26.1%); - --sidebar-primary: hsl(240 5.9% 10%); - --sidebar-primary-foreground: hsl(0 0% 98%); - --sidebar-accent: hsl(240 4.8% 95.9%); - --sidebar-accent-foreground: hsl(240 5.9% 10%); - --sidebar-border: hsl(220 13% 91%); - --sidebar-ring: hsl(217.2 91.2% 59.8%); + --background: hsl(0 0% 100%) /* <- Wrap in HSL */; + --foreground: hsl(240 10% 3.9%); + --muted: hsl(240 4.8% 95.9%); + --muted-foreground: hsl(240 3.8% 46.1%); + --popover: hsl(0 0% 100%); + --popover-foreground: hsl(240 10% 3.9%); + --card: hsl(0 0% 100%); + --card-foreground: hsl(240 10% 3.9%); + --border: hsl(240 5.9% 90%); + --input: hsl(240 5.9% 90%); + --primary: hsl(240 5.9% 10%); + --primary-foreground: hsl(0 0% 98%); + --secondary: hsl(240 4.8% 95.9%); + --secondary-foreground: hsl(240 5.9% 10%); + --accent: hsl(240 4.8% 95.9%); + --accent-foreground: hsl(240 5.9% 10%); + --destructive: hsl(0 72.2% 50.6%); + --destructive-foreground: hsl(0 0% 98%); + --ring: hsl(240 10% 3.9%); + --sidebar: hsl(0 0% 98%); + --sidebar-foreground: hsl(240 5.3% 26.1%); + --sidebar-primary: hsl(240 5.9% 10%); + --sidebar-primary-foreground: hsl(0 0% 98%); + --sidebar-accent: hsl(240 4.8% 95.9%); + --sidebar-accent-foreground: hsl(240 5.9% 10%); + --sidebar-border: hsl(220 13% 91%); + --sidebar-ring: hsl(217.2 91.2% 59.8%); - --support: rgb(220 15 130); - --link: rgb(0 110 180); - --selection: hsl(240 4.8% 93%); - - --radius: 0.5rem; + --support: rgb(220 15 130); + --link: rgb(0 110 180); + --selection: hsl(240 4.8% 93%); + + --radius: 0.5rem; } - + .dark { - --background: hsl(240 10% 3.9%); - --foreground: hsl(0 0% 98%); - --muted: hsl(240 3.7% 15.9%); - --muted-foreground: hsl(240 5% 64.9%); - --popover: hsl(240 10% 3.9%); - --popover-foreground: hsl(0 0% 98%); - --card: hsl(240 10% 3.9%); - --card-foreground: hsl(0 0% 98%); - --border: hsl(240 3.7% 15.9%); - --input: hsl(240 3.7% 15.9%); - --primary: hsl(0 0% 98%); - --primary-foreground: hsl(240 5.9% 10%); - --secondary: hsl(240 3.7% 15.9%); - --secondary-foreground: hsl(0 0% 98%); - --accent: hsl(240 3.7% 15.9%); - --accent-foreground: hsl(0 0% 98%); - --destructive: hsl(0 62.8% 30.6%); - --destructive-foreground: hsl(0 0% 98%); - --ring: hsl(240 4.9% 83.9%); - --sidebar: hsl(240 5.9% 10%); - --sidebar-foreground: hsl(240 4.8% 95.9%); - --sidebar-primary: hsl(224.3 76.3% 48%); - --sidebar-primary-foreground: hsl(0 0% 100%); - --sidebar-accent: hsl(240 3.7% 15.9%); - --sidebar-accent-foreground: hsl(240 4.8% 95.9%); - --sidebar-border: hsl(240 3.7% 15.9%); - --sidebar-ring: hsl(217.2 91.2% 59.8%); + --background: hsl(240 10% 3.9%); + --foreground: hsl(0 0% 98%); + --muted: hsl(240 3.7% 15.9%); + --muted-foreground: hsl(240 5% 64.9%); + --popover: hsl(240 10% 3.9%); + --popover-foreground: hsl(0 0% 98%); + --card: hsl(240 10% 3.9%); + --card-foreground: hsl(0 0% 98%); + --border: hsl(240 3.7% 15.9%); + --input: hsl(240 3.7% 15.9%); + --primary: hsl(0 0% 98%); + --primary-foreground: hsl(240 5.9% 10%); + --secondary: hsl(240 3.7% 15.9%); + --secondary-foreground: hsl(0 0% 98%); + --accent: hsl(240 3.7% 15.9%); + --accent-foreground: hsl(0 0% 98%); + --destructive: hsl(0 62.8% 30.6%); + --destructive-foreground: hsl(0 0% 98%); + --ring: hsl(240 4.9% 83.9%); + --sidebar: hsl(240 5.9% 10%); + --sidebar-foreground: hsl(240 4.8% 95.9%); + --sidebar-primary: hsl(224.3 76.3% 48%); + --sidebar-primary-foreground: hsl(0 0% 100%); + --sidebar-accent: hsl(240 3.7% 15.9%); + --sidebar-accent-foreground: hsl(240 4.8% 95.9%); + --sidebar-border: hsl(240 3.7% 15.9%); + --sidebar-ring: hsl(217.2 91.2% 59.8%); - --support: rgb(255 110 190); - --link: rgb(80 190 255); - --selection: hsl(240 3.7% 22%); + --support: rgb(255 110 190); + --link: rgb(80 190 255); + --selection: hsl(240 3.7% 22%); } - + @theme inline { - /* Radius (for rounded-*) */ - --radius-sm: calc(var(--radius) - 4px); - --radius-md: calc(var(--radius) - 2px); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) + 4px); - - /* Colors */ - --color-background: var(--background); - --color-foreground: var(--foreground); - --color-muted: var(--muted); - --color-muted-foreground: var(--muted-foreground); - --color-popover: var(--popover); - --color-popover-foreground: var(--popover-foreground); - --color-card: var(--card); - --color-card-foreground: var(--card-foreground); - --color-border: var(--border); - --color-input: var(--input); - --color-primary: var(--primary); - --color-primary-foreground: var(--primary-foreground); - --color-secondary: var(--secondary); - --color-secondary-foreground: var(--secondary-foreground); - --color-accent: var(--accent); - --color-accent-foreground: var(--accent-foreground); - --color-destructive: var(--destructive); - --color-destructive-foreground: var(--destructive-foreground); - --color-ring: var(--ring); - --color-radius: var(--radius); - --color-sidebar: var(--sidebar); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-ring: var(--sidebar-ring); - --color-support: var(--support); - --color-link: var(--link); + /* Radius (for rounded-*) */ + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); - --breakpoint-xs: 540px; + /* Colors */ + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-ring: var(--ring); + --color-radius: var(--radius); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + --color-support: var(--support); + --color-link: var(--link); + + --breakpoint-xs: 540px; } - + @layer base { - * { - @apply border-border; - } - - body { - @apply bg-background text-foreground; - } -} \ No newline at end of file + * { + @apply border-border; + } + + body { + @apply bg-background text-foreground; + } +} diff --git a/website/src/lib/assets/layers.ts b/website/src/lib/assets/layers.ts index ce31acf54..44b1cf72e 100644 --- a/website/src/lib/assets/layers.ts +++ b/website/src/lib/assets/layers.ts @@ -22,7 +22,7 @@ import { Binoculars, Toilet, } from 'lucide-static'; -import { type StyleSpecification } from 'mapbox-gl'; +import { type RasterDEMSourceSpecification, type StyleSpecification } from 'mapbox-gl'; import ignFrTopo from './custom/ign-fr-topo.json'; import ignFrPlan from './custom/ign-fr-plan.json'; import ignFrSatellite from './custom/ign-fr-satellite.json'; @@ -368,6 +368,42 @@ export const overlays: { [key: string]: string | StyleSpecification } = { ], }, bikerouterGravel: bikerouterGravel as StyleSpecification, + openRailwayMap: { + version: 8, + sources: { + openRailwayMap: { + type: 'raster', + tiles: ['https://tiles.openrailwaymap.org/standard/{z}/{x}/{y}.png'], + tileSize: 256, + maxzoom: 19, + attribution: + 'Data © OpenStreetMap contributors, Style: CC-BY-SA 2.0 OpenRailwayMap', + }, + }, + layers: [ + { + id: 'openRailwayMap', + type: 'raster', + source: 'openRailwayMap', + }, + ], + }, + mapterhornHillshade: { + version: 8, + sources: { + mapterhornHillshade: { + type: 'raster-dem', + url: 'https://tiles.mapterhorn.com/tilejson.json', + }, + }, + layers: [ + { + id: 'mapterhornHillshade', + type: 'hillshade', + source: 'mapterhornHillshade', + }, + ], + }, swisstopoSlope: { version: 8, sources: { @@ -799,8 +835,10 @@ export const overlayTree: LayerTreeType = { waymarkedTrailsHorseRiding: true, waymarkedTrailsWinter: true, }, - cyclOSMlite: true, bikerouterGravel: true, + cyclOSMlite: true, + mapterhornHillshade: true, + openRailwayMap: true, }, countries: { france: { @@ -883,8 +921,10 @@ export const defaultOverlays: LayerTreeType = { waymarkedTrailsHorseRiding: false, waymarkedTrailsWinter: false, }, - cyclOSMlite: false, bikerouterGravel: false, + cyclOSMlite: false, + mapterhornHillshade: false, + openRailwayMap: false, }, countries: { france: { @@ -1018,8 +1058,10 @@ export const defaultOverlayTree: LayerTreeType = { waymarkedTrailsHorseRiding: false, waymarkedTrailsWinter: false, }, - cyclOSMlite: false, bikerouterGravel: false, + cyclOSMlite: false, + mapterhornHillshade: false, + openRailwayMap: false, }, countries: { france: { @@ -1411,3 +1453,18 @@ export const overpassQueryData: Record = { symbol: 'Anchor', }, }; + +export const terrainSources: { [key: string]: RasterDEMSourceSpecification } = { + 'mapbox-dem': { + type: 'raster-dem', + url: 'mapbox://mapbox.mapbox-terrain-dem-v1', + tileSize: 512, + maxzoom: 14, + }, + mapterhorn: { + type: 'raster-dem', + url: 'https://tiles.mapterhorn.com/tilejson.json', + }, +}; + +export const defaultTerrainSource = 'mapbox-dem'; diff --git a/website/src/lib/components/Footer.svelte b/website/src/lib/components/Footer.svelte index 97f60ad4a..d6ae72222 100644 --- a/website/src/lib/components/Footer.svelte +++ b/website/src/lib/components/Footer.svelte @@ -18,7 +18,7 @@ href="https://github.com/gpxstudio/gpx.studio/blob/main/LICENSE" target="_blank" > - MIT © 2025 gpx.studio + MIT © 2026 gpx.studio 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/Menu.svelte b/website/src/lib/components/Menu.svelte index 518867158..23cd8f630 100644 --- a/website/src/lib/components/Menu.svelte +++ b/website/src/lib/components/Menu.svelte @@ -538,6 +538,7 @@ let targetInput = e && e.target && + e.target instanceof HTMLElement && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT' || 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 7690e72f9..54b398139 100644 --- a/website/src/lib/components/elevation-profile/elevation-profile.ts +++ b/website/src/lib/components/elevation-profile/elevation-profile.ts @@ -14,11 +14,16 @@ import { getTemperatureWithUnits, getVelocityWithUnits, } from '$lib/units'; -import Chart from 'chart.js/auto'; +import Chart, { + type ChartEvent, + type ChartOptions, + type ScriptableLineSegmentContext, + type TooltipItem, +} from 'chart.js/auto'; 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'; @@ -27,6 +32,20 @@ const { distanceUnits, velocityUnits, temperatureUnits } = settings; Chart.defaults.font.family = 'ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"'; // Tailwind CSS font +interface ElevationProfilePoint { + x: number; + y: number; + time?: Date; + slope: { + at: number; + segment: number; + length: number; + }; + extensions: Record; + coordinates: [number, number]; + index: number; +} + export class ElevationProfile { private _chart: Chart | null = null; private _canvas: HTMLCanvasElement; @@ -35,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, @@ -90,7 +109,7 @@ export class ElevationProfile { } initialize() { - let options = { + let options: ChartOptions<'line'> = { animation: false, parsing: false, maintainAspectRatio: false, @@ -98,8 +117,8 @@ export class ElevationProfile { x: { type: 'linear', ticks: { - callback: function (value: number) { - return `${value.toFixed(1).replace(/\.0+$/, '')} ${getDistanceUnits()}`; + callback: function (value: number | string) { + return `${(value as number).toFixed(1).replace(/\.0+$/, '')} ${getDistanceUnits()}`; }, align: 'inner', maxRotation: 0, @@ -108,8 +127,8 @@ export class ElevationProfile { y: { type: 'linear', ticks: { - callback: function (value: number) { - return getElevationWithUnits(value, false); + callback: function (value: number | string) { + return getElevationWithUnits(value as number, false); }, }, }, @@ -140,8 +159,8 @@ export class ElevationProfile { title: () => { return ''; }, - label: (context: Chart.TooltipContext) => { - let point = context.raw; + label: (context: TooltipItem<'line'>) => { + let point = context.raw as ElevationProfilePoint; if (context.datasetIndex === 0) { const map_ = get(map); if (map_ && this._marker) { @@ -165,10 +184,10 @@ export class ElevationProfile { return `${i18n._('quantities.power')}: ${getPowerWithUnits(point.y)}`; } }, - afterBody: (contexts: Chart.TooltipContext[]) => { + afterBody: (contexts: TooltipItem<'line'>[]) => { let context = contexts.filter((context) => context.datasetIndex === 0); if (context.length === 0) return; - let point = context[0].raw; + let point = context[0].raw as ElevationProfilePoint; let slope = { at: point.slope.at.toFixed(1), segment: point.slope.segment.toFixed(1), @@ -227,6 +246,7 @@ export class ElevationProfile { onPanStart: () => { this._panning = true; this._slicedGPXStatistics.set(undefined); + return true; }, onPanComplete: () => { this._panning = false; @@ -238,13 +258,13 @@ export class ElevationProfile { }, mode: 'x', onZoomStart: ({ chart, event }: { chart: Chart; event: any }) => { + if (!this._chart) { + return false; + } + const maxZoom = this._chart.getInitialScaleBounds()?.x?.max ?? 0; if ( event.deltaY < 0 && - Math.abs( - this._chart.getInitialScaleBounds().x.max / - this._chart.options.plugins.zoom.limits.x.minRange - - this._chart.getZoomLevel() - ) < 0.01 + Math.abs(maxZoom / this._chart.getZoomLevel()) < 0.01 ) { // Disable wheel pan if zoomed in to the max, and zooming in return false; @@ -262,7 +282,6 @@ export class ElevationProfile { }, }, }, - stacked: false, onResize: () => { this.updateOverlay(); }, @@ -270,7 +289,7 @@ export class ElevationProfile { let datasets: string[] = ['speed', 'hr', 'cad', 'atemp', 'power']; datasets.forEach((id) => { - options.scales[`y${id}`] = { + options.scales![`y${id}`] = { type: 'linear', position: 'right', grid: { @@ -291,7 +310,7 @@ export class ElevationProfile { { id: 'toggleMarker', events: ['mouseout'], - afterEvent: (chart: Chart, args: { event: Chart.ChartEvent }) => { + afterEvent: (chart: Chart, args: { event: ChartEvent }) => { if (args.event.type === 'mouseout') { const map_ = get(map); if (map_ && this._marker) { @@ -305,7 +324,7 @@ export class ElevationProfile { let startIndex = 0; let endIndex = 0; - const getIndex = (evt) => { + const getIndex = (evt: PointerEvent) => { if (!this._chart) { return undefined; } @@ -323,22 +342,22 @@ 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; } } - let point = points.find((point) => point.element.raw); + const point = points.find((point) => (point.element as any).raw); if (point) { - return point.element.raw.index; + return (point.element as any).raw.index; } else { return points[0].index; } }; let dragStarted = false; - const onMouseDown = (evt) => { + const onMouseDown = (evt: PointerEvent) => { if (evt.shiftKey) { // Panning interaction return; @@ -347,7 +366,7 @@ export class ElevationProfile { this._canvas.style.cursor = 'col-resize'; startIndex = getIndex(evt); }; - const onMouseMove = (evt) => { + const onMouseMove = (evt: PointerEvent) => { if (dragStarted) { this._dragging = true; endIndex = getIndex(evt); @@ -356,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) ), @@ -367,7 +386,7 @@ export class ElevationProfile { } } }; - const onMouseUp = (evt) => { + const onMouseUp = (evt: PointerEvent) => { dragStarted = false; this._dragging = false; this._canvas.style.cursor = ''; @@ -386,85 +405,99 @@ export class ElevationProfile { return; } const data = get(this._gpxStatistics); + const units = { + distance: get(distanceUnits), + 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]), - y: point.ele ? getConvertedElevation(point.ele) : 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.local.points.map((point, index) => { - return { - x: getConvertedDistance(data.local.distance.total[index]), - y: getConvertedVelocity(data.local.speed[index]), - index: index, - }; - }), + data: datasets[1], normalized: true, yAxisID: 'yspeed', }; this._chart.data.datasets[2] = { - data: data.local.points.map((point, index) => { - return { - x: getConvertedDistance(data.local.distance.total[index]), - y: point.getHeartRate(), - index: index, - }; - }), + data: datasets[2], normalized: true, yAxisID: 'yhr', }; this._chart.data.datasets[3] = { - data: data.local.points.map((point, index) => { - return { - x: getConvertedDistance(data.local.distance.total[index]), - y: point.getCadence(), - index: index, - }; - }), + data: datasets[3], normalized: true, yAxisID: 'ycad', }; this._chart.data.datasets[4] = { - data: data.local.points.map((point, index) => { - return { - x: getConvertedDistance(data.local.distance.total[index]), - y: getConvertedTemperature(point.getTemperature()), - index: index, - }; - }), + data: datasets[4], normalized: true, yAxisID: 'yatemp', }; this._chart.data.datasets[5] = { - data: data.local.points.map((point, index) => { - return { - x: getConvertedDistance(data.local.distance.total[index]), - 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); + + this._chart.options.scales!.x!['min'] = 0; + this._chart.options.scales!.x!['max'] = getConvertedDistance( + data.global.distance.total, + units.distance + ); this.setVisibility(); this.setFill(); @@ -513,21 +546,24 @@ export class ElevationProfile { return; } const elevationFill = get(this._elevationFill); + const dataset = this._chart.data.datasets[0]; + let segment: any = {}; if (elevationFill === 'slope') { - this._chart.data.datasets[0]['segment'] = { + segment = { backgroundColor: this.slopeFillCallback, }; } else if (elevationFill === 'surface') { - this._chart.data.datasets[0]['segment'] = { + segment = { backgroundColor: this.surfaceFillCallback, }; } else if (elevationFill === 'highway') { - this._chart.data.datasets[0]['segment'] = { + segment = { backgroundColor: this.highwayFillCallback, }; } else { - this._chart.data.datasets[0]['segment'] = {}; + segment = {}; } + Object.assign(dataset, { segment }); } updateOverlay() { @@ -554,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( @@ -575,19 +613,22 @@ export class ElevationProfile { } } - slopeFillCallback(context) { - return getSlopeColor(context.p0.raw.slope.segment); + slopeFillCallback(context: ScriptableLineSegmentContext & { p0: { raw: any } }) { + const point = context.p0.raw as ElevationProfilePoint; + return getSlopeColor(point.slope.segment); } - surfaceFillCallback(context) { - return getSurfaceColor(context.p0.raw.extensions.surface); + surfaceFillCallback(context: ScriptableLineSegmentContext & { p0: { raw: any } }) { + const point = context.p0.raw as ElevationProfilePoint; + return getSurfaceColor(point.extensions.surface); } - highwayFillCallback(context) { + highwayFillCallback(context: ScriptableLineSegmentContext & { p0: { raw: any } }) { + const point = context.p0.raw as ElevationProfilePoint; return getHighwayColor( - context.p0.raw.extensions.highway, - context.p0.raw.extensions.sac_scale, - context.p0.raw.extensions.mtb_scale + point.extensions.highway, + point.extensions.sac_scale, + point.extensions.mtb_scale ); } 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/gpx-layer.ts b/website/src/lib/components/map/gpx-layer/gpx-layer.ts index 2a1aae332..4439fd9a0 100644 --- a/website/src/lib/components/map/gpx-layer/gpx-layer.ts +++ b/website/src/lib/components/map/gpx-layer/gpx-layer.ts @@ -1,5 +1,5 @@ import { get, type Readable } from 'svelte/store'; -import mapboxgl from 'mapbox-gl'; +import mapboxgl, { type FilterSpecification } from 'mapbox-gl'; import { map } from '$lib/components/map/map'; import { waypointPopup, trackpointPopup } from './gpx-layer-popup'; import { @@ -153,8 +153,6 @@ export class GPXLayer { return; } - this.loadIcons(); - if ( file._data.style && file._data.style.color && @@ -164,6 +162,8 @@ export class GPXLayer { this.layerColor = `#${file._data.style.color}`; } + this.loadIcons(); + try { let source = _map.getSource(this.fileId) as mapboxgl.GeoJSONSource | undefined; if (source) { @@ -281,25 +281,23 @@ export class GPXLayer { } } - let visibleSegments: [number, number][] = []; + let visibleTrackSegmentIds: string[] = []; file.forEachSegment((segment, trackIndex, segmentIndex) => { if (!segment._data.hidden) { - visibleSegments.push([trackIndex, segmentIndex]); + visibleTrackSegmentIds.push(`${trackIndex}-${segmentIndex}`); } }); + const segmentFilter: FilterSpecification = [ + 'in', + ['get', 'trackSegmentId'], + ['literal', visibleTrackSegmentIds], + ]; - _map.setFilter( - this.fileId, - [ - 'any', - ...visibleSegments.map(([trackIndex, segmentIndex]) => [ - 'all', - ['==', 'trackIndex', trackIndex], - ['==', 'segmentIndex', segmentIndex], - ]), - ], - { validate: false } - ); + _map.setFilter(this.fileId, segmentFilter, { validate: false }); + + if (_map.getLayer(this.fileId + '-direction')) { + _map.setFilter(this.fileId + '-direction', segmentFilter, { validate: false }); + } let visibleWaypoints: number[] = []; file.wpt.forEach((waypoint, waypointIndex) => { @@ -313,21 +311,6 @@ export class GPXLayer { ['in', ['get', 'waypointIndex'], ['literal', visibleWaypoints]], { validate: false } ); - - if (_map.getLayer(this.fileId + '-direction')) { - _map.setFilter( - this.fileId + '-direction', - [ - 'any', - ...visibleSegments.map(([trackIndex, segmentIndex]) => [ - 'all', - ['==', 'trackIndex', trackIndex], - ['==', 'segmentIndex', segmentIndex], - ]), - ], - { validate: false } - ); - } } catch (e) { // No reliable way to check if the map is ready to add sources and layers return; @@ -686,6 +669,7 @@ export class GPXLayer { } feature.properties.trackIndex = trackIndex; feature.properties.segmentIndex = segmentIndex; + feature.properties.trackSegmentId = `${trackIndex}-${segmentIndex}`; segmentIndex++; if (segmentIndex >= file.trk[trackIndex].trkseg.length) { @@ -718,7 +702,7 @@ export class GPXLayer { properties: { fileId: this.fileId, waypointIndex: index, - icon: `${this.fileId}-waypoint-${getSymbolKey(waypoint.sym) ?? 'default'}`, + icon: `waypoint-${getSymbolKey(waypoint.sym) ?? 'default'}-${this.layerColor}`, }, }); }); @@ -739,7 +723,7 @@ export class GPXLayer { }); symbols.forEach((symbol) => { - const iconId = `${this.fileId}-waypoint-${symbol ?? 'default'}`; + const iconId = `waypoint-${symbol ?? 'default'}-${this.layerColor}`; if (!_map.hasImage(iconId)) { let icon = new Image(100, 100); icon.onload = () => { 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/map/layer-control/LayerControl.svelte b/website/src/lib/components/map/layer-control/LayerControl.svelte index d8d279453..9f0b1a137 100644 --- a/website/src/lib/components/map/layer-control/LayerControl.svelte +++ b/website/src/lib/components/map/layer-control/LayerControl.svelte @@ -101,9 +101,7 @@ acc: Record, imprt: ImportSpecification ) => { - if ( - !['basemap', 'overlays', 'glyphs-and-sprite'].includes(imprt.id) - ) { + if (!['basemap', 'overlays'].includes(imprt.id)) { acc[imprt.id] = imprt; } return acc; diff --git a/website/src/lib/components/map/layer-control/LayerControlSettings.svelte b/website/src/lib/components/map/layer-control/LayerControlSettings.svelte index 7278371ae..5675bae8a 100644 --- a/website/src/lib/components/map/layer-control/LayerControlSettings.svelte +++ b/website/src/lib/components/map/layer-control/LayerControlSettings.svelte @@ -13,6 +13,7 @@ overlays, overlayTree, overpassTree, + terrainSources, } from '$lib/assets/layers'; import { getLayers, isSelected, toggle } from '$lib/components/map/layer-control/utils'; import { i18n } from '$lib/i18n.svelte'; @@ -31,6 +32,7 @@ currentOverpassQueries, customLayers, opacities, + terrainSource, } = settings; const { isLayerFromExtension, getLayerName } = extensionAPI; @@ -54,7 +56,7 @@ } $effect(() => { - if ($selectedBasemapTree && $currentBasemap) { + if (open && $selectedBasemapTree && $currentBasemap) { if (!isSelected($selectedBasemapTree, $currentBasemap)) { if (!isSelected($selectedBasemapTree, defaultBasemap)) { $selectedBasemapTree = toggle($selectedBasemapTree, defaultBasemap); @@ -65,7 +67,7 @@ }); $effect(() => { - if ($selectedOverlayTree) { + if (open && $selectedOverlayTree) { untrack(() => { if ($currentOverlays) { let overlayLayers = getLayers($currentOverlays); @@ -86,7 +88,7 @@ }); $effect(() => { - if ($selectedOverpassTree) { + if (open && $selectedOverpassTree) { untrack(() => { if ($currentOverpassQueries) { let overlayLayers = getLayers($currentOverpassQueries); @@ -233,6 +235,23 @@ + + {i18n._('layers.terrain')} + + + + {i18n._(`layers.label.${$terrainSource}`)} + + + {#each Object.keys(terrainSources) as id} + + {i18n._(`layers.label.${id}`)} + + {/each} + + + + diff --git a/website/src/lib/components/map/map.ts b/website/src/lib/components/map/map.ts index 5b31fe291..2983f3f47 100644 --- a/website/src/lib/components/map/map.ts +++ b/website/src/lib/components/map/map.ts @@ -3,8 +3,16 @@ import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder'; import { get, writable, type Writable } from 'svelte/store'; import { settings } from '$lib/logic/settings'; import { tick } from 'svelte'; +import { terrainSources } from '$lib/assets/layers'; -const { treeFileView, elevationProfile, bottomPanelSize, rightPanelSize, distanceUnits } = settings; +const { + treeFileView, + elevationProfile, + bottomPanelSize, + rightPanelSize, + distanceUnits, + terrainSource, +} = settings; let fitBoundsOptions: mapboxgl.MapOptions['fitBoundsOptions'] = { maxZoom: 15, @@ -35,17 +43,6 @@ export class MapboxGLMap { sources: {}, layers: [], imports: [ - { - id: 'glyphs-and-sprite', // make Mapbox glyphs and sprite available to other styles - url: '', - data: { - version: 8, - sources: {}, - layers: [], - glyphs: 'mapbox://fonts/mapbox/{fontstack}/{range}.pbf', - sprite: 'mapbox://sprites/mapbox/outdoors-v12', - }, - }, { id: 'basemap', url: '', @@ -134,39 +131,26 @@ export class MapboxGLMap { }); map.addControl(scaleControl); map.on('style.load', () => { - map.addSource('mapbox-dem', { - type: 'raster-dem', - url: 'mapbox://mapbox.mapbox-terrain-dem-v1', - tileSize: 512, - maxzoom: 14, - }); - if (map.getPitch() > 0) { - map.setTerrain({ - source: 'mapbox-dem', - exaggeration: 1, - }); - } map.setFog({ color: 'rgb(186, 210, 235)', 'high-color': 'rgb(36, 92, 223)', 'horizon-blend': 0.1, 'space-color': 'rgb(156, 240, 255)', }); - map.on('pitch', () => { - if (map.getPitch() > 0) { - map.setTerrain({ - source: 'mapbox-dem', - exaggeration: 1, - }); - } else { - map.setTerrain(null); - } - }); + map.on('pitch', this.setTerrain.bind(this)); + this.setTerrain(); + }); + map.on('style.import.load', () => { + const basemap = map.getStyle().imports?.find((imprt) => imprt.id === 'basemap'); + if (basemap && basemap.data && basemap.data.glyphs) { + map.setGlyphsUrl(basemap.data.glyphs); + } }); map.on('load', () => { this._map.set(map); // only set the store after the map has loaded window._map = map; // entry point for extensions this.resize(); + this.setTerrain(); scaleControl.setUnit(get(distanceUnits)); this._onLoadCallbacks.forEach((callback) => callback(map)); @@ -182,6 +166,7 @@ export class MapboxGLMap { scaleControl.setUnit(units); }) ); + this._unsubscribes.push(terrainSource.subscribe(() => this.setTerrain())); } onLoad(callback: (map: mapboxgl.Map) => void) { @@ -222,6 +207,24 @@ export class MapboxGLMap { } } } + + setTerrain() { + const map = get(this._map); + if (map) { + const source = get(terrainSource); + if (!map.getSource(source)) { + map.addSource(source, terrainSources[source]); + } + if (map.getPitch() > 0) { + map.setTerrain({ + source: source, + exaggeration: 1, + }); + } else { + map.setTerrain(null); + } + } + } } export const map = new MapboxGLMap(); 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/docs/de/home/mapbox.mdx b/website/src/lib/docs/de/home/mapbox.mdx index d9d7c4126..6c4372a89 100644 --- a/website/src/lib/docs/de/home/mapbox.mdx +++ b/website/src/lib/docs/de/home/mapbox.mdx @@ -1,5 +1,5 @@ Mapbox ist das Unternehmen, das einige der schönen Karten auf dieser Website zur Verfügung stellt. Sie entwickeln auch die Karten-Engine welche **gpx.studio** unterstützt. -Wir sind äusserst glücklich und dankbar, Teil ihres Community Programms zu sein, das gemeinnützige Organisationen, Bildungseinrichtungen und Organisationen mit positivem Einfluss unterstützt. +Wir sind äußerst glücklich und dankbar, Teil ihres Community Programms zu sein, das gemeinnützige Organisationen, Bildungseinrichtungen und Organisationen mit positivem Einfluss unterstützt. Diese Partnerschaft ermöglicht es **gpx.studio**, von den Mapbox-Tools zu ermäßigten Preisen zu profitieren, was erheblich zur finanziellen Tragfähigkeit des Projekts beiträgt und es uns ermöglicht, die bestmögliche Benutzererfahrung zu bieten. diff --git a/website/src/lib/docs/it/files-and-stats.mdx b/website/src/lib/docs/it/files-and-stats.mdx index a72f05f97..9fc453316 100644 --- a/website/src/lib/docs/it/files-and-stats.mdx +++ b/website/src/lib/docs/it/files-and-stats.mdx @@ -50,7 +50,7 @@ Facendo clic destro su una scheda file, è possibile accedere alle stesse azioni Come accennato nella [sezione opzioni di visualizzazione](./menu/view), è possibile passare a un layout ad albero per l'elenco dei file. Questo layout è ideale per gestire un gran numero di file aperti, organizzandoli in una lista verticale sul lato destro della mappa. -Inoltre, la vista ad albero dei file consente di ispezionare [tracce, segmenti e punti di interesse](./gpx) all'interno dei file attraverso sezioni espandibili. +Inoltre, la vista ad albero dei file consente d'ispezionare [tracce, segmenti e punti di interesse](./gpx) all'interno dei file attraverso sezioni espandibili. Puoi anche applicare [modifiche](./menu/edit) e [strumenti](./toolbar) agli elementi interni del file. Inoltre, è possibile trascinare e rilasciare gli elementi per riordinarli, o spostarli nella gerarchia o anche in un altro file. @@ -78,7 +78,7 @@ Quando si passa sopra il profilo di elevazione, un suggerimento mostrerà le sta Per ottenere le statistiche per una sezione specifica del profilo di elevazione, è possibile trascinare un rettangolo di selezione sul profilo. Fare clic sul profilo per resettare la selezione. -È inoltre possibile utilizzare la rotellina del mouse per ingrandire e rimpicciolire sul profilo di elevazione, e spostarsi a sinistra e a destra trascinando il profilo tenendo premuto il tasto Maiusc. +È inoltre possibile utilizzare la rotellina del mouse per ingrandire e rimpicciolire sul profilo di elevazione, e spostarsi a sinistra e a destra trascinando il profilo tenendo premuto il tasto Maiuscolo.
Aiuta a mantenere il sito gratuito (e senza pubblicità) Ogni volta che aggiungi o sposti i punti GPS, i nostri server calcolano il percorso migliore sulla rete stradale. -Utilizziamo anche le API di Mapbox per visualizzare mappe gradevoli, recuperare i dati altimetrici e consentire la ricerca di luoghi. +Utilizziamo anche le API di Mapbox per visualizzare mappe stupende, recuperare i dati altimetrici e consentire la ricerca di luoghi. -Sfortunatamente, questo è costoso. +Sfortunatamente, fare tutto ciò è costoso. Se ti piace utilizzare questo strumento e lo trovi utile, per favore considera di fare una piccola donazione per aiutare a mantenere il sito web gratuito e senza pubblicità. Grazie mille per il vostro supporto! ❤️ diff --git a/website/src/lib/docs/vi/home/funding.mdx b/website/src/lib/docs/vi/home/funding.mdx index ad60b05e8..db88fd7a4 100644 --- a/website/src/lib/docs/vi/home/funding.mdx +++ b/website/src/lib/docs/vi/home/funding.mdx @@ -2,7 +2,7 @@ import { HeartHandshake } from '@lucide/svelte'; -## Help keep the website free (and ad-free) +## Hãy giúp duy trì trang web miễn phí (và không có quảng cáo) Khi bạn thêm hoặc di chuyển các điểm định vị, máy chủ của chúng tôi sẽ tính toán đoạn đường tốt nhất trên mạng lưới giao thông. Chúng tôi cũng sử dụng các API từ Mapbox để hiển thị đa dạng các bản đồ, lưu trữ các dữ liệu độ cao cũng như giúp bạn có thể tìm kiếm các địa điểm khác nhau. diff --git a/website/src/lib/docs/vi/home/mapbox.mdx b/website/src/lib/docs/vi/home/mapbox.mdx index 3085ec537..86a0d1a9a 100644 --- a/website/src/lib/docs/vi/home/mapbox.mdx +++ b/website/src/lib/docs/vi/home/mapbox.mdx @@ -1,5 +1,5 @@ -Mapbox is the company that provides some of the beautiful maps on this website. -They also develop the map engine which powers **gpx.studio**. +Mapbox là công ty cung cấp một số bản đồ đẹp trên trang web này. +Họ cũng phát triển công cụ bản đồ cung cấp sức mạnh cho **gpx.studio**. -We are incredibly fortunate and grateful to be part of their Community program, which supports nonprofits, educational institutions, and positive impact organizations. -This partnership allows **gpx.studio** to benefit from Mapbox tools at discounted prices, greatly contributing to the financial viability of the project and enabling us to offer the best possible user experience. +Chúng tôi vô cùng may mắn và biết ơn khi được tham gia chương trình Cộng đồng của họ, chương trình hỗ trợ các tổ chức phi lợi nhuận, các tổ chức giáo dục và các tổ chức tạo ra tác động tích cực. +Sự hợp tác này cho phép **gpx.studio** được hưởng lợi từ các công cụ của Mapbox với giá ưu đãi, góp phần đáng kể vào tính khả thi về tài chính của dự án và giúp chúng tôi mang đến trải nghiệm người dùng tốt nhất có thể. diff --git a/website/src/lib/docs/vi/menu/edit.mdx b/website/src/lib/docs/vi/menu/edit.mdx index ac0b4e9ab..252488c82 100644 --- a/website/src/lib/docs/vi/menu/edit.mdx +++ b/website/src/lib/docs/vi/menu/edit.mdx @@ -9,8 +9,8 @@ title: Edit actions # { title } -Unlike the file actions, the edit actions can potentially modify the content of the currently selected files. -Moreover, when the tree layout of the files list is enabled (see [Files and statistics](../files-and-stats)), they can also be applied to [tracks, segments, and points of interest](../gpx). +Không giống như các thao tác trên tệp, các thao tác chỉnh sửa có thể thay đổi nội dung của các tệp hiện đang được chọn. +Hơn nữa, khi bố cục dạng cây của danh sách tệp được bật (xem [Tệp và thống kê](../files-and-stats)), chúng cũng có thể được áp dụng cho [đường đi, đoạn đường và điểm quan tâm](../gpx). Therefore, we will refer to the elements that can be modified by these actions as _file items_. Note that except for the undo and redo actions, the edit actions are also accessible through the context menu (right-click) of the file items. diff --git a/website/src/lib/docs/zh/menu/file.mdx b/website/src/lib/docs/zh/menu/file.mdx index 2e470ad00..d9c1ade51 100644 --- a/website/src/lib/docs/zh/menu/file.mdx +++ b/website/src/lib/docs/zh/menu/file.mdx @@ -29,9 +29,9 @@ title: 文件 创建当前选中文件的副本。 -### Delete +### 删除 -Delete the currently selected files. +删除当前选中的文件。 ### 删除全部 diff --git a/website/src/lib/logic/file-actions.ts b/website/src/lib/logic/file-actions.ts index f8771e727..8ffd4cbb1 100644 --- a/website/src/lib/logic/file-actions.ts +++ b/website/src/lib/logic/file-actions.ts @@ -17,7 +17,6 @@ import { import { i18n } from '$lib/i18n.svelte'; import { freeze, type WritableDraft } from 'immer'; import { - distance, GPXFile, parseGPX, Track, @@ -30,7 +29,7 @@ import { } from 'gpx'; import { get } from 'svelte/store'; import { settings } from '$lib/logic/settings'; -import { getClosestLinePoint, getElevation } from '$lib/utils'; +import { getClosestLinePoint, getClosestTrackSegments, getElevation } from '$lib/utils'; import { gpxStatistics } from '$lib/logic/statistics'; import { boundsManager } from './bounds'; @@ -216,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; } @@ -346,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; + } } } } @@ -453,34 +453,13 @@ export const fileActions = { selection.applyToOrderedSelectedItemsFromFile((fileId, level, items) => { if (level === ListLevel.FILE) { let file = fileStateCollection.getFile(fileId); - if (file) { + let statistics = fileStateCollection.getStatistics(fileId); + if (file && statistics) { if (file.trk.length > 1) { let fileIds = getFileIds(file.trk.length); - let closest = file.wpt.map((wpt, wptIndex) => { - return { - wptIndex: wptIndex, - index: [0], - distance: Number.MAX_VALUE, - }; - }); - file.trk.forEach((track, index) => { - track.getSegments().forEach((segment) => { - segment.trkpt.forEach((point) => { - file.wpt.forEach((wpt, wptIndex) => { - let dist = distance( - point.getCoordinates(), - wpt.getCoordinates() - ); - if (dist < closest[wptIndex].distance) { - closest[wptIndex].distance = dist; - closest[wptIndex].index = [index]; - } else if (dist === closest[wptIndex].distance) { - closest[wptIndex].index.push(index); - } - }); - }); - }); - }); + let closest = file.wpt.map((wpt) => + getClosestTrackSegments(file, statistics, wpt.getCoordinates()) + ); file.trk.forEach((track, index) => { let newFile = file.clone(); let tracks = track.trkseg.map((segment, segmentIndex) => { @@ -495,9 +474,11 @@ export const fileActions = { newFile.replaceWaypoints( 0, file.wpt.length - 1, - closest - .filter((c) => c.index.includes(index)) - .map((c) => file.wpt[c.wptIndex]) + file.wpt.filter((wpt, wptIndex) => + closest[wptIndex].some( + ([trackIndex, segmentIndex]) => trackIndex === index + ) + ) ); newFile._data.id = fileIds[index]; newFile.metadata.name = @@ -506,29 +487,9 @@ export const fileActions = { }); } else if (file.trk.length === 1) { let fileIds = getFileIds(file.trk[0].trkseg.length); - let closest = file.wpt.map((wpt, wptIndex) => { - return { - wptIndex: wptIndex, - index: [0], - distance: Number.MAX_VALUE, - }; - }); - file.trk[0].trkseg.forEach((segment, index) => { - segment.trkpt.forEach((point) => { - file.wpt.forEach((wpt, wptIndex) => { - let dist = distance( - point.getCoordinates(), - wpt.getCoordinates() - ); - if (dist < closest[wptIndex].distance) { - closest[wptIndex].distance = dist; - closest[wptIndex].index = [index]; - } else if (dist === closest[wptIndex].distance) { - closest[wptIndex].index.push(index); - } - }); - }); - }); + let closest = file.wpt.map((wpt) => + getClosestTrackSegments(file, statistics, wpt.getCoordinates()) + ); file.trk[0].trkseg.forEach((segment, index) => { let newFile = file.clone(); newFile.replaceTrackSegments(0, 0, file.trk[0].trkseg.length - 1, [ @@ -537,9 +498,11 @@ export const fileActions = { newFile.replaceWaypoints( 0, file.wpt.length - 1, - closest - .filter((c) => c.index.includes(index)) - .map((c) => file.wpt[c.wptIndex]) + file.wpt.filter((wpt, wptIndex) => + closest[wptIndex].some( + ([trackIndex, segmentIndex]) => segmentIndex === index + ) + ) ); newFile._data.id = fileIds[index]; newFile.metadata.name = `${file.trk[0].name ?? file.metadata.name} (${index + 1})`; diff --git a/website/src/lib/logic/settings.ts b/website/src/lib/logic/settings.ts index f4e6e2cf8..c86df6ac0 100644 --- a/website/src/lib/logic/settings.ts +++ b/website/src/lib/logic/settings.ts @@ -8,6 +8,7 @@ import { defaultOverlayTree, defaultOverpassQueries, defaultOverpassTree, + defaultTerrainSource, type CustomLayer, } from '$lib/assets/layers'; import { browser } from '$app/environment'; @@ -154,6 +155,7 @@ export const settings = { customLayers: new Setting>('customLayers', {}), customBasemapOrder: new Setting('customBasemapOrder', []), customOverlayOrder: new Setting('customOverlayOrder', []), + terrainSource: new Setting('terrainSource', defaultTerrainSource), directionMarkers: new Setting('directionMarkers', false), distanceMarkers: new Setting('distanceMarkers', false), streetViewSource: new Setting('streetViewSource', 'mapillary'), diff --git a/website/src/lib/logic/statistics-tree.ts b/website/src/lib/logic/statistics-tree.ts index 5a114d944..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,23 +21,23 @@ export class GPXStatisticsTree { } } - getStatisticsFor(item: ListItem): GPXStatistics { - let statistics = new GPXStatistics(); + 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.mergeWith(this.statistics[key]); + statistics.add(this.statistics[key]); } else { - statistics.mergeWith(this.statistics[key].getStatisticsFor(item)); + statistics.add(this.statistics[key].getStatisticsFor(item)); } }); } else { let child = this.statistics[id]; if (child instanceof GPXStatistics) { - statistics.mergeWith(child); + statistics.add(child); } else if (child !== undefined) { - statistics.mergeWith(child.getStatisticsFor(item)); + statistics.add(child.getStatisticsFor(item)); } } return statistics; 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(() => { diff --git a/website/src/lib/units.ts b/website/src/lib/units.ts index dd0bee304..cfff44df2 100644 --- a/website/src/lib/units.ts +++ b/website/src/lib/units.ts @@ -229,6 +229,9 @@ export function getConvertedVelocity( } } -export function getConvertedTemperature(value: number) { - return get(temperatureUnits) === 'celsius' ? value : celsiusToFahrenheit(value); +export function getConvertedTemperature( + value: number, + targetTemperatureUnits = get(temperatureUnits) +) { + return targetTemperatureUnits === 'celsius' ? value : celsiusToFahrenheit(value); } diff --git a/website/src/lib/utils.ts b/website/src/lib/utils.ts index 098aab0b4..035de5e97 100644 --- a/website/src/lib/utils.ts +++ b/website/src/lib/utils.ts @@ -2,11 +2,13 @@ import { type ClassValue, clsx } from 'clsx'; import { twMerge } from 'tailwind-merge'; import { base } from '$app/paths'; import { languages } from '$lib/languages'; -import { TrackPoint, Waypoint, type Coordinates, crossarcDistance, distance } from 'gpx'; +import { TrackPoint, Waypoint, type Coordinates, crossarcDistance, distance, GPXFile } from 'gpx'; import mapboxgl from 'mapbox-gl'; import { pointToTile, pointToTileFraction } from '@mapbox/tilebelt'; import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public'; import PNGReader from 'png.js'; +import type { GPXStatisticsTree } from '$lib/logic/statistics-tree'; +import { ListTrackSegmentItem } from '$lib/components/file-list/file-list'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); @@ -47,6 +49,59 @@ export function getClosestLinePoint( return closest; } +export function getClosestTrackSegments( + file: GPXFile, + statistics: GPXStatisticsTree, + point: Coordinates +): [number, number][] { + let segmentBoundsDistances: [number, number, number][] = []; + file.forEachSegment((segment, trackIndex, segmentIndex) => { + let segmentStatistics = statistics.getStatisticsFor( + new ListTrackSegmentItem(file._data.id, trackIndex, segmentIndex) + ); + let segmentBounds = segmentStatistics.global.bounds; + let northEast = segmentBounds.northEast; + let southWest = segmentBounds.southWest; + let bounds = new mapboxgl.LngLatBounds(southWest, northEast); + if (bounds.contains(point)) { + segmentBoundsDistances.push([0, trackIndex, segmentIndex]); + } else { + let northWest: Coordinates = { lat: northEast.lat, lon: southWest.lon }; + let southEast: Coordinates = { lat: southWest.lat, lon: northEast.lon }; + let distanceToBounds = Math.min( + crossarcDistance(northWest, northEast, point), + crossarcDistance(northEast, southEast, point), + crossarcDistance(southEast, southWest, point), + crossarcDistance(southWest, northWest, point) + ); + segmentBoundsDistances.push([distanceToBounds, trackIndex, segmentIndex]); + } + }); + segmentBoundsDistances.sort((a, b) => a[0] - b[0]); + + let closest: { distance: number; indices: [number, number][] } = { + distance: Number.MAX_VALUE, + indices: [], + }; + for (let s = 0; s < segmentBoundsDistances.length; s++) { + if (segmentBoundsDistances[s][0] > closest.distance) { + break; + } + const segment = file.getSegment(segmentBoundsDistances[s][1], segmentBoundsDistances[s][2]); + segment.trkpt.forEach((pt) => { + let dist = distance(pt.getCoordinates(), point); + if (dist < closest.distance) { + closest.distance = dist; + closest.indices = [[segmentBoundsDistances[s][1], segmentBoundsDistances[s][2]]]; + } else if (dist === closest.distance) { + closest.indices.push([segmentBoundsDistances[s][1], segmentBoundsDistances[s][2]]); + } + }); + } + + return closest.indices; +} + export function getElevation( points: (TrackPoint | Waypoint | Coordinates)[], ELEVATION_ZOOM: number = 13, diff --git a/website/src/locales/be.json b/website/src/locales/be.json index f94f456ea..441bec5a2 100644 --- a/website/src/locales/be.json +++ b/website/src/locales/be.json @@ -282,6 +282,7 @@ "update": "Update layer" }, "opacity": "Overlay opacity", + "terrain": "Terrain source", "label": { "basemaps": "Basemaps", "overlays": "Overlays", @@ -325,6 +326,8 @@ "usgs": "USGS", "bikerouterGravel": "bikerouter.de Gravel", "cyclOSMlite": "CyclOSM Lite", + "mapterhornHillshade": "Mapterhorn Hillshade", + "openRailwayMap": "OpenRailwayMap", "swisstopoSlope": "swisstopo Slope", "swisstopoHiking": "swisstopo Hiking", "swisstopoHikingClosures": "swisstopo Hiking Closures", @@ -377,7 +380,9 @@ "railway-station": "Railway Station", "tram-stop": "Tram Stop", "bus-stop": "Bus Stop", - "ferry": "Ferry" + "ferry": "Ferry", + "mapbox-dem": "Mapbox DEM", + "mapterhorn": "Mapterhorn" } }, "chart": { diff --git a/website/src/locales/ca.json b/website/src/locales/ca.json index 3437c8574..a20e4a770 100644 --- a/website/src/locales/ca.json +++ b/website/src/locales/ca.json @@ -282,6 +282,7 @@ "update": "Actualitza la capa" }, "opacity": "Opacitat de la superposició", + "terrain": "Terrain source", "label": { "basemaps": "Mapes base", "overlays": "Capes", @@ -325,6 +326,8 @@ "usgs": "USGS", "bikerouterGravel": "bikerouter.de Gravel", "cyclOSMlite": "CyclOSM Lite", + "mapterhornHillshade": "Mapterhorn Hillshade", + "openRailwayMap": "OpenRailwayMap", "swisstopoSlope": "swisstopo Slope", "swisstopoHiking": "swisstopo Hiking", "swisstopoHikingClosures": "swisstopo Hiking Closures", @@ -377,7 +380,9 @@ "railway-station": "Estació de tren", "tram-stop": "Parada de tramvia", "bus-stop": "Parada d'autobús", - "ferry": "Ferri" + "ferry": "Ferri", + "mapbox-dem": "Mapbox DEM", + "mapterhorn": "Mapterhorn" } }, "chart": { diff --git a/website/src/locales/cs.json b/website/src/locales/cs.json index 7d283a256..42bc9ed9a 100644 --- a/website/src/locales/cs.json +++ b/website/src/locales/cs.json @@ -282,6 +282,7 @@ "update": "Aktualizovat vrstvu" }, "opacity": "Průhlednost překryvu", + "terrain": "Terrain source", "label": { "basemaps": "Základní mapy", "overlays": "Překrytí", @@ -325,6 +326,8 @@ "usgs": "USGS", "bikerouterGravel": "bikerouter.de Gravel", "cyclOSMlite": "CyclOSM Lite", + "mapterhornHillshade": "Mapterhorn Hillshade", + "openRailwayMap": "OpenRailwayMap", "swisstopoSlope": "swisstopo Vrstevnice", "swisstopoHiking": "swisstopo Turistická", "swisstopoHikingClosures": "swisstopo Turistické uzávěry", @@ -377,7 +380,9 @@ "railway-station": "Železniční stanice", "tram-stop": "Zastávka tramvaje", "bus-stop": "Autobusová zastávka", - "ferry": "Trajekt" + "ferry": "Trajekt", + "mapbox-dem": "Mapbox DEM", + "mapterhorn": "Mapterhorn" } }, "chart": { diff --git a/website/src/locales/da.json b/website/src/locales/da.json index 9d0ae6623..282061a9a 100644 --- a/website/src/locales/da.json +++ b/website/src/locales/da.json @@ -282,6 +282,7 @@ "update": "Update layer" }, "opacity": "Overlay opacity", + "terrain": "Terrain source", "label": { "basemaps": "Basemaps", "overlays": "Overlays", @@ -325,6 +326,8 @@ "usgs": "USGS", "bikerouterGravel": "bikerouter.de Gravel", "cyclOSMlite": "CyclOSM Lite", + "mapterhornHillshade": "Mapterhorn Hillshade", + "openRailwayMap": "OpenRailwayMap", "swisstopoSlope": "swisstopo Slope", "swisstopoHiking": "swisstopo Hiking", "swisstopoHikingClosures": "swisstopo Hiking Closures", @@ -377,7 +380,9 @@ "railway-station": "Railway Station", "tram-stop": "Tram Stop", "bus-stop": "Bus Stop", - "ferry": "Ferry" + "ferry": "Ferry", + "mapbox-dem": "Mapbox DEM", + "mapterhorn": "Mapterhorn" } }, "chart": { diff --git a/website/src/locales/de.json b/website/src/locales/de.json index 6c663019a..f041ec3bc 100644 --- a/website/src/locales/de.json +++ b/website/src/locales/de.json @@ -282,6 +282,7 @@ "update": "Layer aktualisieren" }, "opacity": "Deckkraft der Überlagerung", + "terrain": "Terrain source", "label": { "basemaps": "Basiskarte", "overlays": "Ebenen", @@ -325,6 +326,8 @@ "usgs": "USGS", "bikerouterGravel": "bikerouter.de Gravel", "cyclOSMlite": "CyclOSM Lite", + "mapterhornHillshade": "Mapterhorn Hillshade", + "openRailwayMap": "OpenRailwayMap", "swisstopoSlope": "swisstopo Neigung", "swisstopoHiking": "swisstopo Wandern", "swisstopoHikingClosures": "swisstopo Wanderungen Schließungen", @@ -377,7 +380,9 @@ "railway-station": "Bahnhof", "tram-stop": "Straßenbahnhaltestelle", "bus-stop": "Bushaltestelle", - "ferry": "Fähre" + "ferry": "Fähre", + "mapbox-dem": "Mapbox DEM", + "mapterhorn": "Mapterhorn" } }, "chart": { @@ -473,7 +478,7 @@ }, "homepage": { "website": "Webseite", - "home": "Zuhause", + "home": "Startseite", "app": "App", "contact": "Kontakt", "reddit": "Reddit", diff --git a/website/src/locales/el.json b/website/src/locales/el.json index 0137ac426..041f2726e 100644 --- a/website/src/locales/el.json +++ b/website/src/locales/el.json @@ -282,6 +282,7 @@ "update": "Update layer" }, "opacity": "Overlay opacity", + "terrain": "Terrain source", "label": { "basemaps": "Basemaps", "overlays": "Overlays", @@ -325,6 +326,8 @@ "usgs": "USGS", "bikerouterGravel": "bikerouter.de Gravel", "cyclOSMlite": "CyclOSM Lite", + "mapterhornHillshade": "Mapterhorn Hillshade", + "openRailwayMap": "OpenRailwayMap", "swisstopoSlope": "swisstopo Slope", "swisstopoHiking": "swisstopo Hiking", "swisstopoHikingClosures": "swisstopo Hiking Closures", @@ -377,7 +380,9 @@ "railway-station": "Railway Station", "tram-stop": "Tram Stop", "bus-stop": "Bus Stop", - "ferry": "Ferry" + "ferry": "Ferry", + "mapbox-dem": "Mapbox DEM", + "mapterhorn": "Mapterhorn" } }, "chart": { diff --git a/website/src/locales/en.json b/website/src/locales/en.json index 2350951d5..4bf445f04 100644 --- a/website/src/locales/en.json +++ b/website/src/locales/en.json @@ -282,6 +282,7 @@ "update": "Update layer" }, "opacity": "Overlay opacity", + "terrain": "Terrain source", "label": { "basemaps": "Basemaps", "overlays": "Overlays", @@ -325,6 +326,8 @@ "usgs": "USGS", "bikerouterGravel": "bikerouter.de Gravel", "cyclOSMlite": "CyclOSM Lite", + "mapterhornHillshade": "Mapterhorn Hillshade", + "openRailwayMap": "OpenRailwayMap", "swisstopoSlope": "swisstopo Slope", "swisstopoHiking": "swisstopo Hiking", "swisstopoHikingClosures": "swisstopo Hiking Closures", @@ -377,7 +380,9 @@ "railway-station": "Railway Station", "tram-stop": "Tram Stop", "bus-stop": "Bus Stop", - "ferry": "Ferry" + "ferry": "Ferry", + "mapbox-dem": "Mapbox DEM", + "mapterhorn": "Mapterhorn" } }, "chart": { diff --git a/website/src/locales/es.json b/website/src/locales/es.json index 145566cc8..5f2cbc8e4 100644 --- a/website/src/locales/es.json +++ b/website/src/locales/es.json @@ -2,7 +2,7 @@ "metadata": { "home_title": "el editor online de archivos GPX", "app_title": "app", - "embed_title": "El editor online de archivos GPX", + "embed_title": " editor online de archivos GPX", "help_title": "ayuda", "404_title": "página no encontrada", "description": "Mira, edita y crea archivos GPX online con planificación avanzada de rutas y herramientas de procesamiento de archivos, bonitos mapas y visualizaciones detalladas de datos." @@ -282,6 +282,7 @@ "update": "Actualizar capa" }, "opacity": "Opacidad de la capa superpuesta", + "terrain": "Terrain source", "label": { "basemaps": "Mapas base", "overlays": "Capas", @@ -325,6 +326,8 @@ "usgs": "USGS", "bikerouterGravel": "Gravel bikerouter.de", "cyclOSMlite": "CyclOSM Lite", + "mapterhornHillshade": "Mapterhorn Hillshade", + "openRailwayMap": "OpenRailwayMap", "swisstopoSlope": "swisstopo Slope", "swisstopoHiking": "swisstopo Senderismo", "swisstopoHikingClosures": "swisstopo Rutas Senderismo", @@ -377,7 +380,9 @@ "railway-station": "Estación de tren", "tram-stop": "Parada de tranvía", "bus-stop": "Parada de autobús", - "ferry": "Ferri" + "ferry": "Ferri", + "mapbox-dem": "Mapbox DEM", + "mapterhorn": "Mapterhorn" } }, "chart": { diff --git a/website/src/locales/eu.json b/website/src/locales/eu.json index 7f68f17d4..7aac98cf2 100644 --- a/website/src/locales/eu.json +++ b/website/src/locales/eu.json @@ -282,6 +282,7 @@ "update": "Eguneratu geruza" }, "opacity": "Geruzaren opakutasuna", + "terrain": "Terrain source", "label": { "basemaps": "Oinarrizko mapak", "overlays": "Geruzak", @@ -325,6 +326,8 @@ "usgs": "USGS", "bikerouterGravel": "bikerouter.de Gravel", "cyclOSMlite": "CyclOSM Lite", + "mapterhornHillshade": "Mapterhorn Hillshade", + "openRailwayMap": "OpenRailwayMap", "swisstopoSlope": "swisstopo Malda", "swisstopoHiking": "swisstopo Mendi ibilaldiak", "swisstopoHikingClosures": "swisstopo Hiking Closures", @@ -377,7 +380,9 @@ "railway-station": "Tren geltokia", "tram-stop": "Tranbia geltokia", "bus-stop": "Autobus geltokia", - "ferry": "Ferria" + "ferry": "Ferria", + "mapbox-dem": "Mapbox DEM", + "mapterhorn": "Mapterhorn" } }, "chart": { diff --git a/website/src/locales/fi.json b/website/src/locales/fi.json index 6e46b70eb..754754d59 100644 --- a/website/src/locales/fi.json +++ b/website/src/locales/fi.json @@ -282,6 +282,7 @@ "update": "Päivitä karttataso" }, "opacity": "Peitetason läpinäkyvyys", + "terrain": "Terrain source", "label": { "basemaps": "Taustakartat", "overlays": "Peitetasot", @@ -325,6 +326,8 @@ "usgs": "USGS", "bikerouterGravel": "bikerouter.de Gravel", "cyclOSMlite": "CyclOSM Lite", + "mapterhornHillshade": "Mapterhorn Hillshade", + "openRailwayMap": "OpenRailwayMap", "swisstopoSlope": "swisstopo Rinnekaltevuus", "swisstopoHiking": "swisstopo Retkeilyreitit", "swisstopoHikingClosures": "swisstopo Hiking Closures", @@ -377,7 +380,9 @@ "railway-station": "Rautatieasemat", "tram-stop": "Raitiovaunupysäkit", "bus-stop": "Linja-autopysäkit", - "ferry": "Lautat" + "ferry": "Lautat", + "mapbox-dem": "Mapbox DEM", + "mapterhorn": "Mapterhorn" } }, "chart": { diff --git a/website/src/locales/fr.json b/website/src/locales/fr.json index 44137b8ca..033e2068f 100644 --- a/website/src/locales/fr.json +++ b/website/src/locales/fr.json @@ -282,6 +282,7 @@ "update": "Mettre à jour la couche" }, "opacity": "Opacité de la surcouche", + "terrain": "Source du relief", "label": { "basemaps": "Fonds de carte", "overlays": "Surcouches", @@ -325,6 +326,8 @@ "usgs": "USGS", "bikerouterGravel": "bikerouter.de Gravel", "cyclOSMlite": "CyclOSM Lite", + "mapterhornHillshade": "Mapterhorn Relief", + "openRailwayMap": "OpenRailwayMap", "swisstopoSlope": "swisstopo Pente", "swisstopoHiking": "swisstopo Randonnée", "swisstopoHikingClosures": "swisstopo Fermetures de randonnée", @@ -350,7 +353,7 @@ "eat-and-drink": "Nourriture et boissons", "amenities": "Commodités", "toilets": "Toilettes", - "water": "Cours d'eau", + "water": "Eau potable", "shower": "Douche", "shelter": "Abri", "cemetery": "Cimetière", @@ -377,7 +380,9 @@ "railway-station": "Gare", "tram-stop": "Arrêt de tram", "bus-stop": "Arrêt de bus", - "ferry": "Ferry" + "ferry": "Ferry", + "mapbox-dem": "Mapbox DEM", + "mapterhorn": "Mapterhorn" } }, "chart": { @@ -443,7 +448,7 @@ "convenience_store": "Épicerie", "crossing": "Croisement", "department_store": "Grand magasin", - "drinking_water": "Cours d'eau", + "drinking_water": "Eau potable", "exit": "Sortie", "lodge": "Refuge", "lodging": "Hébergement", @@ -468,7 +473,7 @@ "summit": "Sommet", "telephone": "Téléphone", "tunnel": "Tunnel", - "water_source": "Source d'eau" + "water_source": "Point d'eau" } }, "homepage": { diff --git a/website/src/locales/he.json b/website/src/locales/he.json index a8143d282..2e2b27c2f 100644 --- a/website/src/locales/he.json +++ b/website/src/locales/he.json @@ -282,6 +282,7 @@ "update": "Update layer" }, "opacity": "Overlay opacity", + "terrain": "Terrain source", "label": { "basemaps": "Basemaps", "overlays": "Overlays", @@ -325,6 +326,8 @@ "usgs": "USGS", "bikerouterGravel": "bikerouter.de Gravel", "cyclOSMlite": "CyclOSM Lite", + "mapterhornHillshade": "Mapterhorn Hillshade", + "openRailwayMap": "OpenRailwayMap", "swisstopoSlope": "swisstopo Slope", "swisstopoHiking": "swisstopo Hiking", "swisstopoHikingClosures": "swisstopo Hiking Closures", @@ -377,7 +380,9 @@ "railway-station": "Railway Station", "tram-stop": "Tram Stop", "bus-stop": "Bus Stop", - "ferry": "Ferry" + "ferry": "Ferry", + "mapbox-dem": "Mapbox DEM", + "mapterhorn": "Mapterhorn" } }, "chart": { diff --git a/website/src/locales/hu.json b/website/src/locales/hu.json index a187cfac6..0e9c76af8 100644 --- a/website/src/locales/hu.json +++ b/website/src/locales/hu.json @@ -282,6 +282,7 @@ "update": "Réteg feltöltése" }, "opacity": "Átfedés átlátszósága", + "terrain": "Terrain source", "label": { "basemaps": "Alaptérkép", "overlays": "Térkép rétegek", @@ -325,6 +326,8 @@ "usgs": "USGS", "bikerouterGravel": "kerékpár és terepkerékpár út", "cyclOSMlite": "CyclOSM Lite", + "mapterhornHillshade": "Mapterhorn Hillshade", + "openRailwayMap": "OpenRailwayMap", "swisstopoSlope": "swisstopo Lejtő", "swisstopoHiking": "swisstopo Túra", "swisstopoHikingClosures": "swisstopo túralezárások", @@ -377,7 +380,9 @@ "railway-station": "Vasútállomás", "tram-stop": "Villamos megálló", "bus-stop": "Buszmegálló", - "ferry": "Komp" + "ferry": "Komp", + "mapbox-dem": "Mapbox DEM", + "mapterhorn": "Mapterhorn" } }, "chart": { diff --git a/website/src/locales/id.json b/website/src/locales/id.json index 92f8c3fd1..6b63a575e 100644 --- a/website/src/locales/id.json +++ b/website/src/locales/id.json @@ -282,6 +282,7 @@ "update": "Perbarui lapisan" }, "opacity": "Opasitas Overlay", + "terrain": "Terrain source", "label": { "basemaps": "Peta dasar", "overlays": "Overlay", @@ -325,6 +326,8 @@ "usgs": "USGS", "bikerouterGravel": "bikerouter.de Gravel", "cyclOSMlite": "CyclOSM Lite", + "mapterhornHillshade": "Mapterhorn Hillshade", + "openRailwayMap": "OpenRailwayMap", "swisstopoSlope": "swisstopo Kemiringan", "swisstopoHiking": "swisstopo Pendakian", "swisstopoHikingClosures": "Penutupan Jalur Pendakian swisstopo", @@ -377,7 +380,9 @@ "railway-station": "Stasiun kereta api", "tram-stop": "Halt trem", "bus-stop": "Pemberhentian Bus", - "ferry": "Feri" + "ferry": "Feri", + "mapbox-dem": "Mapbox DEM", + "mapterhorn": "Mapterhorn" } }, "chart": { diff --git a/website/src/locales/it.json b/website/src/locales/it.json index 7d3e2656b..0c52c4584 100644 --- a/website/src/locales/it.json +++ b/website/src/locales/it.json @@ -34,8 +34,8 @@ "elevation_profile": "Profilo altimetrico", "tree_file_view": "Vista ad albero", "switch_basemap": "Passa alla mappa di base precedente", - "toggle_overlays": "Attiva/disattiva le sovrapposizioni", - "toggle_3d": "Attiva/disattiva 3D", + "toggle_overlays": "Attiva / disattiva le sovrapposizioni", + "toggle_3d": "Attiva / disattiva 3D", "settings": "Impostazioni", "distance_units": "Unità distanza", "metric": "Metrico", @@ -53,7 +53,7 @@ "street_view_source": "Sorgente della vista stradale", "mapillary": "Mapillary", "google": "Google", - "toggle_street_view": "Street View", + "toggle_street_view": "Vista stradale", "layers": "Livelli della mappa...", "distance_markers": "Indicatori di distanza", "direction_markers": "Frecce direzionali", @@ -87,7 +87,7 @@ "tooltip": "Pianifica o modifica un percorsoo", "activity": "Attività", "use_routing": "Instradamento", - "use_routing_tooltip": "Collega i punti di ancoraggio tramite la rete stradale o in linea retta se disabilitato", + "use_routing_tooltip": "Collega i punti di ancoraggio tramite la rete stradale (o in linea retta se disabilitato)", "allow_private": "Consenti strade private", "reverse": { "button": "Inverti la traccia", @@ -235,18 +235,18 @@ "help_no_selection": "Seleziona un file per richiedere i dati di altitudine." }, "waypoint": { - "tooltip": "Creare e modificare punti di interesse", + "tooltip": "Creare e modificare punti d'interesse", "icon": "Icona", "link": "Collegamento", "longitude": "Longitudine", "latitude": "Latitudine", - "create": "Creare un punto di interesse", - "add": "Aggiungi punto di interesse al file", - "help": "Compila il modulo per creare un nuovo punto di interesse, oppure fai clic su uno esistente per modificarlo. Fare clic sulla mappa per inserire le coordinate o trascinare i punti di interesse per spostarli.", - "help_no_selection": "Selezionare un file per creare o modificare punti di interesse." + "create": "Creare un punto d'interesse", + "add": "Aggiungi punto d'interesse al file", + "help": "Compila il modulo per creare un nuovo punto d'interesse, oppure fai clic su uno esistente per modificarlo. Fare clic sulla mappa per inserire le coordinate o trascinare i punti d'interesse per spostarli.", + "help_no_selection": "Selezionare un file per creare o modificare punti d'interesse." }, "reduce": { - "tooltip": "Riduci il numero di punti della traccia", + "tooltip": "Riduci il numero di punti GPS", "tolerance": "Tolleranza", "number_of_points": "Numero di punti GPS", "button": "Minimizza", @@ -254,14 +254,14 @@ "help_no_selection": "Selezionare una traccia per ridurre il numero dei suoi punti GPS." }, "clean": { - "tooltip": "Pulire i punti GPS e i punti di interesse con una selezione rettangolare", + "tooltip": "Pulire i punti GPS e i punti d'interesse con una selezione rettangolare", "delete_trackpoints": "Eliminare punti GPS", "delete_waypoints": "Cancella punti d'interesse", "delete_inside": "Elimina all'interno della selezione", "delete_outside": "Elimina fuori dalla selezione", "button": "Elimina", - "help": "Selezionare un'area rettangolare sulla mappa per rimuovere i punti GPS e i punti di interesse.", - "help_no_selection": "Seleziona una traccia per pulire i punti GPS e i punti di interesse." + "help": "Selezionare un'area rettangolare sulla mappa per rimuovere i punti GPS e i punti d'interesse.", + "help_no_selection": "Seleziona una traccia per pulire i punti GPS e i punti d'interesse." } }, "layers": { @@ -273,7 +273,7 @@ "new": "Nuovo livello personalizzato", "edit": "Modifica livello personalizzato", "urls": "URL(s)", - "url_placeholder": "WMTS, WMS o Mapbox stile JSON", + "url_placeholder": "WMTS, WMS o JSON in stile Mapbox", "max_zoom": "Zoom massimo", "layer_type": "Tipo del layer", "basemap": "Mappa Base", @@ -282,6 +282,7 @@ "update": "Aggiorna livello" }, "opacity": "Opacità di sovrapposizione", + "terrain": "Terrain source", "label": { "basemaps": "Mappe di base", "overlays": "Sovrapposizioni", @@ -309,7 +310,7 @@ "linz": "LINZ Topo", "linzTopo": "LINZ Topo50", "swisstopoRaster": "swisstopo Raster", - "swisstopoVector": "Swisstopo Vector", + "swisstopoVector": "swisstopo Vector", "swisstopoSatellite": "swisstopo Satellite", "ignBe": "IGN Topo", "ignFrPlan": "IGN Plan", @@ -318,25 +319,27 @@ "ignFrSatellite": "Satellitare IGN", "ignEs": "IGN Topo", "ignEsSatellite": "Satellitare IGN", - "ordnanceSurvey": "Sondaggio Ordnance", + "ordnanceSurvey": "Ordnance Survey", "norwayTopo": "Topografisk Norgeskart 4", - "finlandTopo": "Carta topografica del vecchio Catasto svedese", + "finlandTopo": "Lantmäteriverket Terrängkarta", "bgMountains": "BGMountains", "usgs": "USGS", "bikerouterGravel": "bikerouter.de Gravel", "cyclOSMlite": "CyclOSM Lite", - "swisstopoSlope": "Carta topografica Svizzera Pendenza", - "swisstopoHiking": "Carta topografica Svedese Escursione", - "swisstopoHikingClosures": "Carta topografica Svizzera Fine escursione", - "swisstopoCycling": "Carta topografica Svizzera Ciclabile", - "swisstopoCyclingClosures": "Carta topografica Svizzera fine ciclabile", - "swisstopoMountainBike": "Carta topografica Svizzera MTB", - "swisstopoMountainBikeClosures": "Carta topografica Svizzera fine MTB", - "swisstopoSkiTouring": "Carta topografica Svizzera pista sci", + "mapterhornHillshade": "Mapterhorn Hillshade", + "openRailwayMap": "OpenRailwayMap", + "swisstopoSlope": "swisstopo Pendenza", + "swisstopoHiking": "swisstopo Escursione", + "swisstopoHikingClosures": "swisstopo Fine escursione", + "swisstopoCycling": "swisstopo Ciclabile", + "swisstopoCyclingClosures": "swisstopo Fine ciclabile", + "swisstopoMountainBike": "swisstopo MTB", + "swisstopoMountainBikeClosures": "swisstopo Fine MTB", + "swisstopoSkiTouring": "swisstopo Sci Alpinismo", "ignFrCadastre": "IGN Catasto", - "ignSlope": "Pendenza IGN", - "ignSkiTouring": "IGN Sciescursionismo", - "waymarked_trails": "Waymarked Trails", + "ignSlope": "IGN Pendenza", + "ignSkiTouring": "IGN Sci Alpinismo", + "waymarked_trails": "Sentieri Segnalati", "waymarkedTrailsHiking": "Escursionismo", "waymarkedTrailsCycling": "Ciclismo", "waymarkedTrailsMTB": "MTB", @@ -377,7 +380,9 @@ "railway-station": "Stazione ferroviaria", "tram-stop": "Fermata del tram", "bus-stop": "Fermata dell'autobus", - "ferry": "Traghetto" + "ferry": "Traghetto", + "mapbox-dem": "Mapbox DEM", + "mapterhorn": "Mapterhorn" } }, "chart": { @@ -406,11 +411,11 @@ "feet": "ft", "kilometers": "km", "miles": "mi", - "nautical_miles": "nm", + "nautical_miles": "NM", "celsius": "°C", "fahrenheit": "°F", "kilometers_per_hour": "km/h", - "miles_per_hour": "mph", + "miles_per_hour": "mi/h", "minutes_per_kilometer": "min/km", "minutes_per_mile": "min/mi", "minutes_per_nautical_mile": "min/nm", @@ -426,8 +431,8 @@ "tracks": "Tracce", "segment": "Segmento", "segments": "Segmenti", - "waypoint": "Punto di interesse", - "waypoints": "Punti di interesse", + "waypoint": "Punto d'interesse", + "waypoints": "Punti d'interesse", "symbol": { "alert": "Avviso", "anchor": "Ancora", @@ -483,13 +488,13 @@ "email": "Email", "contribute": "Contribuire", "supported_by": "supportato da", - "support_button": "Supporto di gpx.studio su Ko-fi", + "support_button": "Supporta gpx.studio su Ko-fi", "route_planning": "Pianificazione del percorso", - "route_planning_description": "Un'interfaccia intuitiva per creare itinerari su misura per ogni sport, basati sui dati OpenStreetMap.", + "route_planning_description": "Un'interfaccia intuitiva per creare itinerari su misura per ogni sport, basata sui dati OpenStreetMap.", "file_processing": "Elaborazione avanzata dei file", "file_processing_description": "Una serie di strumenti per eseguire tutte le attività comuni di elaborazione dei file e che possono essere applicati a più file contemporaneamente.", "maps": "Mappe globali e locali", - "maps_description": "Una vasta collezione di mappe di base, sovrapposizioni e punti d'interesse per aiutarti a creare la tua prossima avventura all'aperto o visualizzare il tuo ultimo risultato.", + "maps_description": "Una vasta collezione di mappe di base, sovrapposizioni e punti d'interesse per aiutarti a creare la tua prossima avventura all'aperto o visualizzare la tua ultima impresa.", "data_visualization": "Visualizzazione dei dati", "data_visualization_description": "Un profilo di elevazione interattivo con statistiche dettagliate per analizzare attività registrate e obiettivi futuri.", "identity": "Gratuito, senza pubblicità e open source", diff --git a/website/src/locales/ko.json b/website/src/locales/ko.json index 9fed8012f..918e9fcbe 100644 --- a/website/src/locales/ko.json +++ b/website/src/locales/ko.json @@ -282,6 +282,7 @@ "update": "레이어 갱신" }, "opacity": "오버레이 투명도", + "terrain": "Terrain source", "label": { "basemaps": "배경 지도", "overlays": "오버레이", @@ -325,6 +326,8 @@ "usgs": "USGS", "bikerouterGravel": "bikerouter.de Gravel", "cyclOSMlite": "CyclOSM Lite", + "mapterhornHillshade": "Mapterhorn Hillshade", + "openRailwayMap": "OpenRailwayMap", "swisstopoSlope": "swisstopo Slope", "swisstopoHiking": "swisstopo Hiking", "swisstopoHikingClosures": "swisstopo Hiking Closures", @@ -377,7 +380,9 @@ "railway-station": "철도역", "tram-stop": "트램 정류장", "bus-stop": "버스 정류장", - "ferry": "페리" + "ferry": "페리", + "mapbox-dem": "Mapbox DEM", + "mapterhorn": "Mapterhorn" } }, "chart": { diff --git a/website/src/locales/lt.json b/website/src/locales/lt.json index 285823419..2ddc8046e 100644 --- a/website/src/locales/lt.json +++ b/website/src/locales/lt.json @@ -282,6 +282,7 @@ "update": "Naujinti sluoksnį" }, "opacity": "Sluoksnio skaidrumas", + "terrain": "Terrain source", "label": { "basemaps": "Pagrindo žemėlapiai", "overlays": "Sluoksniai", @@ -325,6 +326,8 @@ "usgs": "USGS", "bikerouterGravel": "bikerouter.de Gravel", "cyclOSMlite": "CyclOSM Lite", + "mapterhornHillshade": "Mapterhorn Hillshade", + "openRailwayMap": "OpenRailwayMap", "swisstopoSlope": "swisstopo Slope", "swisstopoHiking": "swisstopo Hiking", "swisstopoHikingClosures": "swisstopo Hiking Closures", @@ -377,7 +380,9 @@ "railway-station": "Geležinkelio stotis", "tram-stop": "Tramvajaus stotelė", "bus-stop": "Autobusų stotelė", - "ferry": "Keltas" + "ferry": "Keltas", + "mapbox-dem": "Mapbox DEM", + "mapterhorn": "Mapterhorn" } }, "chart": { diff --git a/website/src/locales/lv.json b/website/src/locales/lv.json index db6b57bb7..cbae3f1de 100644 --- a/website/src/locales/lv.json +++ b/website/src/locales/lv.json @@ -282,6 +282,7 @@ "update": "Update layer" }, "opacity": "Overlay opacity", + "terrain": "Terrain source", "label": { "basemaps": "Basemaps", "overlays": "Overlays", @@ -325,6 +326,8 @@ "usgs": "USGS", "bikerouterGravel": "bikerouter.de Gravel", "cyclOSMlite": "CyclOSM Lite", + "mapterhornHillshade": "Mapterhorn Hillshade", + "openRailwayMap": "OpenRailwayMap", "swisstopoSlope": "swisstopo Slope", "swisstopoHiking": "swisstopo Hiking", "swisstopoHikingClosures": "swisstopo Hiking Closures", @@ -377,7 +380,9 @@ "railway-station": "Railway Station", "tram-stop": "Tram Stop", "bus-stop": "Bus Stop", - "ferry": "Ferry" + "ferry": "Ferry", + "mapbox-dem": "Mapbox DEM", + "mapterhorn": "Mapterhorn" } }, "chart": { diff --git a/website/src/locales/nl.json b/website/src/locales/nl.json index 69765238d..ad9332628 100644 --- a/website/src/locales/nl.json +++ b/website/src/locales/nl.json @@ -282,6 +282,7 @@ "update": "Update laag" }, "opacity": "Laag Transparantie", + "terrain": "Terrain source", "label": { "basemaps": "Basis kaarten", "overlays": "Lagen", @@ -325,6 +326,8 @@ "usgs": "USGS", "bikerouterGravel": "bikerouter.de Grind", "cyclOSMlite": "CyclOSM Lite", + "mapterhornHillshade": "Mapterhorn Hillshade", + "openRailwayMap": "OpenRailwayMap", "swisstopoSlope": "swisstopo Helling", "swisstopoHiking": "swisstopo Wandelen", "swisstopoHikingClosures": "swisstopo Hiking Sluiting", @@ -377,7 +380,9 @@ "railway-station": "Treinstation", "tram-stop": "Tramhalte", "bus-stop": "Bushalte", - "ferry": "Veerboot" + "ferry": "Veerboot", + "mapbox-dem": "Mapbox DEM", + "mapterhorn": "Mapterhorn" } }, "chart": { diff --git a/website/src/locales/no.json b/website/src/locales/no.json index 79a862ecb..45baa5a48 100644 --- a/website/src/locales/no.json +++ b/website/src/locales/no.json @@ -282,6 +282,7 @@ "update": "Oppdater lag" }, "opacity": "Gjennomsiktighet for overlegg", + "terrain": "Terrain source", "label": { "basemaps": "Basiskart", "overlays": "Overlag", @@ -325,6 +326,8 @@ "usgs": "USGS", "bikerouterGravel": "sykkelrute Grus", "cyclOSMlite": "SyklOSM Lite", + "mapterhornHillshade": "Mapterhorn Hillshade", + "openRailwayMap": "OpenRailwayMap", "swisstopoSlope": "swisstopografisk helningskart", "swisstopoHiking": "swisstopografisk Fottur", "swisstopoHikingClosures": "swisstopografi Stengte turstier", @@ -377,7 +380,9 @@ "railway-station": "Jernbanestasjon", "tram-stop": "Trikkestopp", "bus-stop": "Bussholdeplass", - "ferry": "Ferge" + "ferry": "Ferge", + "mapbox-dem": "Mapbox DEM", + "mapterhorn": "Mapterhorn" } }, "chart": { diff --git a/website/src/locales/pl.json b/website/src/locales/pl.json index 73a13dedf..2cd6a32a1 100644 --- a/website/src/locales/pl.json +++ b/website/src/locales/pl.json @@ -2,7 +2,7 @@ "metadata": { "home_title": "edytor online plików GPX", "app_title": "Aplikacja", - "embed_title": "Online'owy edytor plików GPX", + "embed_title": "Edytor plików GPX online", "help_title": "pomoc", "404_title": "nie odnaleziono strony", "description": "Przeglądaj, edytuj i twórz pliki GPX online z zaawansowanymi możliwościami planowania trasy i narzędziami do przetwarzania plików, pięknymi mapami i szczegółowymi wizualizacjami danych." @@ -28,7 +28,7 @@ "undo": "Cofnij", "redo": "Ponów", "delete": "Usuń", - "delete_all": "Delete all", + "delete_all": "Usuń wszystko", "select_all": "Zaznacz wszystko", "view": "Widok", "elevation_profile": "Profil wysokości", @@ -80,7 +80,7 @@ "center": "Wyśrodkuj", "open_in": "Otwórz w", "copy_coordinates": "Kopiuj współrzędne", - "edit_osm": "Edit in OpenStreetMap" + "edit_osm": "Edytuj w OpenStreetMap" }, "toolbar": { "routing": { @@ -282,6 +282,7 @@ "update": "Zaktualizuj warstwę" }, "opacity": "Przezroczystość nakładki", + "terrain": "Terrain source", "label": { "basemaps": "Mapy bazowe", "overlays": "Nakładki", @@ -325,6 +326,8 @@ "usgs": "USGS", "bikerouterGravel": "bikerouter.de Gravel", "cyclOSMlite": "CyclOSM Lite", + "mapterhornHillshade": "Mapterhorn Hillshade", + "openRailwayMap": "OpenRailwayMap", "swisstopoSlope": "swisstopo Stoki", "swisstopoHiking": "swisstopo Szlaki Turystyczne", "swisstopoHikingClosures": "swisstopo Zamknięcia Szlaków", @@ -353,7 +356,7 @@ "water": "Woda", "shower": "Prysznic", "shelter": "Schronienie", - "cemetery": "Cemetery", + "cemetery": "Cmentarz", "motorized": "Samochody i motocykle", "fuel-station": "Stacja paliw", "parking": "Parking", @@ -377,7 +380,9 @@ "railway-station": "Stacja kolejowa", "tram-stop": "Przystanek tramwajowy", "bus-stop": "Przystanek autobusowy", - "ferry": "Prom" + "ferry": "Prom", + "mapbox-dem": "Mapbox DEM", + "mapterhorn": "Mapterhorn" } }, "chart": { diff --git a/website/src/locales/pt-BR.json b/website/src/locales/pt-BR.json index fc28dc3c4..bb9e3a59c 100644 --- a/website/src/locales/pt-BR.json +++ b/website/src/locales/pt-BR.json @@ -282,6 +282,7 @@ "update": "Atualizar camada" }, "opacity": "Opacidade de sobreposição", + "terrain": "Terrain source", "label": { "basemaps": "Mapa base", "overlays": "Sobreposições", @@ -325,6 +326,8 @@ "usgs": "USGS", "bikerouterGravel": "bikerouter.de Gravel", "cyclOSMlite": "CyclOSM Lite", + "mapterhornHillshade": "Mapterhorn Hillshade", + "openRailwayMap": "OpenRailwayMap", "swisstopoSlope": "swisstopo Slope", "swisstopoHiking": "swisstopo Hiking", "swisstopoHikingClosures": "swisstopo Hiking Closures", @@ -377,7 +380,9 @@ "railway-station": "Estações ferroviárias", "tram-stop": "Parada de bonde", "bus-stop": "Parada de Ônibus", - "ferry": "Balsa" + "ferry": "Balsa", + "mapbox-dem": "Mapbox DEM", + "mapterhorn": "Mapterhorn" } }, "chart": { diff --git a/website/src/locales/pt.json b/website/src/locales/pt.json index 56fc94b55..8dfbb1f3e 100644 --- a/website/src/locales/pt.json +++ b/website/src/locales/pt.json @@ -282,6 +282,7 @@ "update": "Atualizar camada" }, "opacity": "Opacidade da sobreposição", + "terrain": "Terrain source", "label": { "basemaps": "Mapas base", "overlays": "Sobreposições", @@ -325,6 +326,8 @@ "usgs": "USGS", "bikerouterGravel": "bikerouter.de Gravel", "cyclOSMlite": "CyclOSM Lite", + "mapterhornHillshade": "Mapterhorn Hillshade", + "openRailwayMap": "OpenRailwayMap", "swisstopoSlope": "swisstopo Slope", "swisstopoHiking": "swisstopo Hiking", "swisstopoHikingClosures": "swisstopo Hiking Closures", @@ -377,7 +380,9 @@ "railway-station": "Estações ferroviárias", "tram-stop": "Parada de bonde", "bus-stop": "Parada de Ônibus", - "ferry": "Balsa" + "ferry": "Balsa", + "mapbox-dem": "Mapbox DEM", + "mapterhorn": "Mapterhorn" } }, "chart": { diff --git a/website/src/locales/ro.json b/website/src/locales/ro.json index a3623708d..3853df374 100644 --- a/website/src/locales/ro.json +++ b/website/src/locales/ro.json @@ -282,6 +282,7 @@ "update": "Actualizează stratul" }, "opacity": "Opacitatea overlay-ului", + "terrain": "Terrain source", "label": { "basemaps": "Hărți de bază", "overlays": "Suprapuneri", @@ -325,6 +326,8 @@ "usgs": "USGS", "bikerouterGravel": "bikerouter.de Gravel", "cyclOSMlite": "CyclOSM Lite", + "mapterhornHillshade": "Mapterhorn Hillshade", + "openRailwayMap": "OpenRailwayMap", "swisstopoSlope": "swisstopo Slope", "swisstopoHiking": "swisstopo Hiking", "swisstopoHikingClosures": "swisstopo Hiking Closures", @@ -377,7 +380,9 @@ "railway-station": "Gară", "tram-stop": "Tram Stop", "bus-stop": "Stație de autobuz", - "ferry": "Feribot" + "ferry": "Feribot", + "mapbox-dem": "Mapbox DEM", + "mapterhorn": "Mapterhorn" } }, "chart": { diff --git a/website/src/locales/ru.json b/website/src/locales/ru.json index 23064dde5..5b169ec3d 100644 --- a/website/src/locales/ru.json +++ b/website/src/locales/ru.json @@ -282,6 +282,7 @@ "update": "Обновить слой" }, "opacity": "Прозрачность наложения", + "terrain": "Terrain source", "label": { "basemaps": "Основные карты", "overlays": "Наложения", @@ -325,6 +326,8 @@ "usgs": "USGS", "bikerouterGravel": "bikerouter.de Gravel", "cyclOSMlite": "CyclOSM Lite", + "mapterhornHillshade": "Mapterhorn Hillshade", + "openRailwayMap": "OpenRailwayMap", "swisstopoSlope": "swisstopo Slope", "swisstopoHiking": "swisstopo Hiking", "swisstopoHikingClosures": "swisstopo Hiking Closures", @@ -377,7 +380,9 @@ "railway-station": "Железнодорожная станция", "tram-stop": "Трамвайная остановка", "bus-stop": "Автобусная остановка", - "ferry": "Паром" + "ferry": "Паром", + "mapbox-dem": "Mapbox DEM", + "mapterhorn": "Mapterhorn" } }, "chart": { diff --git a/website/src/locales/sr.json b/website/src/locales/sr.json index 8acc6bd3c..53e80c876 100644 --- a/website/src/locales/sr.json +++ b/website/src/locales/sr.json @@ -282,6 +282,7 @@ "update": "Ažurirajte sloj" }, "opacity": "Providnost preklapanja", + "terrain": "Terrain source", "label": { "basemaps": "Osnovne mape", "overlays": "Preklapanja", @@ -325,6 +326,8 @@ "usgs": "USGS", "bikerouterGravel": "bikerouter.de Gravel", "cyclOSMlite": "CyclOSM Lite", + "mapterhornHillshade": "Mapterhorn Hillshade", + "openRailwayMap": "OpenRailwayMap", "swisstopoSlope": "swisstopo Slope", "swisstopoHiking": "swisstopo Hiking", "swisstopoHikingClosures": "swisstopo Hiking Closures", @@ -377,7 +380,9 @@ "railway-station": "Železnička stanica", "tram-stop": "Tramvajsko stajalište", "bus-stop": "Autobusko stajalište", - "ferry": "Trajekt" + "ferry": "Trajekt", + "mapbox-dem": "Mapbox DEM", + "mapterhorn": "Mapterhorn" } }, "chart": { diff --git a/website/src/locales/sv.json b/website/src/locales/sv.json index c8781b6fb..f6d259eda 100644 --- a/website/src/locales/sv.json +++ b/website/src/locales/sv.json @@ -282,6 +282,7 @@ "update": "Update layer" }, "opacity": "Overlay opacity", + "terrain": "Terrain source", "label": { "basemaps": "Baskartor", "overlays": "Lager", @@ -325,6 +326,8 @@ "usgs": "USGS", "bikerouterGravel": "bikerouter.de Gravel", "cyclOSMlite": "CyclOSM Lite", + "mapterhornHillshade": "Mapterhorn Hillshade", + "openRailwayMap": "OpenRailwayMap", "swisstopoSlope": "swisstopo Slope", "swisstopoHiking": "swisstopo Hiking", "swisstopoHikingClosures": "swisstopo Hiking Closures", @@ -377,7 +380,9 @@ "railway-station": "Järnvägsstation", "tram-stop": "Spårvagnshållplats", "bus-stop": "Busshållplats", - "ferry": "Ferry" + "ferry": "Ferry", + "mapbox-dem": "Mapbox DEM", + "mapterhorn": "Mapterhorn" } }, "chart": { diff --git a/website/src/locales/th.json b/website/src/locales/th.json index aec6bb4b5..ff1210312 100644 --- a/website/src/locales/th.json +++ b/website/src/locales/th.json @@ -282,6 +282,7 @@ "update": "Update layer" }, "opacity": "Overlay opacity", + "terrain": "Terrain source", "label": { "basemaps": "Basemaps", "overlays": "Overlays", @@ -325,6 +326,8 @@ "usgs": "USGS", "bikerouterGravel": "bikerouter.de Gravel", "cyclOSMlite": "CyclOSM Lite", + "mapterhornHillshade": "Mapterhorn Hillshade", + "openRailwayMap": "OpenRailwayMap", "swisstopoSlope": "swisstopo Slope", "swisstopoHiking": "swisstopo Hiking", "swisstopoHikingClosures": "swisstopo Hiking Closures", @@ -377,7 +380,9 @@ "railway-station": "Railway Station", "tram-stop": "Tram Stop", "bus-stop": "Bus Stop", - "ferry": "Ferry" + "ferry": "Ferry", + "mapbox-dem": "Mapbox DEM", + "mapterhorn": "Mapterhorn" } }, "chart": { diff --git a/website/src/locales/tr.json b/website/src/locales/tr.json index 63740c69f..1f27c242e 100644 --- a/website/src/locales/tr.json +++ b/website/src/locales/tr.json @@ -282,6 +282,7 @@ "update": "Katman güncelle" }, "opacity": "Katman şeffaflığı", + "terrain": "Terrain source", "label": { "basemaps": "Temel haritalar", "overlays": "Katmanlar", @@ -325,6 +326,8 @@ "usgs": "USGS", "bikerouterGravel": "bikerouter.de Gravel", "cyclOSMlite": "CyclOSM Lite", + "mapterhornHillshade": "Mapterhorn Hillshade", + "openRailwayMap": "OpenRailwayMap", "swisstopoSlope": "swisstopo Eğim", "swisstopoHiking": "swisstopo Yürüyüş", "swisstopoHikingClosures": "swisstopo Yürüyüş Sonu", @@ -377,7 +380,9 @@ "railway-station": "Tren istasyonu", "tram-stop": "Tramvay Durağı", "bus-stop": "Otobüs Durağı", - "ferry": "Feribot" + "ferry": "Feribot", + "mapbox-dem": "Mapbox DEM", + "mapterhorn": "Mapterhorn" } }, "chart": { diff --git a/website/src/locales/uk.json b/website/src/locales/uk.json index 4b508f4fc..2a8725e92 100644 --- a/website/src/locales/uk.json +++ b/website/src/locales/uk.json @@ -282,6 +282,7 @@ "update": "Оновити шар" }, "opacity": "Непрозорість накладання", + "terrain": "Terrain source", "label": { "basemaps": "Базові карти", "overlays": "Накладання", @@ -325,6 +326,8 @@ "usgs": "USGS", "bikerouterGravel": "bikerouter.de Gravel", "cyclOSMlite": "CyclOSM Lite", + "mapterhornHillshade": "Mapterhorn Hillshade", + "openRailwayMap": "OpenRailwayMap", "swisstopoSlope": "swisstopo Slope", "swisstopoHiking": "swisstopo Hiking", "swisstopoHikingClosures": "swisstopo Hiking Closures", @@ -377,7 +380,9 @@ "railway-station": "Залізнична Станція", "tram-stop": "Трамвайна Зупинка", "bus-stop": "Автобусна Зупинка", - "ferry": "Пором" + "ferry": "Пором", + "mapbox-dem": "Mapbox DEM", + "mapterhorn": "Mapterhorn" } }, "chart": { diff --git a/website/src/locales/vi.json b/website/src/locales/vi.json index 63910c2e2..297b1bbb1 100644 --- a/website/src/locales/vi.json +++ b/website/src/locales/vi.json @@ -21,18 +21,18 @@ "export_all": "Xuất tất cả...", "export_options": "Tùy chọn xuất", "support_message": "Công cụ này miễn phí, nhưng nó tốn phí để duy trì hoạt động. Nếu bạn dùng công cụ này thường xuyên, bạn có thể xem xét đóng góp để hỗ trợ chúng tôi. Chúng tôi rất biết ơn vì điều đó!", - "support_button": "Help keep the website free", + "support_button": "Hãy giúp duy trì trang web miễn phí", "download_file": "Tải tệp xuống", "download_files": "Tải xuống tất cả", "edit": "Chỉnh sửa", "undo": "Hoàn tác", "redo": "Khôi phục", "delete": "Xóa", - "delete_all": "Delete all", + "delete_all": "Xóa tất cả", "select_all": "Chọn tất cả", "view": "Xem", "elevation_profile": "Thông tin độ cao", - "tree_file_view": "File tree", + "tree_file_view": "Cây thư mục", "switch_basemap": "Quay lại bản đồ trước đó", "toggle_overlays": "Thay đổi lớp phủ", "toggle_3d": "Chuyển đổi 3D", @@ -57,35 +57,35 @@ "layers": "Lớp bản đồ...", "distance_markers": "Đánh dấu khoảng cách", "direction_markers": "Mũi tên định hướng", - "help": "Help", - "more": "More...", - "donate": "Donate", + "help": "Trợ giúp", + "more": "Chi tiết...", + "donate": "Ủng hộ", "ctrl": "Ctrl", - "click": "Click", - "drag": "Drag", + "click": "Nhấp chuột", + "drag": "Kéo", "metadata": { - "button": "Info...", - "name": "Name", - "description": "Description", - "save": "Save" + "button": "Thông tin...", + "name": "Tên", + "description": "Mô tả", + "save": "Lưu" }, "style": { - "button": "Appearance...", - "color": "Color", - "opacity": "Opacity", - "width": "Width" + "button": "Diện mạo...", + "color": "Màu sắc", + "opacity": "Độ trong suốt", + "width": "Độ rộng" }, - "hide": "Hide", - "unhide": "Unhide", - "center": "Center", - "open_in": "Open in", - "copy_coordinates": "Copy coordinates", - "edit_osm": "Edit in OpenStreetMap" + "hide": "Ẩn", + "unhide": "Bỏ ẩn", + "center": "Giữa", + "open_in": "Mở ra", + "copy_coordinates": "Sao chép tọa độ", + "edit_osm": "" }, "toolbar": { "routing": { "tooltip": "Plan or edit a route", - "activity": "Activity", + "activity": "Hoạt động", "use_routing": "Routing", "use_routing_tooltip": "Connect anchor points via road network, or in a straight line if disabled", "allow_private": "Allow private roads", @@ -94,14 +94,14 @@ "tooltip": "Reverse the direction of the route" }, "route_back_to_start": { - "button": "Back to start", + "button": "Quay về Bắt đầu", "tooltip": "Connect the last point of the route with the starting point" }, "round_trip": { "button": "Round trip", "tooltip": "Return to the starting point by the same route" }, - "start_loop_here": "Start loop here", + "start_loop_here": "Bắt đầu lặp ở đây", "help_no_file": "Select a trace to use the routing tool, or click on the map to start creating a new route.", "help": "Click on the map to add a new anchor point, or drag existing ones to change the route.", "activities": { @@ -127,15 +127,15 @@ "wood": "Wood", "compacted": "Compacted gravel", "fine_gravel": "Fine gravel", - "gravel": "Gravel", + "gravel": "Sỏi", "pebblestone": "Pebblestone", - "rock": "Rock", - "dirt": "Dirt", + "rock": "Đá", + "dirt": "Đất", "ground": "Ground", "earth": "Earth", "mud": "Mud", "sand": "Sand", - "grass": "Grass", + "grass": "Cỏ", "grass_paver": "Grass paver", "clay": "Clay", "stone": "Stone" @@ -282,6 +282,7 @@ "update": "Update layer" }, "opacity": "Overlay opacity", + "terrain": "Terrain source", "label": { "basemaps": "Basemaps", "overlays": "Overlays", @@ -325,6 +326,8 @@ "usgs": "USGS", "bikerouterGravel": "bikerouter.de Gravel", "cyclOSMlite": "CyclOSM Lite", + "mapterhornHillshade": "Mapterhorn Hillshade", + "openRailwayMap": "OpenRailwayMap", "swisstopoSlope": "swisstopo Slope", "swisstopoHiking": "swisstopo Hiking", "swisstopoHikingClosures": "swisstopo Hiking Closures", @@ -377,7 +380,9 @@ "railway-station": "Railway Station", "tram-stop": "Tram Stop", "bus-stop": "Bus Stop", - "ferry": "Ferry" + "ferry": "Ferry", + "mapbox-dem": "Mapbox DEM", + "mapterhorn": "Mapterhorn" } }, "chart": { diff --git a/website/src/locales/zh-HK.json b/website/src/locales/zh-HK.json index 54f37955c..13590168d 100644 --- a/website/src/locales/zh-HK.json +++ b/website/src/locales/zh-HK.json @@ -282,6 +282,7 @@ "update": "Update layer" }, "opacity": "Overlay opacity", + "terrain": "Terrain source", "label": { "basemaps": "Basemaps", "overlays": "Overlays", @@ -325,6 +326,8 @@ "usgs": "USGS", "bikerouterGravel": "bikerouter.de Gravel", "cyclOSMlite": "CyclOSM Lite", + "mapterhornHillshade": "Mapterhorn Hillshade", + "openRailwayMap": "OpenRailwayMap", "swisstopoSlope": "swisstopo Slope", "swisstopoHiking": "swisstopo Hiking", "swisstopoHikingClosures": "swisstopo Hiking Closures", @@ -377,7 +380,9 @@ "railway-station": "Railway Station", "tram-stop": "Tram Stop", "bus-stop": "Bus Stop", - "ferry": "Ferry" + "ferry": "Ferry", + "mapbox-dem": "Mapbox DEM", + "mapterhorn": "Mapterhorn" } }, "chart": { diff --git a/website/src/locales/zh.json b/website/src/locales/zh.json index e8b234c8e..a18e741ea 100644 --- a/website/src/locales/zh.json +++ b/website/src/locales/zh.json @@ -230,8 +230,8 @@ "help_invalid_selection": "须先选择包含多个轨迹的文件以提取。" }, "elevation": { - "button": "请求数据", - "help": "请求成功后将使用 Mapbox 海拔数据替换原有数据。", + "button": "请求海拔数据", + "help": "请求成功后将移除原有的海拔数据,并使用 Mapbox 的海拔数据替换原有数据。", "help_no_selection": "选择要请求海拔数据的文件。" }, "waypoint": { @@ -282,6 +282,7 @@ "update": "更新图层" }, "opacity": "图层透明度", + "terrain": "Terrain source", "label": { "basemaps": "底图", "overlays": "叠加层", @@ -325,6 +326,8 @@ "usgs": "USGS", "bikerouterGravel": "bikerouter.de Gravel", "cyclOSMlite": "CyclOSM Lite", + "mapterhornHillshade": "Mapterhorn Hillshade", + "openRailwayMap": "OpenRailwayMap", "swisstopoSlope": "Swisstopo Slope", "swisstopoHiking": "Swisstopo Hiking", "swisstopoHikingClosures": "Swisstopo Hiking Closures", @@ -377,7 +380,9 @@ "railway-station": "火车站", "tram-stop": "有轨电车站", "bus-stop": "小型公交站台", - "ferry": "渡口" + "ferry": "渡口", + "mapbox-dem": "Mapbox DEM", + "mapterhorn": "Mapterhorn" } }, "chart": {