mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-03-13 16:22:59 +00:00
Compare commits
1 Commits
l10n
...
c9472e10be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9472e10be |
@@ -1,3 +1,6 @@
|
||||
website/src/lib/components/ui
|
||||
website/src/lib/docs/**/*.mdx
|
||||
**/*.webmanifest
|
||||
# Ignore files for PNPM, NPM and YARN
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
src/lib/components/ui
|
||||
*.mdx
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 gpx.studio
|
||||
Copyright (c) 2024 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
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"postinstall": "npm run build",
|
||||
"lint": "prettier --check . --config ../.prettierrc && eslint .",
|
||||
"format": "prettier --write . --config ../.prettierrc"
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"format": "prettier --write ."
|
||||
}
|
||||
}
|
||||
|
||||
675
gpx/src/gpx.ts
675
gpx/src/gpx.ts
@@ -1,5 +1,4 @@
|
||||
import { ramerDouglasPeucker } from './simplify';
|
||||
import { GPXStatistics, GPXStatisticsGroup, TrackPointLocalStatistics } from './statistics';
|
||||
import {
|
||||
Coordinates,
|
||||
GPXFileAttributes,
|
||||
@@ -18,9 +17,6 @@ import {
|
||||
import { immerable, isDraft, original, freeze } from 'immer';
|
||||
|
||||
function cloneJSON<T>(obj: T): T {
|
||||
if (obj === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (obj === null || typeof obj !== 'object') {
|
||||
return null;
|
||||
}
|
||||
@@ -37,6 +33,7 @@ export abstract class GPXTreeElement<T extends GPXTreeElement<any>> {
|
||||
abstract getNumberOfTrackPoints(): number;
|
||||
abstract getStartTimestamp(): Date | undefined;
|
||||
abstract getEndTimestamp(): Date | undefined;
|
||||
abstract getStatistics(): GPXStatistics;
|
||||
abstract getSegments(): TrackSegment[];
|
||||
abstract getTrackPoints(): TrackPoint[];
|
||||
|
||||
@@ -76,6 +73,14 @@ abstract class GPXTreeNode<T extends GPXTreeElement<any>> 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());
|
||||
}
|
||||
@@ -140,9 +145,7 @@ export class GPXFile extends GPXTreeNode<Track> {
|
||||
},
|
||||
},
|
||||
};
|
||||
this.wpt = gpx.wpt
|
||||
? gpx.wpt.map((waypoint, index) => new Waypoint(waypoint, index))
|
||||
: [];
|
||||
this.wpt = gpx.wpt ? gpx.wpt.map((waypoint) => new Waypoint(waypoint)) : [];
|
||||
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)));
|
||||
@@ -180,6 +183,9 @@ export class GPXFile extends GPXTreeNode<Track> {
|
||||
segment._data['segmentIndex'] = segmentIndex;
|
||||
});
|
||||
});
|
||||
this.wpt.forEach((waypoint, waypointIndex) => {
|
||||
waypoint._data['index'] = waypointIndex;
|
||||
});
|
||||
}
|
||||
|
||||
get children(): Array<Track> {
|
||||
@@ -200,16 +206,8 @@ export class GPXFile extends GPXTreeNode<Track> {
|
||||
});
|
||||
}
|
||||
|
||||
getStatistics(): GPXStatisticsGroup {
|
||||
let statistics = new GPXStatisticsGroup();
|
||||
this.forEachSegment((segment) => {
|
||||
statistics.add(segment.getStatistics());
|
||||
});
|
||||
return statistics;
|
||||
}
|
||||
|
||||
getStyle(defaultColor?: string): MergedLineStyles {
|
||||
const style = this.trk
|
||||
return this.trk
|
||||
.map((track) => track.getStyle())
|
||||
.reduce(
|
||||
(acc, style) => {
|
||||
@@ -219,6 +217,8 @@ export class GPXFile extends GPXTreeNode<Track> {
|
||||
!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 &&
|
||||
@@ -242,10 +242,6 @@ export class GPXFile extends GPXTreeNode<Track> {
|
||||
width: [],
|
||||
}
|
||||
);
|
||||
if (style.color.length === 0 && defaultColor) {
|
||||
style.color.push(defaultColor);
|
||||
}
|
||||
return style;
|
||||
}
|
||||
|
||||
clone(): GPXFile {
|
||||
@@ -808,7 +804,7 @@ export class TrackSegment extends GPXTreeLeaf {
|
||||
constructor(segment?: (TrackSegmentType & { _data?: any }) | TrackSegment) {
|
||||
super();
|
||||
if (segment) {
|
||||
this.trkpt = segment.trkpt.map((point, index) => new TrackPoint(point, index));
|
||||
this.trkpt = segment.trkpt.map((point) => new TrackPoint(point));
|
||||
if (segment.hasOwnProperty('_data')) {
|
||||
this._data = segment._data;
|
||||
}
|
||||
@@ -820,12 +816,15 @@ export class TrackSegment extends GPXTreeLeaf {
|
||||
_computeStatistics(): GPXStatistics {
|
||||
let statistics = new GPXStatistics();
|
||||
|
||||
statistics.global.length = this.trkpt.length;
|
||||
statistics.local.points = this.trkpt.slice(0);
|
||||
statistics.local.data = this.trkpt.map(() => new TrackPointLocalStatistics());
|
||||
statistics.local.points = this.trkpt.map((point) => point);
|
||||
|
||||
statistics.local.elevation.smoothed = this._computeSmoothedElevation();
|
||||
statistics.local.slope.at = this._computeSlope();
|
||||
|
||||
const points = this.trkpt;
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
points[i]._data['index'] = i;
|
||||
|
||||
// distance
|
||||
let dist = 0;
|
||||
if (i > 0) {
|
||||
@@ -834,18 +833,34 @@ export class TrackSegment extends GPXTreeLeaf {
|
||||
statistics.global.distance.total += dist;
|
||||
}
|
||||
|
||||
statistics.local.data[i].distance.total = statistics.global.distance.total;
|
||||
statistics.local.distance.total.push(statistics.global.distance.total);
|
||||
|
||||
// elevation
|
||||
if (i > 0) {
|
||||
const ele =
|
||||
statistics.local.elevation.smoothed[i] -
|
||||
statistics.local.elevation.smoothed[i - 1];
|
||||
if (ele > 0) {
|
||||
statistics.global.elevation.gain += ele;
|
||||
} else if (ele < 0) {
|
||||
statistics.global.elevation.loss -= ele;
|
||||
}
|
||||
}
|
||||
|
||||
statistics.local.elevation.gain.push(statistics.global.elevation.gain);
|
||||
statistics.local.elevation.loss.push(statistics.global.elevation.loss);
|
||||
|
||||
// time
|
||||
if (points[i].time === undefined) {
|
||||
statistics.local.data[i].time.total = 0;
|
||||
statistics.local.time.total.push(0);
|
||||
} else {
|
||||
if (statistics.global.time.start === undefined) {
|
||||
statistics.global.time.start = points[i].time;
|
||||
}
|
||||
statistics.global.time.end = points[i].time;
|
||||
statistics.local.data[i].time.total =
|
||||
(points[i].time.getTime() - statistics.global.time.start.getTime()) / 1000;
|
||||
statistics.local.time.total.push(
|
||||
(points[i].time.getTime() - statistics.global.time.start.getTime()) / 1000
|
||||
);
|
||||
}
|
||||
|
||||
// speed
|
||||
@@ -860,8 +875,8 @@ export class TrackSegment extends GPXTreeLeaf {
|
||||
}
|
||||
}
|
||||
|
||||
statistics.local.data[i].distance.moving = statistics.global.distance.moving;
|
||||
statistics.local.data[i].time.moving = statistics.global.time.moving;
|
||||
statistics.local.distance.moving.push(statistics.global.distance.moving);
|
||||
statistics.local.time.moving.push(statistics.global.time.moving);
|
||||
|
||||
// bounds
|
||||
statistics.global.bounds.southWest.lat = Math.min(
|
||||
@@ -945,7 +960,8 @@ export class TrackSegment extends GPXTreeLeaf {
|
||||
}
|
||||
}
|
||||
|
||||
this._elevationComputation(statistics);
|
||||
[statistics.local.slope.segment, statistics.local.slope.length] =
|
||||
this._computeSlopeSegments(statistics);
|
||||
|
||||
statistics.global.time.total =
|
||||
statistics.global.time.start && statistics.global.time.end
|
||||
@@ -961,115 +977,73 @@ export class TrackSegment extends GPXTreeLeaf {
|
||||
? statistics.global.distance.moving / (statistics.global.time.moving / 3600)
|
||||
: 0;
|
||||
|
||||
timeWindowSmoothing(
|
||||
statistics.local.speed = distanceWindowSmoothingWithDistanceAccumulator(
|
||||
points,
|
||||
10000,
|
||||
(start, end) =>
|
||||
200,
|
||||
(accumulated, 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;
|
||||
}
|
||||
? (3600 * accumulated) /
|
||||
(points[end].time.getTime() - points[start].time.getTime())
|
||||
: undefined
|
||||
);
|
||||
|
||||
return statistics;
|
||||
}
|
||||
|
||||
_elevationComputation(statistics: GPXStatistics) {
|
||||
_computeSmoothedElevation(): number[] {
|
||||
const points = this.trkpt;
|
||||
|
||||
let smoothed = distanceWindowSmoothing(
|
||||
points,
|
||||
100,
|
||||
(index) => points[index].ele ?? 0,
|
||||
(accumulated, start, end) => accumulated / (end - start + 1)
|
||||
);
|
||||
|
||||
if (points.length > 0) {
|
||||
smoothed[0] = points[0].ele ?? 0;
|
||||
smoothed[points.length - 1] = points[points.length - 1].ele ?? 0;
|
||||
}
|
||||
|
||||
return smoothed;
|
||||
}
|
||||
|
||||
_computeSlope(): number[] {
|
||||
const points = this.trkpt;
|
||||
|
||||
return distanceWindowSmoothingWithDistanceAccumulator(
|
||||
points,
|
||||
50,
|
||||
(accumulated, start, end) =>
|
||||
(100 * ((points[end].ele ?? 0) - (points[start].ele ?? 0))) /
|
||||
(accumulated > 0 ? accumulated : 1)
|
||||
);
|
||||
}
|
||||
|
||||
_computeSlopeSegments(statistics: GPXStatistics): [number[], number[]] {
|
||||
let simplified = ramerDouglasPeucker(
|
||||
this.trkpt,
|
||||
20,
|
||||
getElevationDistanceFunction(statistics)
|
||||
);
|
||||
|
||||
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 cumulEle = 0;
|
||||
let currentStart = start;
|
||||
let currentEnd = start;
|
||||
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;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
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;
|
||||
}
|
||||
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.data[end].distance.total -
|
||||
statistics.local.data[start].distance.total;
|
||||
statistics.local.distance.total[end] - statistics.local.distance.total[start];
|
||||
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++) {
|
||||
statistics.local.data[j].slope.segment = (0.1 * ele) / dist;
|
||||
statistics.local.data[j].slope.length = dist;
|
||||
slope.push((0.1 * ele) / dist);
|
||||
length.push(dist);
|
||||
}
|
||||
}
|
||||
|
||||
distanceWindowSmoothing(
|
||||
0,
|
||||
this.trkpt.length,
|
||||
statistics,
|
||||
0.05,
|
||||
(start, end) => {
|
||||
const ele = this.trkpt[end].ele - this.trkpt[start].ele || 0;
|
||||
const dist =
|
||||
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;
|
||||
}
|
||||
);
|
||||
return [slope, length];
|
||||
}
|
||||
|
||||
getNumberOfTrackPoints(): number {
|
||||
@@ -1316,8 +1290,8 @@ export class TrackSegment extends GPXTreeLeaf {
|
||||
lastPoint: TrackPoint | undefined
|
||||
) {
|
||||
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);
|
||||
let slope = og._computeSlope();
|
||||
let trkpt = withArtificialTimestamps(og.trkpt, totalTime, lastPoint, startTime, slope);
|
||||
this.trkpt = freeze(trkpt); // Pre-freeze the array, faster as well
|
||||
}
|
||||
|
||||
@@ -1326,7 +1300,6 @@ export class TrackSegment extends GPXTreeLeaf {
|
||||
}
|
||||
}
|
||||
|
||||
const emptyExtensions: Record<string, string> = {};
|
||||
export class TrackPoint {
|
||||
[immerable] = true;
|
||||
|
||||
@@ -1337,7 +1310,7 @@ export class TrackPoint {
|
||||
|
||||
_data: { [key: string]: any } = {};
|
||||
|
||||
constructor(point: (TrackPointType & { _data?: any }) | TrackPoint, index?: number) {
|
||||
constructor(point: (TrackPointType & { _data?: any }) | TrackPoint) {
|
||||
this.attributes = point.attributes;
|
||||
this.ele = point.ele;
|
||||
this.time = point.time;
|
||||
@@ -1345,9 +1318,6 @@ export class TrackPoint {
|
||||
if (point.hasOwnProperty('_data')) {
|
||||
this._data = point._data;
|
||||
}
|
||||
if (index !== undefined) {
|
||||
this._data.index = index;
|
||||
}
|
||||
}
|
||||
|
||||
getCoordinates(): Coordinates {
|
||||
@@ -1421,7 +1391,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 {
|
||||
@@ -1491,18 +1461,11 @@ export class TrackPoint {
|
||||
|
||||
clone(): TrackPoint {
|
||||
return new TrackPoint({
|
||||
attributes: {
|
||||
lat: this.attributes.lat,
|
||||
lon: this.attributes.lon,
|
||||
},
|
||||
attributes: cloneJSON(this.attributes),
|
||||
ele: this.ele,
|
||||
time: this.time ? new Date(this.time.getTime()) : undefined,
|
||||
extensions: this.extensions ? cloneJSON(this.extensions) : undefined,
|
||||
_data: {
|
||||
index: this._data?.index,
|
||||
anchor: this._data?.anchor,
|
||||
zoom: this._data?.zoom,
|
||||
},
|
||||
extensions: cloneJSON(this.extensions),
|
||||
_data: cloneJSON(this._data),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1521,28 +1484,19 @@ export class Waypoint {
|
||||
type?: string;
|
||||
_data: { [key: string]: any } = {};
|
||||
|
||||
constructor(waypoint: (WaypointType & { _data?: any }) | Waypoint, index?: number) {
|
||||
constructor(waypoint: (WaypointType & { _data?: any }) | Waypoint) {
|
||||
this.attributes = waypoint.attributes;
|
||||
this.ele = waypoint.ele;
|
||||
this.time = waypoint.time;
|
||||
this.name = waypoint.name === '' ? undefined : waypoint.name;
|
||||
this.cmt = waypoint.cmt === '' ? undefined : waypoint.cmt;
|
||||
this.desc = waypoint.desc === '' ? undefined : waypoint.desc;
|
||||
this.link =
|
||||
!waypoint.link ||
|
||||
!waypoint.link.attributes ||
|
||||
!waypoint.link.attributes.href ||
|
||||
waypoint.link.attributes.href === ''
|
||||
? undefined
|
||||
: waypoint.link;
|
||||
this.sym = waypoint.sym === '' ? undefined : waypoint.sym;
|
||||
this.type = waypoint.type === '' ? undefined : waypoint.type;
|
||||
this.name = waypoint.name;
|
||||
this.cmt = waypoint.cmt;
|
||||
this.desc = waypoint.desc;
|
||||
this.link = waypoint.link;
|
||||
this.sym = waypoint.sym;
|
||||
this.type = waypoint.type;
|
||||
if (waypoint.hasOwnProperty('_data')) {
|
||||
this._data = waypoint._data;
|
||||
}
|
||||
if (index !== undefined) {
|
||||
this._data.index = index;
|
||||
}
|
||||
}
|
||||
|
||||
getCoordinates(): Coordinates {
|
||||
@@ -1590,10 +1544,7 @@ export class Waypoint {
|
||||
|
||||
clone(): Waypoint {
|
||||
return new Waypoint({
|
||||
attributes: {
|
||||
lat: this.attributes.lat,
|
||||
lon: this.attributes.lon,
|
||||
},
|
||||
attributes: cloneJSON(this.attributes),
|
||||
ele: this.ele,
|
||||
time: this.time ? new Date(this.time.getTime()) : undefined,
|
||||
name: this.name,
|
||||
@@ -1642,6 +1593,310 @@ 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<string, Record<string, number>>;
|
||||
};
|
||||
local: {
|
||||
points: TrackPoint[];
|
||||
distance: {
|
||||
moving: number[];
|
||||
total: number[];
|
||||
};
|
||||
time: {
|
||||
moving: number[];
|
||||
total: number[];
|
||||
};
|
||||
speed: number[];
|
||||
elevation: {
|
||||
smoothed: number[];
|
||||
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: {
|
||||
smoothed: [],
|
||||
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.elevation.smoothed = this.local.elevation.smoothed.concat(
|
||||
other.local.elevation.smoothed
|
||||
);
|
||||
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,
|
||||
@@ -1656,15 +1911,11 @@ export function distance(
|
||||
const rad = Math.PI / 180;
|
||||
const lat1 = coord1.lat * rad;
|
||||
const lat2 = coord2.lat * rad;
|
||||
const dLat = lat2 - lat1;
|
||||
const dLon = (coord2.lon - coord1.lon) * rad;
|
||||
|
||||
// Haversine formula - better numerical stability for small distances
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
||||
const c = 2 * Math.asin(Math.sqrt(Math.min(a, 1)));
|
||||
return earthRadius * c;
|
||||
Math.sin(lat1) * Math.sin(lat2) +
|
||||
Math.cos(lat1) * Math.cos(lat2) * Math.cos((coord2.lon - coord1.lon) * rad);
|
||||
const maxMeters = earthRadius * Math.acos(Math.min(a, 1));
|
||||
return maxMeters;
|
||||
}
|
||||
|
||||
export function getElevationDistanceFunction(statistics: GPXStatistics) {
|
||||
@@ -1675,9 +1926,9 @@ export function getElevationDistanceFunction(statistics: GPXStatistics) {
|
||||
if (point1.ele === undefined || point2.ele === undefined || point3.ele === undefined) {
|
||||
return 0;
|
||||
}
|
||||
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 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 y1 = point1.ele;
|
||||
let y2 = point2.ele;
|
||||
let y3 = point3.ele;
|
||||
@@ -1691,61 +1942,57 @@ export function getElevationDistanceFunction(statistics: GPXStatistics) {
|
||||
};
|
||||
}
|
||||
|
||||
function windowSmoothing(
|
||||
left: number,
|
||||
right: number,
|
||||
distance: (index1: number, index2: number) => number,
|
||||
window: number,
|
||||
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) {
|
||||
function distanceWindowSmoothing(
|
||||
points: TrackPoint[],
|
||||
distanceWindow: number,
|
||||
accumulate: (index: number) => number,
|
||||
compute: (accumulated: number, start: number, end: number) => number,
|
||||
remove?: (index: number) => number
|
||||
): number[] {
|
||||
let result = [];
|
||||
|
||||
let start = 0,
|
||||
end = 0,
|
||||
accumulated = 0;
|
||||
for (var i = 0; i < points.length; i++) {
|
||||
while (
|
||||
start + 1 < i &&
|
||||
distance(points[start].getCoordinates(), points[i].getCoordinates()) > distanceWindow
|
||||
) {
|
||||
if (remove) {
|
||||
accumulated -= remove(start);
|
||||
} else {
|
||||
accumulated -= accumulate(start);
|
||||
}
|
||||
start++;
|
||||
}
|
||||
let end = Math.min(i + 2, right);
|
||||
while (end < right && distance(i, end) <= window) {
|
||||
while (
|
||||
end < points.length &&
|
||||
distance(points[i].getCoordinates(), points[end].getCoordinates()) <= distanceWindow
|
||||
) {
|
||||
accumulated += accumulate(end);
|
||||
end++;
|
||||
}
|
||||
callback(compute(start, end - 1), i);
|
||||
result[i] = compute(accumulated, start, end - 1);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function distanceWindowSmoothing(
|
||||
left: number,
|
||||
right: number,
|
||||
statistics: GPXStatistics,
|
||||
window: number,
|
||||
compute: (start: number, end: number) => number,
|
||||
callback: (value: number, index: number) => void
|
||||
): void {
|
||||
windowSmoothing(
|
||||
left,
|
||||
right,
|
||||
(index1, index2) =>
|
||||
statistics.local.data[index2].distance.total -
|
||||
statistics.local.data[index1].distance.total,
|
||||
window,
|
||||
compute,
|
||||
callback
|
||||
);
|
||||
}
|
||||
|
||||
function timeWindowSmoothing(
|
||||
function distanceWindowSmoothingWithDistanceAccumulator(
|
||||
points: TrackPoint[],
|
||||
window: number,
|
||||
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,
|
||||
distanceWindow: number,
|
||||
compute: (accumulated: number, start: number, end: number) => number
|
||||
): number[] {
|
||||
return distanceWindowSmoothing(
|
||||
points,
|
||||
distanceWindow,
|
||||
(index) =>
|
||||
index > 0
|
||||
? distance(points[index - 1].getCoordinates(), points[index].getCoordinates())
|
||||
: 0,
|
||||
compute,
|
||||
callback
|
||||
(index) => distance(points[index].getCoordinates(), points[index + 1].getCoordinates())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1797,14 +2044,14 @@ function withArtificialTimestamps(
|
||||
totalTime: number,
|
||||
lastPoint: TrackPoint | undefined,
|
||||
startTime: Date,
|
||||
statistics: GPXStatistics
|
||||
slope: number[]
|
||||
): 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 * statistics.local.data[i].slope.at)));
|
||||
let w = dist * (0.5 + 1 / (1 + Math.exp(-0.2 * slope[i])));
|
||||
weight.push(w);
|
||||
totalWeight += w;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export * from './gpx';
|
||||
export * from './statistics';
|
||||
export { Coordinates, LineStyleExtension, WaypointType } from './types';
|
||||
export { parseGPX, buildGPX } from './io';
|
||||
export * from './simplify';
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Coordinates } from './types';
|
||||
|
||||
export type SimplifiedTrackPoint = { point: TrackPoint; distance?: number };
|
||||
|
||||
const earthRadius = 6371008.8;
|
||||
|
||||
export function ramerDouglasPeucker(
|
||||
points: TrackPoint[],
|
||||
epsilon: number = 50,
|
||||
@@ -59,56 +61,76 @@ function ramerDouglasPeuckerRecursive(
|
||||
}
|
||||
|
||||
export function crossarcDistance(
|
||||
point1: TrackPoint | Coordinates,
|
||||
point2: TrackPoint | Coordinates,
|
||||
point1: TrackPoint,
|
||||
point2: TrackPoint,
|
||||
point3: TrackPoint | Coordinates
|
||||
): number {
|
||||
return crossarc(
|
||||
point1 instanceof TrackPoint ? point1.getCoordinates() : point1,
|
||||
point2 instanceof TrackPoint ? point2.getCoordinates() : point2,
|
||||
point1.getCoordinates(),
|
||||
point2.getCoordinates(),
|
||||
point3 instanceof TrackPoint ? point3.getCoordinates() : point3
|
||||
);
|
||||
}
|
||||
|
||||
const metersPerLatitudeDegree = 111320;
|
||||
|
||||
function getMetersPerLongitudeDegree(latitude: number): number {
|
||||
return Math.cos((latitude * Math.PI) / 180) * metersPerLatitudeDegree;
|
||||
}
|
||||
|
||||
function crossarc(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates): number {
|
||||
// Calculates the perpendicular distance in meters
|
||||
// between a line segment (defined by p1 and p2) and a third point, p3.
|
||||
// Uses simple planar geometry (ignores earth curvature).
|
||||
// Calculates the shortest distance in meters
|
||||
// between an arc (defined by p1 and p2) and a third point, p3.
|
||||
// Input lat1,lon1,lat2,lon2,lat3,lon3 in degrees.
|
||||
|
||||
// Convert to meters using approximate scaling
|
||||
const metersPerLongitudeDegree = getMetersPerLongitudeDegree(coord1.lat);
|
||||
const rad = Math.PI / 180;
|
||||
const lat1 = coord1.lat * rad;
|
||||
const lat2 = coord2.lat * rad;
|
||||
const lat3 = coord3.lat * rad;
|
||||
|
||||
const x1 = coord1.lon * metersPerLongitudeDegree;
|
||||
const y1 = coord1.lat * metersPerLatitudeDegree;
|
||||
const x2 = coord2.lon * metersPerLongitudeDegree;
|
||||
const y2 = coord2.lat * metersPerLatitudeDegree;
|
||||
const x3 = coord3.lon * metersPerLongitudeDegree;
|
||||
const y3 = coord3.lat * metersPerLatitudeDegree;
|
||||
const lon1 = coord1.lon * rad;
|
||||
const lon2 = coord2.lon * rad;
|
||||
const lon3 = coord3.lon * rad;
|
||||
|
||||
const dx = x2 - x1;
|
||||
const dy = y2 - y1;
|
||||
const segmentLengthSquared = dx * dx + dy * dy;
|
||||
// Prerequisites for the formulas
|
||||
const bear12 = bearing(lat1, lon1, lat2, lon2);
|
||||
const bear13 = bearing(lat1, lon1, lat3, lon3);
|
||||
let dis13 = distance(lat1, lon1, lat3, lon3);
|
||||
|
||||
if (segmentLengthSquared === 0) {
|
||||
// p1 and p2 are the same point
|
||||
return Math.sqrt((x3 - x1) * (x3 - x1) + (y3 - y1) * (y3 - y1));
|
||||
let diff = Math.abs(bear13 - bear12);
|
||||
if (diff > Math.PI) {
|
||||
diff = 2 * Math.PI - diff;
|
||||
}
|
||||
|
||||
// Project p3 onto the line defined by p1-p2
|
||||
const t = Math.max(0, Math.min(1, ((x3 - x1) * dx + (y3 - y1) * dy) / segmentLengthSquared));
|
||||
// Is relative bearing obtuse?
|
||||
if (diff > Math.PI / 2) {
|
||||
return dis13;
|
||||
}
|
||||
|
||||
// Find the closest point on the segment
|
||||
const projX = x1 + t * dx;
|
||||
const projY = y1 + t * dy;
|
||||
// Find the cross-track distance.
|
||||
let dxt = Math.asin(Math.sin(dis13 / earthRadius) * Math.sin(bear13 - bear12)) * earthRadius;
|
||||
|
||||
// Return distance from p3 to the projected point
|
||||
return Math.sqrt((x3 - projX) * (x3 - projX) + (y3 - projY) * (y3 - projY));
|
||||
// Is p4 beyond the arc?
|
||||
let dis12 = distance(lat1, lon1, lat2, lon2);
|
||||
let dis14 =
|
||||
Math.acos(Math.cos(dis13 / earthRadius) / Math.cos(dxt / earthRadius)) * earthRadius;
|
||||
if (dis14 > dis12) {
|
||||
return distance(lat2, lon2, lat3, lon3);
|
||||
} else {
|
||||
return Math.abs(dxt);
|
||||
}
|
||||
}
|
||||
|
||||
function distance(latA: number, lonA: number, latB: number, lonB: number): number {
|
||||
// Finds the distance between two lat / lon points.
|
||||
return (
|
||||
Math.acos(
|
||||
Math.sin(latA) * Math.sin(latB) +
|
||||
Math.cos(latA) * Math.cos(latB) * Math.cos(lonB - lonA)
|
||||
) * earthRadius
|
||||
);
|
||||
}
|
||||
|
||||
function bearing(latA: number, lonA: number, latB: number, lonB: number): number {
|
||||
// Finds the bearing from one lat / lon point to another.
|
||||
return Math.atan2(
|
||||
Math.sin(lonB - lonA) * Math.cos(latB),
|
||||
Math.cos(latA) * Math.sin(latB) - Math.sin(latA) * Math.cos(latB) * Math.cos(lonB - lonA)
|
||||
);
|
||||
}
|
||||
|
||||
export function projectedPoint(
|
||||
@@ -124,39 +146,56 @@ export function projectedPoint(
|
||||
}
|
||||
|
||||
function projected(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates): Coordinates {
|
||||
// Calculates the point on the line segment defined by p1 and p2
|
||||
// Calculates the point on the line defined by p1 and p2
|
||||
// that is closest to the third point, p3.
|
||||
// Uses simple planar geometry (ignores earth curvature).
|
||||
// Input lat1,lon1,lat2,lon2,lat3,lon3 in degrees.
|
||||
|
||||
// Convert to meters using approximate scaling
|
||||
const metersPerLongitudeDegree = getMetersPerLongitudeDegree(coord1.lat);
|
||||
const rad = Math.PI / 180;
|
||||
const lat1 = coord1.lat * rad;
|
||||
const lat2 = coord2.lat * rad;
|
||||
const lat3 = coord3.lat * rad;
|
||||
|
||||
const x1 = coord1.lon * metersPerLongitudeDegree;
|
||||
const y1 = coord1.lat * metersPerLatitudeDegree;
|
||||
const x2 = coord2.lon * metersPerLongitudeDegree;
|
||||
const y2 = coord2.lat * metersPerLatitudeDegree;
|
||||
const x3 = coord3.lon * metersPerLongitudeDegree;
|
||||
const y3 = coord3.lat * metersPerLatitudeDegree;
|
||||
const lon1 = coord1.lon * rad;
|
||||
const lon2 = coord2.lon * rad;
|
||||
const lon3 = coord3.lon * rad;
|
||||
|
||||
const dx = x2 - x1;
|
||||
const dy = y2 - y1;
|
||||
const segmentLengthSquared = dx * dx + dy * dy;
|
||||
// Prerequisites for the formulas
|
||||
const bear12 = bearing(lat1, lon1, lat2, lon2);
|
||||
const bear13 = bearing(lat1, lon1, lat3, lon3);
|
||||
let dis13 = distance(lat1, lon1, lat3, lon3);
|
||||
|
||||
if (segmentLengthSquared === 0) {
|
||||
// p1 and p2 are the same point
|
||||
let diff = Math.abs(bear13 - bear12);
|
||||
if (diff > Math.PI) {
|
||||
diff = 2 * Math.PI - diff;
|
||||
}
|
||||
|
||||
// Is relative bearing obtuse?
|
||||
if (diff > Math.PI / 2) {
|
||||
return coord1;
|
||||
}
|
||||
|
||||
// Project p3 onto the line defined by p1-p2
|
||||
const t = Math.max(0, Math.min(1, ((x3 - x1) * dx + (y3 - y1) * dy) / segmentLengthSquared));
|
||||
// Find the cross-track distance.
|
||||
let dxt = Math.asin(Math.sin(dis13 / earthRadius) * Math.sin(bear13 - bear12)) * earthRadius;
|
||||
|
||||
// Find the closest point on the segment
|
||||
const projX = x1 + t * dx;
|
||||
const projY = y1 + t * dy;
|
||||
// Is p4 beyond the arc?
|
||||
let dis12 = distance(lat1, lon1, lat2, lon2);
|
||||
let dis14 =
|
||||
Math.acos(Math.cos(dis13 / earthRadius) / Math.cos(dxt / earthRadius)) * earthRadius;
|
||||
if (dis14 > dis12) {
|
||||
return coord2;
|
||||
} else {
|
||||
// Determine the closest point (p4) on the great circle
|
||||
const f = dis14 / earthRadius;
|
||||
const lat4 = Math.asin(
|
||||
Math.sin(lat1) * Math.cos(f) + Math.cos(lat1) * Math.sin(f) * Math.cos(bear12)
|
||||
);
|
||||
const lon4 =
|
||||
lon1 +
|
||||
Math.atan2(
|
||||
Math.sin(bear12) * Math.sin(f) * Math.cos(lat1),
|
||||
Math.cos(f) - Math.sin(lat1) * Math.sin(lat4)
|
||||
);
|
||||
|
||||
// Convert back to degrees
|
||||
return {
|
||||
lat: projY / metersPerLatitudeDegree,
|
||||
lon: projX / metersPerLongitudeDegree,
|
||||
};
|
||||
return { lat: lat4 / rad, lon: lon4 / rad };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,391 +0,0 @@
|
||||
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<string, Record<string, number>>;
|
||||
|
||||
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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
6
package-lock.json
generated
Normal file
6
package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "gpx.studio",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
119
website/package-lock.json
generated
119
website/package-lock.json
generated
@@ -14,7 +14,7 @@
|
||||
"@mapbox/sphericalmercator": "^2.0.1",
|
||||
"@mapbox/tilebelt": "^2.0.2",
|
||||
"@types/mapbox__sphericalmercator": "^1.2.3",
|
||||
"chart.js": "^4.5.1",
|
||||
"chart.js": "^4.4.9",
|
||||
"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.17.0",
|
||||
"mapbox-gl": "^3.12.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.14.4",
|
||||
"bits-ui": "^2.12.0",
|
||||
"eslint": "^9.28.0",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-plugin-svelte": "^3.9.1",
|
||||
@@ -1701,10 +1701,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/point-geometry": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
|
||||
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
|
||||
"license": "ISC"
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz",
|
||||
"integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ=="
|
||||
},
|
||||
"node_modules/@mapbox/polyline": {
|
||||
"version": "1.2.1",
|
||||
@@ -1739,26 +1738,11 @@
|
||||
"integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw=="
|
||||
},
|
||||
"node_modules/@mapbox/vector-tile": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz",
|
||||
"integrity": "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz",
|
||||
"integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "~1.1.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"pbf": "^4.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/vector-tile/node_modules/pbf": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz",
|
||||
"integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"resolve-protobuf-schema": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"pbf": "bin/pbf"
|
||||
"@mapbox/point-geometry": "~0.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/whoots-js": {
|
||||
@@ -2660,8 +2644,7 @@
|
||||
"node_modules/@types/mapbox__point-geometry": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/mapbox__point-geometry/-/mapbox__point-geometry-0.1.4.tgz",
|
||||
"integrity": "sha512-mUWlSxAmYLfwnRBmgYV86tgYmMIICX4kza8YnE/eIlywGe2XoOxlpVnXWwir92xRLjwyarqwpu2EJKD2pk0IUA==",
|
||||
"license": "MIT"
|
||||
"integrity": "sha512-mUWlSxAmYLfwnRBmgYV86tgYmMIICX4kza8YnE/eIlywGe2XoOxlpVnXWwir92xRLjwyarqwpu2EJKD2pk0IUA=="
|
||||
},
|
||||
"node_modules/@types/mapbox__sphericalmercator": {
|
||||
"version": "1.2.3",
|
||||
@@ -2677,6 +2660,16 @@
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/mapbox__vector-tile": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/mapbox__vector-tile/-/mapbox__vector-tile-1.3.4.tgz",
|
||||
"integrity": "sha512-bpd8dRn9pr6xKvuEBQup8pwQfD4VUyqO/2deGjfpe6AwC8YRlyEipvefyRJUSiCJTZuCb8Pl1ciVV5ekqJ96Bg==",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*",
|
||||
"@types/mapbox__point-geometry": "*",
|
||||
"@types/pbf": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/mapbox-gl": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/mapbox-gl/-/mapbox-gl-3.4.1.tgz",
|
||||
@@ -3241,9 +3234,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/bits-ui": {
|
||||
"version": "2.14.4",
|
||||
"resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.14.4.tgz",
|
||||
"integrity": "sha512-W6kenhnbd/YVvur+DKkaVJ6GldE53eLewur5AhUCqslYQ0vjZr8eWlOfwZnMiPB+PF5HMVqf61vXBvmyrAmPWg==",
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.12.0.tgz",
|
||||
"integrity": "sha512-8NF4ILNyAJlIxDXpl/akGXGBV5QmZAe+8gTfPttM5P6/+LrijumcSfFXY5cr4QkXwTmLA7H5stYpbgJf2XFJvg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -3664,9 +3657,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"version": "4.4.9",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.9.tgz",
|
||||
"integrity": "sha512-EyZ9wWKgpAU0fLJ43YAEIF8sr5F2W3LqbS40ZJyHIner2lY14ufqv2VMp69MAiZ2rpwxEUxEhIH/0U3xyRynxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
@@ -4954,10 +4947,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/gl-matrix": {
|
||||
"version": "3.4.4",
|
||||
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
|
||||
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
|
||||
"license": "MIT"
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.3.tgz",
|
||||
"integrity": "sha512-wcCp8vu8FT22BnvKVPjXa/ICBWRq/zjFfdofZy1WSpQZpphblv12/bOQLBC1rMM7SGOFS9ltVmKOHil5+Ml7gA=="
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "11.0.2",
|
||||
@@ -6069,55 +6061,44 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mapbox-gl": {
|
||||
"version": "3.17.0",
|
||||
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.17.0.tgz",
|
||||
"integrity": "sha512-nCrDKRlr5di6xUksUDslNWwxroJ5yv1hT8pyVFtcpWJOOKsYQxF/wOFTMie8oxMnXeFkrz1Tl1TwA1XN1yX0KA==",
|
||||
"version": "3.12.0",
|
||||
"resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.12.0.tgz",
|
||||
"integrity": "sha512-DV6TRr+xoPrLSKuGiUcbyLVkoLdNaNNpn6O7+ZC27yQH7BOOIF7l6JKbTCMhfMJuZBVJfL8YRJjlMJ6MZCTggA==",
|
||||
"license": "SEE LICENSE IN LICENSE.txt",
|
||||
"workspaces": [
|
||||
"src/style-spec",
|
||||
"test/build/vite",
|
||||
"test/build/webpack",
|
||||
"test/build/typings"
|
||||
],
|
||||
"dependencies": {
|
||||
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
|
||||
"@mapbox/mapbox-gl-supported": "^3.0.0",
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"@mapbox/point-geometry": "^0.1.0",
|
||||
"@mapbox/tiny-sdf": "^2.0.6",
|
||||
"@mapbox/unitbezier": "^0.0.1",
|
||||
"@mapbox/vector-tile": "^2.0.4",
|
||||
"@mapbox/vector-tile": "^1.3.1",
|
||||
"@mapbox/whoots-js": "^3.1.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"@types/geojson-vt": "^3.2.5",
|
||||
"@types/mapbox__point-geometry": "^0.1.4",
|
||||
"@types/mapbox__vector-tile": "^1.3.4",
|
||||
"@types/pbf": "^3.0.5",
|
||||
"@types/supercluster": "^7.1.3",
|
||||
"cheap-ruler": "^4.0.0",
|
||||
"csscolorparser": "~1.0.3",
|
||||
"earcut": "^3.0.1",
|
||||
"geojson-vt": "^4.0.2",
|
||||
"gl-matrix": "^3.4.4",
|
||||
"gl-matrix": "^3.4.3",
|
||||
"grid-index": "^1.1.0",
|
||||
"kdbush": "^4.0.2",
|
||||
"martinez-polygon-clipping": "^0.7.4",
|
||||
"murmurhash-js": "^1.0.0",
|
||||
"pbf": "^4.0.1",
|
||||
"pbf": "^3.2.1",
|
||||
"potpack": "^2.0.0",
|
||||
"quickselect": "^3.0.0",
|
||||
"serialize-to-js": "^3.1.2",
|
||||
"supercluster": "^8.0.1",
|
||||
"tinyqueue": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mapbox-gl/node_modules/pbf": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz",
|
||||
"integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"resolve-protobuf-schema": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"pbf": "bin/pbf"
|
||||
"tinyqueue": "^3.0.0",
|
||||
"vt-pbf": "^3.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/mapillary-js": {
|
||||
@@ -7635,6 +7616,14 @@
|
||||
"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",
|
||||
@@ -9032,6 +9021,16 @@
|
||||
"integrity": "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/vt-pbf": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz",
|
||||
"integrity": "sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "0.1.0",
|
||||
"@mapbox/vector-tile": "^1.3.1",
|
||||
"pbf": "^3.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
@@ -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 . --config ../.prettierrc --ignore-path ../.prettierignore --ignore-path ./.gitignore && eslint .",
|
||||
"format": "prettier --write . --config ../.prettierrc --ignore-path ../.prettierignore --ignore-path ./.gitignore"
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"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.14.4",
|
||||
"bits-ui": "^2.12.0",
|
||||
"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.5.1",
|
||||
"chart.js": "^4.4.9",
|
||||
"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.17.0",
|
||||
"mapbox-gl": "^3.12.0",
|
||||
"mapillary-js": "^4.1.2",
|
||||
"png.js": "^0.2.1",
|
||||
"sanitize-html": "^2.17.0",
|
||||
|
||||
@@ -1,126 +1,124 @@
|
||||
@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);
|
||||
|
||||
--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);
|
||||
}
|
||||
|
||||
|
||||
@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);
|
||||
/* 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);
|
||||
|
||||
/* 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;
|
||||
--breakpoint-xs: 540px;
|
||||
}
|
||||
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
@@ -24,14 +24,6 @@ export async function handle({ event, resolve }) {
|
||||
|
||||
let headTag = `<head>
|
||||
<title>gpx.studio — ${title}</title>
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"name": "gpx.studio",
|
||||
"url": "https://gpx.studio"
|
||||
}
|
||||
</script>
|
||||
<meta name="description" content="${description}" />
|
||||
<meta property="og:title" content="gpx.studio — ${title}" />
|
||||
<meta property="og:description" content="${description}" />
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
}
|
||||
},
|
||||
"sprite": "https://demotiles.maplibre.org/styles/osm-bright-gl-style/sprite",
|
||||
"glyphs": "https://api.maptiler.com/fonts/{fontstack}/{range}.pbf?key={key}",
|
||||
"layers": [
|
||||
{
|
||||
"id": "background",
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
Binoculars,
|
||||
Toilet,
|
||||
} from 'lucide-static';
|
||||
import { type RasterDEMSourceSpecification, type StyleSpecification } from 'mapbox-gl';
|
||||
import { 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';
|
||||
@@ -145,7 +145,7 @@ export const basemaps: { [key: string]: string | StyleSpecification } = {
|
||||
swisstopoVector: 'https://vectortiles.geo.admin.ch/styles/ch.swisstopo.basemap.vt/style.json',
|
||||
swisstopoSatellite:
|
||||
'https://vectortiles.geo.admin.ch/styles/ch.swisstopo.imagerybasemap.vt/style.json',
|
||||
linz: 'https://basemaps.linz.govt.nz/v1/styles/topographic-v2.json?api=d01fbtg0ar23gctac5m0jgyy2ds',
|
||||
linz: 'https://basemaps.linz.govt.nz/v1/tiles/topographic/EPSG:3857/style/topographic.json?api=d01fbtg0ar23gctac5m0jgyy2ds',
|
||||
linzTopo: {
|
||||
version: 8,
|
||||
sources: {
|
||||
@@ -368,42 +368,6 @@ 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 <a href="https://www.openstreetmap.org/copyright">© OpenStreetMap contributors</a>, Style: <a href="http://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA 2.0</a> <a href="http://www.openrailwaymap.org/">OpenRailwayMap</a>',
|
||||
},
|
||||
},
|
||||
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: {
|
||||
@@ -835,10 +799,8 @@ export const overlayTree: LayerTreeType = {
|
||||
waymarkedTrailsHorseRiding: true,
|
||||
waymarkedTrailsWinter: true,
|
||||
},
|
||||
bikerouterGravel: true,
|
||||
cyclOSMlite: true,
|
||||
mapterhornHillshade: true,
|
||||
openRailwayMap: true,
|
||||
bikerouterGravel: true,
|
||||
},
|
||||
countries: {
|
||||
france: {
|
||||
@@ -874,7 +836,6 @@ export const overpassTree: LayerTreeType = {
|
||||
shower: true,
|
||||
shelter: true,
|
||||
barrier: true,
|
||||
cemetery: true,
|
||||
},
|
||||
tourism: {
|
||||
attraction: true,
|
||||
@@ -921,10 +882,8 @@ export const defaultOverlays: LayerTreeType = {
|
||||
waymarkedTrailsHorseRiding: false,
|
||||
waymarkedTrailsWinter: false,
|
||||
},
|
||||
bikerouterGravel: false,
|
||||
cyclOSMlite: false,
|
||||
mapterhornHillshade: false,
|
||||
openRailwayMap: false,
|
||||
bikerouterGravel: false,
|
||||
},
|
||||
countries: {
|
||||
france: {
|
||||
@@ -960,7 +919,6 @@ export const defaultOverpassQueries: LayerTreeType = {
|
||||
shower: false,
|
||||
shelter: false,
|
||||
barrier: false,
|
||||
cemetery: false,
|
||||
},
|
||||
tourism: {
|
||||
attraction: false,
|
||||
@@ -1058,10 +1016,8 @@ export const defaultOverlayTree: LayerTreeType = {
|
||||
waymarkedTrailsHorseRiding: false,
|
||||
waymarkedTrailsWinter: false,
|
||||
},
|
||||
bikerouterGravel: false,
|
||||
cyclOSMlite: false,
|
||||
mapterhornHillshade: false,
|
||||
openRailwayMap: false,
|
||||
bikerouterGravel: false,
|
||||
},
|
||||
countries: {
|
||||
france: {
|
||||
@@ -1097,7 +1053,6 @@ export const defaultOverpassTree: LayerTreeType = {
|
||||
shower: false,
|
||||
shelter: false,
|
||||
barrier: false,
|
||||
cemetery: false,
|
||||
},
|
||||
tourism: {
|
||||
attraction: false,
|
||||
@@ -1144,7 +1099,9 @@ type OverpassQueryData = {
|
||||
svg: string;
|
||||
color: string;
|
||||
};
|
||||
tags: Record<string, string | string[]> | Record<string, string | string[]>[];
|
||||
tags:
|
||||
| Record<string, string | boolean | string[]>
|
||||
| Record<string, string | boolean | string[]>[];
|
||||
symbol?: string;
|
||||
};
|
||||
|
||||
@@ -1225,20 +1182,6 @@ export const overpassQueryData: Record<string, OverpassQueryData> = {
|
||||
},
|
||||
symbol: 'Shelter',
|
||||
},
|
||||
cemetery: {
|
||||
icon: {
|
||||
svg: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 17v-10a6 5 0 1 1 12 0v10"/><path d="M 4 21 a 1 1 0 0 0 1 1 h 14 a 1 1 0 0 0 1-1 v -1 a 2 2 0 0 0-2-2 H6 a 2 2 0 0 0-2 2 z"/></svg>',
|
||||
color: '#000000',
|
||||
},
|
||||
tags: [
|
||||
{
|
||||
landuse: 'cemetery',
|
||||
},
|
||||
{
|
||||
amenity: 'grave_yard',
|
||||
},
|
||||
],
|
||||
},
|
||||
'fuel-station': {
|
||||
icon: {
|
||||
svg: Fuel,
|
||||
@@ -1275,25 +1218,7 @@ export const overpassQueryData: Record<string, OverpassQueryData> = {
|
||||
color: '#000000',
|
||||
},
|
||||
tags: {
|
||||
barrier: [
|
||||
'bar',
|
||||
'barrier_board',
|
||||
'block',
|
||||
'chain',
|
||||
'cycle_barrier',
|
||||
'gate',
|
||||
'hampshire_gate',
|
||||
'horse_stile',
|
||||
'kissing_gate',
|
||||
'lift_gate',
|
||||
'motorcycle_barrier',
|
||||
'sliding_beam',
|
||||
'sliding_gate',
|
||||
'stile',
|
||||
'swing_gate',
|
||||
'turnstile',
|
||||
'wicket_gate',
|
||||
],
|
||||
barrier: true,
|
||||
},
|
||||
},
|
||||
attraction: {
|
||||
@@ -1453,18 +1378,3 @@ export const overpassQueryData: Record<string, OverpassQueryData> = {
|
||||
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';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
Landmark,
|
||||
Icon,
|
||||
Shell,
|
||||
Bike,
|
||||
Building,
|
||||
@@ -28,7 +29,6 @@ import {
|
||||
TriangleAlert,
|
||||
Anchor,
|
||||
Toilet,
|
||||
X,
|
||||
type IconProps,
|
||||
} from '@lucide/svelte';
|
||||
import {
|
||||
@@ -61,7 +61,6 @@ import {
|
||||
TriangleAlert as TriangleAlertSvg,
|
||||
Anchor as AnchorSvg,
|
||||
Toilet as ToiletSvg,
|
||||
X as XSvg,
|
||||
} from 'lucide-static';
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
@@ -88,11 +87,7 @@ export const symbols: { [key: string]: Symbol } = {
|
||||
icon: ShoppingBasket,
|
||||
iconSvg: ShoppingBasketSvg,
|
||||
},
|
||||
crossing: {
|
||||
value: 'Crossing',
|
||||
icon: X,
|
||||
iconSvg: XSvg,
|
||||
},
|
||||
crossing: { value: 'Crossing' },
|
||||
department_store: {
|
||||
value: 'Department Store',
|
||||
icon: ShoppingBasket,
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
href="https://github.com/gpxstudio/gpx.studio/blob/main/LICENSE"
|
||||
target="_blank"
|
||||
>
|
||||
MIT © 2026 gpx.studio
|
||||
MIT © 2025 gpx.studio
|
||||
</Button>
|
||||
<LanguageSelect class="w-40 mt-3" />
|
||||
</div>
|
||||
@@ -34,7 +34,6 @@
|
||||
{i18n._('homepage.home')}
|
||||
</Button>
|
||||
<Button
|
||||
data-sveltekit-reload
|
||||
variant="link"
|
||||
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
||||
href={getURLForLanguage(i18n.lang, '/app')}
|
||||
@@ -71,6 +70,15 @@
|
||||
<Logo company="facebook" class="h-4 fill-muted-foreground" />
|
||||
{i18n._('homepage.facebook')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
||||
href="https://x.com/gpxstudio"
|
||||
target="_blank"
|
||||
>
|
||||
<Logo company="x" class="h-4 fill-muted-foreground" />
|
||||
{i18n._('homepage.x')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { MoveDownRight, MoveUpRight, Ruler, Timer, Zap } from '@lucide/svelte';
|
||||
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import type { GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx';
|
||||
import type { GPXStatistics } from 'gpx';
|
||||
import type { Readable } from 'svelte/store';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
|
||||
@@ -18,14 +18,14 @@
|
||||
orientation,
|
||||
panelSize,
|
||||
}: {
|
||||
gpxStatistics: Readable<GPXStatisticsGroup>;
|
||||
slicedGPXStatistics: Readable<[GPXGlobalStatistics, number, number] | undefined>;
|
||||
gpxStatistics: Readable<GPXStatistics>;
|
||||
slicedGPXStatistics: Readable<[GPXStatistics, number, number] | undefined>;
|
||||
orientation: 'horizontal' | 'vertical';
|
||||
panelSize: number;
|
||||
} = $props();
|
||||
|
||||
let statistics = $derived(
|
||||
$slicedGPXStatistics !== undefined ? $slicedGPXStatistics[0] : $gpxStatistics.global
|
||||
$slicedGPXStatistics !== undefined ? $slicedGPXStatistics[0] : $gpxStatistics
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -42,15 +42,15 @@
|
||||
<Tooltip label={i18n._('quantities.distance')}>
|
||||
<span class="flex flex-row items-center">
|
||||
<Ruler size="16" class="mr-1" />
|
||||
<WithUnits value={statistics.distance.total} type="distance" />
|
||||
<WithUnits value={statistics.global.distance.total} type="distance" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip label={i18n._('quantities.elevation_gain_loss')}>
|
||||
<span class="flex flex-row items-center">
|
||||
<MoveUpRight size="16" class="mr-1" />
|
||||
<WithUnits value={statistics.elevation.gain} type="elevation" />
|
||||
<WithUnits value={statistics.global.elevation.gain} type="elevation" />
|
||||
<MoveDownRight size="16" class="mx-1" />
|
||||
<WithUnits value={statistics.elevation.loss} type="elevation" />
|
||||
<WithUnits value={statistics.global.elevation.loss} type="elevation" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
{#if panelSize > 120 || orientation === 'horizontal'}
|
||||
@@ -64,9 +64,13 @@
|
||||
>
|
||||
<span class="flex flex-row items-center">
|
||||
<Zap size="16" class="mr-1" />
|
||||
<WithUnits value={statistics.speed.moving} type="speed" showUnits={false} />
|
||||
<WithUnits
|
||||
value={statistics.global.speed.moving}
|
||||
type="speed"
|
||||
showUnits={false}
|
||||
/>
|
||||
<span class="mx-1">/</span>
|
||||
<WithUnits value={statistics.speed.total} type="speed" />
|
||||
<WithUnits value={statistics.global.speed.total} type="speed" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
@@ -79,9 +83,9 @@
|
||||
>
|
||||
<span class="flex flex-row items-center">
|
||||
<Timer size="16" class="mr-1" />
|
||||
<WithUnits value={statistics.time.moving} type="time" />
|
||||
<WithUnits value={statistics.global.time.moving} type="time" />
|
||||
<span class="mx-1">/</span>
|
||||
<WithUnits value={statistics.time.total} type="time" />
|
||||
<WithUnits value={statistics.global.time.total} type="time" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
...others
|
||||
}: {
|
||||
iconOnly?: boolean;
|
||||
company?: 'gpx.studio' | 'mapbox' | 'github' | 'crowdin' | 'facebook' | 'reddit';
|
||||
company?: 'gpx.studio' | 'mapbox' | 'github' | 'crowdin' | 'facebook' | 'x' | 'reddit';
|
||||
[key: string]: any;
|
||||
} = $props();
|
||||
</script>
|
||||
@@ -55,6 +55,16 @@
|
||||
d="M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z"
|
||||
/></svg
|
||||
>
|
||||
{:else if company === 'x'}
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="fill-foreground {others.class ?? ''}"
|
||||
><title>X</title><path
|
||||
d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z"
|
||||
/></svg
|
||||
>
|
||||
{:else if company === 'reddit'}
|
||||
<svg
|
||||
role="img"
|
||||
|
||||
@@ -538,7 +538,6 @@
|
||||
let targetInput =
|
||||
e &&
|
||||
e.target &&
|
||||
e.target instanceof HTMLElement &&
|
||||
(e.target.tagName === 'INPUT' ||
|
||||
e.target.tagName === 'TEXTAREA' ||
|
||||
e.target.tagName === 'SELECT' ||
|
||||
@@ -645,19 +644,6 @@
|
||||
} else if (e.key === 'F5') {
|
||||
$routing = !$routing;
|
||||
e.preventDefault();
|
||||
} else if (
|
||||
e.key === 'ArrowRight' ||
|
||||
e.key === 'ArrowDown' ||
|
||||
e.key === 'ArrowLeft' ||
|
||||
e.key === 'ArrowUp'
|
||||
) {
|
||||
if (!targetInput) {
|
||||
selection.updateFromKey(
|
||||
e.key === 'ArrowRight' || e.key === 'ArrowDown',
|
||||
e.shiftKey
|
||||
);
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
}}
|
||||
on:dragover={(e) => e.preventDefault()}
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
{i18n._('homepage.home')}
|
||||
</Button>
|
||||
<Button
|
||||
data-sveltekit-reload
|
||||
variant="link"
|
||||
class="text-base px-0 has-[>svg]:px-0"
|
||||
href={getURLForLanguage(i18n.lang, '/app')}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
Construction,
|
||||
} from '@lucide/svelte';
|
||||
import type { Readable, Writable } from 'svelte/store';
|
||||
import type { GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx';
|
||||
import type { GPXStatistics } 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<GPXStatisticsGroup>;
|
||||
slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>;
|
||||
gpxStatistics: Readable<GPXStatistics>;
|
||||
slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>;
|
||||
additionalDatasets: Writable<string[]>;
|
||||
elevationFill: Writable<'slope' | 'surface' | 'highway' | undefined>;
|
||||
showControls?: boolean;
|
||||
|
||||
@@ -14,16 +14,11 @@ import {
|
||||
getTemperatureWithUnits,
|
||||
getVelocityWithUnits,
|
||||
} from '$lib/units';
|
||||
import Chart, {
|
||||
type ChartEvent,
|
||||
type ChartOptions,
|
||||
type ScriptableLineSegmentContext,
|
||||
type TooltipItem,
|
||||
} from 'chart.js/auto';
|
||||
import Chart 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 { GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx';
|
||||
import type { GPXStatistics } from 'gpx';
|
||||
import { mode } from 'mode-watcher';
|
||||
import { getHighwayColor, getSlopeColor, getSurfaceColor } from '$lib/assets/colors';
|
||||
|
||||
@@ -32,20 +27,6 @@ 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<string, any>;
|
||||
coordinates: [number, number];
|
||||
index: number;
|
||||
}
|
||||
|
||||
export class ElevationProfile {
|
||||
private _chart: Chart | null = null;
|
||||
private _canvas: HTMLCanvasElement;
|
||||
@@ -54,14 +35,14 @@ export class ElevationProfile {
|
||||
private _dragging = false;
|
||||
private _panning = false;
|
||||
|
||||
private _gpxStatistics: Readable<GPXStatisticsGroup>;
|
||||
private _slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>;
|
||||
private _gpxStatistics: Readable<GPXStatistics>;
|
||||
private _slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>;
|
||||
private _additionalDatasets: Readable<string[]>;
|
||||
private _elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>;
|
||||
|
||||
constructor(
|
||||
gpxStatistics: Readable<GPXStatisticsGroup>,
|
||||
slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined>,
|
||||
gpxStatistics: Readable<GPXStatistics>,
|
||||
slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>,
|
||||
additionalDatasets: Readable<string[]>,
|
||||
elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>,
|
||||
canvas: HTMLCanvasElement,
|
||||
@@ -109,7 +90,7 @@ export class ElevationProfile {
|
||||
}
|
||||
|
||||
initialize() {
|
||||
let options: ChartOptions<'line'> = {
|
||||
let options = {
|
||||
animation: false,
|
||||
parsing: false,
|
||||
maintainAspectRatio: false,
|
||||
@@ -117,8 +98,8 @@ export class ElevationProfile {
|
||||
x: {
|
||||
type: 'linear',
|
||||
ticks: {
|
||||
callback: function (value: number | string) {
|
||||
return `${(value as number).toFixed(1).replace(/\.0+$/, '')} ${getDistanceUnits()}`;
|
||||
callback: function (value: number) {
|
||||
return `${value.toFixed(1).replace(/\.0+$/, '')} ${getDistanceUnits()}`;
|
||||
},
|
||||
align: 'inner',
|
||||
maxRotation: 0,
|
||||
@@ -127,8 +108,8 @@ export class ElevationProfile {
|
||||
y: {
|
||||
type: 'linear',
|
||||
ticks: {
|
||||
callback: function (value: number | string) {
|
||||
return getElevationWithUnits(value as number, false);
|
||||
callback: function (value: number) {
|
||||
return getElevationWithUnits(value, false);
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -159,8 +140,8 @@ export class ElevationProfile {
|
||||
title: () => {
|
||||
return '';
|
||||
},
|
||||
label: (context: TooltipItem<'line'>) => {
|
||||
let point = context.raw as ElevationProfilePoint;
|
||||
label: (context: Chart.TooltipContext) => {
|
||||
let point = context.raw;
|
||||
if (context.datasetIndex === 0) {
|
||||
const map_ = get(map);
|
||||
if (map_ && this._marker) {
|
||||
@@ -184,10 +165,10 @@ export class ElevationProfile {
|
||||
return `${i18n._('quantities.power')}: ${getPowerWithUnits(point.y)}`;
|
||||
}
|
||||
},
|
||||
afterBody: (contexts: TooltipItem<'line'>[]) => {
|
||||
afterBody: (contexts: Chart.TooltipContext[]) => {
|
||||
let context = contexts.filter((context) => context.datasetIndex === 0);
|
||||
if (context.length === 0) return;
|
||||
let point = context[0].raw as ElevationProfilePoint;
|
||||
let point = context[0].raw;
|
||||
let slope = {
|
||||
at: point.slope.at.toFixed(1),
|
||||
segment: point.slope.segment.toFixed(1),
|
||||
@@ -246,7 +227,6 @@ export class ElevationProfile {
|
||||
onPanStart: () => {
|
||||
this._panning = true;
|
||||
this._slicedGPXStatistics.set(undefined);
|
||||
return true;
|
||||
},
|
||||
onPanComplete: () => {
|
||||
this._panning = false;
|
||||
@@ -258,13 +238,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(maxZoom / this._chart.getZoomLevel()) < 0.01
|
||||
Math.abs(
|
||||
this._chart.getInitialScaleBounds().x.max /
|
||||
this._chart.options.plugins.zoom.limits.x.minRange -
|
||||
this._chart.getZoomLevel()
|
||||
) < 0.01
|
||||
) {
|
||||
// Disable wheel pan if zoomed in to the max, and zooming in
|
||||
return false;
|
||||
@@ -282,6 +262,7 @@ export class ElevationProfile {
|
||||
},
|
||||
},
|
||||
},
|
||||
stacked: false,
|
||||
onResize: () => {
|
||||
this.updateOverlay();
|
||||
},
|
||||
@@ -289,7 +270,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: {
|
||||
@@ -310,7 +291,7 @@ export class ElevationProfile {
|
||||
{
|
||||
id: 'toggleMarker',
|
||||
events: ['mouseout'],
|
||||
afterEvent: (chart: Chart, args: { event: ChartEvent }) => {
|
||||
afterEvent: (chart: Chart, args: { event: Chart.ChartEvent }) => {
|
||||
if (args.event.type === 'mouseout') {
|
||||
const map_ = get(map);
|
||||
if (map_ && this._marker) {
|
||||
@@ -324,7 +305,7 @@ export class ElevationProfile {
|
||||
|
||||
let startIndex = 0;
|
||||
let endIndex = 0;
|
||||
const getIndex = (evt: PointerEvent) => {
|
||||
const getIndex = (evt) => {
|
||||
if (!this._chart) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -342,22 +323,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 this._chart.data.datasets[0].data.length - 1;
|
||||
return get(this._gpxStatistics).local.points.length - 1;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const point = points.find((point) => (point.element as any).raw);
|
||||
let point = points.find((point) => point.element.raw);
|
||||
if (point) {
|
||||
return (point.element as any).raw.index;
|
||||
return point.element.raw.index;
|
||||
} else {
|
||||
return points[0].index;
|
||||
}
|
||||
};
|
||||
|
||||
let dragStarted = false;
|
||||
const onMouseDown = (evt: PointerEvent) => {
|
||||
const onMouseDown = (evt) => {
|
||||
if (evt.shiftKey) {
|
||||
// Panning interaction
|
||||
return;
|
||||
@@ -366,7 +347,7 @@ export class ElevationProfile {
|
||||
this._canvas.style.cursor = 'col-resize';
|
||||
startIndex = getIndex(evt);
|
||||
};
|
||||
const onMouseMove = (evt: PointerEvent) => {
|
||||
const onMouseMove = (evt) => {
|
||||
if (dragStarted) {
|
||||
this._dragging = true;
|
||||
endIndex = getIndex(evt);
|
||||
@@ -375,7 +356,7 @@ export class ElevationProfile {
|
||||
startIndex = endIndex;
|
||||
} else if (startIndex !== endIndex) {
|
||||
this._slicedGPXStatistics.set([
|
||||
get(this._gpxStatistics).sliced(
|
||||
get(this._gpxStatistics).slice(
|
||||
Math.min(startIndex, endIndex),
|
||||
Math.max(startIndex, endIndex)
|
||||
),
|
||||
@@ -386,7 +367,7 @@ export class ElevationProfile {
|
||||
}
|
||||
}
|
||||
};
|
||||
const onMouseUp = (evt: PointerEvent) => {
|
||||
const onMouseUp = (evt) => {
|
||||
dragStarted = false;
|
||||
this._dragging = false;
|
||||
this._canvas.style.cursor = '';
|
||||
@@ -405,99 +386,85 @@ export class ElevationProfile {
|
||||
return;
|
||||
}
|
||||
const data = get(this._gpxStatistics);
|
||||
const units = {
|
||||
distance: get(distanceUnits),
|
||||
velocity: get(velocityUnits),
|
||||
temperature: get(temperatureUnits),
|
||||
};
|
||||
|
||||
const datasets: Array<Array<any>> = [[], [], [], [], [], []];
|
||||
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: datasets[0],
|
||||
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,
|
||||
};
|
||||
}),
|
||||
normalized: true,
|
||||
fill: 'start',
|
||||
order: 1,
|
||||
segment: {},
|
||||
};
|
||||
this._chart.data.datasets[1] = {
|
||||
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,
|
||||
};
|
||||
}),
|
||||
normalized: true,
|
||||
yAxisID: 'yspeed',
|
||||
};
|
||||
this._chart.data.datasets[2] = {
|
||||
data: datasets[2],
|
||||
data: data.local.points.map((point, index) => {
|
||||
return {
|
||||
x: getConvertedDistance(data.local.distance.total[index]),
|
||||
y: point.getHeartRate(),
|
||||
index: index,
|
||||
};
|
||||
}),
|
||||
normalized: true,
|
||||
yAxisID: 'yhr',
|
||||
};
|
||||
this._chart.data.datasets[3] = {
|
||||
data: datasets[3],
|
||||
data: data.local.points.map((point, index) => {
|
||||
return {
|
||||
x: getConvertedDistance(data.local.distance.total[index]),
|
||||
y: point.getCadence(),
|
||||
index: index,
|
||||
};
|
||||
}),
|
||||
normalized: true,
|
||||
yAxisID: 'ycad',
|
||||
};
|
||||
this._chart.data.datasets[4] = {
|
||||
data: datasets[4],
|
||||
data: data.local.points.map((point, index) => {
|
||||
return {
|
||||
x: getConvertedDistance(data.local.distance.total[index]),
|
||||
y: getConvertedTemperature(point.getTemperature()),
|
||||
index: index,
|
||||
};
|
||||
}),
|
||||
normalized: true,
|
||||
yAxisID: 'yatemp',
|
||||
};
|
||||
this._chart.data.datasets[5] = {
|
||||
data: datasets[5],
|
||||
data: data.local.points.map((point, index) => {
|
||||
return {
|
||||
x: getConvertedDistance(data.local.distance.total[index]),
|
||||
y: point.getPower(),
|
||||
index: index,
|
||||
};
|
||||
}),
|
||||
normalized: true,
|
||||
yAxisID: 'ypower',
|
||||
};
|
||||
|
||||
this._chart.options.scales!.x!['min'] = 0;
|
||||
this._chart.options.scales!.x!['max'] = getConvertedDistance(
|
||||
data.global.distance.total,
|
||||
units.distance
|
||||
);
|
||||
this._chart.options.scales.x['min'] = 0;
|
||||
this._chart.options.scales.x['max'] = getConvertedDistance(data.global.distance.total);
|
||||
|
||||
this.setVisibility();
|
||||
this.setFill();
|
||||
@@ -546,24 +513,21 @@ export class ElevationProfile {
|
||||
return;
|
||||
}
|
||||
const elevationFill = get(this._elevationFill);
|
||||
const dataset = this._chart.data.datasets[0];
|
||||
let segment: any = {};
|
||||
if (elevationFill === 'slope') {
|
||||
segment = {
|
||||
this._chart.data.datasets[0]['segment'] = {
|
||||
backgroundColor: this.slopeFillCallback,
|
||||
};
|
||||
} else if (elevationFill === 'surface') {
|
||||
segment = {
|
||||
this._chart.data.datasets[0]['segment'] = {
|
||||
backgroundColor: this.surfaceFillCallback,
|
||||
};
|
||||
} else if (elevationFill === 'highway') {
|
||||
segment = {
|
||||
this._chart.data.datasets[0]['segment'] = {
|
||||
backgroundColor: this.highwayFillCallback,
|
||||
};
|
||||
} else {
|
||||
segment = {};
|
||||
this._chart.data.datasets[0]['segment'] = {};
|
||||
}
|
||||
Object.assign(dataset, { segment });
|
||||
}
|
||||
|
||||
updateOverlay() {
|
||||
@@ -590,12 +554,10 @@ export class ElevationProfile {
|
||||
|
||||
const gpxStatistics = get(this._gpxStatistics);
|
||||
let startPixel = this._chart.scales.x.getPixelForValue(
|
||||
getConvertedDistance(
|
||||
gpxStatistics.getTrackPoint(startIndex)?.distance.total ?? 0
|
||||
)
|
||||
getConvertedDistance(gpxStatistics.local.distance.total[startIndex])
|
||||
);
|
||||
let endPixel = this._chart.scales.x.getPixelForValue(
|
||||
getConvertedDistance(gpxStatistics.getTrackPoint(endIndex)?.distance.total ?? 0)
|
||||
getConvertedDistance(gpxStatistics.local.distance.total[endIndex])
|
||||
);
|
||||
|
||||
selectionContext.fillRect(
|
||||
@@ -613,22 +575,19 @@ export class ElevationProfile {
|
||||
}
|
||||
}
|
||||
|
||||
slopeFillCallback(context: ScriptableLineSegmentContext & { p0: { raw: any } }) {
|
||||
const point = context.p0.raw as ElevationProfilePoint;
|
||||
return getSlopeColor(point.slope.segment);
|
||||
slopeFillCallback(context) {
|
||||
return getSlopeColor(context.p0.raw.slope.segment);
|
||||
}
|
||||
|
||||
surfaceFillCallback(context: ScriptableLineSegmentContext & { p0: { raw: any } }) {
|
||||
const point = context.p0.raw as ElevationProfilePoint;
|
||||
return getSurfaceColor(point.extensions.surface);
|
||||
surfaceFillCallback(context) {
|
||||
return getSurfaceColor(context.p0.raw.extensions.surface);
|
||||
}
|
||||
|
||||
highwayFillCallback(context: ScriptableLineSegmentContext & { p0: { raw: any } }) {
|
||||
const point = context.p0.raw as ElevationProfilePoint;
|
||||
highwayFillCallback(context) {
|
||||
return getHighwayColor(
|
||||
point.extensions.highway,
|
||||
point.extensions.sac_scale,
|
||||
point.extensions.mtb_scale
|
||||
context.p0.raw.extensions.highway,
|
||||
context.p0.raw.extensions.sac_scale,
|
||||
context.p0.raw.extensions.mtb_scale
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
import { loadFile } from '$lib/logic/file-actions';
|
||||
import { selection } from '$lib/logic/selection';
|
||||
import { untrack } from 'svelte';
|
||||
import { isSelected, toggle } from '$lib/components/map/layer-control/utils';
|
||||
|
||||
let {
|
||||
useHash = true,
|
||||
@@ -33,7 +32,6 @@
|
||||
|
||||
const {
|
||||
currentBasemap,
|
||||
selectedBasemapTree,
|
||||
distanceUnits,
|
||||
velocityUnits,
|
||||
temperatureUnits,
|
||||
@@ -68,9 +66,6 @@
|
||||
if (allowedEmbeddingBasemaps.includes(options.basemap)) {
|
||||
$currentBasemap = options.basemap;
|
||||
}
|
||||
if (!isSelected($selectedBasemapTree, options.basemap)) {
|
||||
$selectedBasemapTree = toggle($selectedBasemapTree, options.basemap);
|
||||
}
|
||||
$distanceMarkers = options.distanceMarkers;
|
||||
$directionMarkers = options.directionMarkers;
|
||||
$distanceUnits = options.distanceUnits;
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
SquareActivity,
|
||||
} from '@lucide/svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { GPXGlobalStatistics } from 'gpx';
|
||||
import { GPXStatistics } 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.global;
|
||||
let statistics = $gpxStatistics;
|
||||
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()).global);
|
||||
acc.mergeWith(cur.getStatisticsFor(new ListRootItem()));
|
||||
}
|
||||
return acc;
|
||||
}, new GPXGlobalStatistics());
|
||||
}, new GPXStatistics());
|
||||
}
|
||||
return {
|
||||
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,
|
||||
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,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -121,16 +121,20 @@
|
||||
}
|
||||
|
||||
.vertical :global(button) {
|
||||
@apply hover:bg-[var(--selection)];
|
||||
@apply hover:bg-muted;
|
||||
}
|
||||
|
||||
.vertical :global(.sortable-selected button) {
|
||||
@apply hover:bg-accent;
|
||||
}
|
||||
|
||||
.vertical :global(.sortable-selected) {
|
||||
@apply bg-[var(--selection)];
|
||||
@apply bg-accent;
|
||||
}
|
||||
|
||||
.horizontal :global(button) {
|
||||
@apply bg-[var(--selection)];
|
||||
@apply hover:bg-background;
|
||||
@apply bg-accent;
|
||||
@apply hover:bg-muted;
|
||||
}
|
||||
|
||||
.horizontal :global(.sortable-selected button) {
|
||||
|
||||
@@ -34,10 +34,11 @@
|
||||
import { editStyle } from '$lib/components/file-list/style/utils.svelte';
|
||||
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
||||
import { selection, copied, cut } from '$lib/logic/selection';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import { fileActions, pasteSelection } from '$lib/logic/file-actions';
|
||||
import { allHidden } from '$lib/logic/hidden';
|
||||
import { boundsManager } from '$lib/logic/bounds';
|
||||
import { gpxColors, gpxLayers } from '$lib/components/map/gpx-layer/gpx-layers';
|
||||
import { gpxLayers } from '$lib/components/map/gpx-layer/gpx-layers';
|
||||
import { fileStateCollection } from '$lib/logic/file-state';
|
||||
import { waypointPopup } from '$lib/components/map/gpx-layer/gpx-layer-popup';
|
||||
import { allowedPastes } from './sortable-file-list';
|
||||
@@ -57,31 +58,41 @@
|
||||
|
||||
let singleSelection = $derived($selection.size === 1);
|
||||
|
||||
let nodeColors: string[] = $derived.by(() => {
|
||||
let nodeColors: string[] = $state([]);
|
||||
|
||||
$effect.pre(() => {
|
||||
let colors: string[] = [];
|
||||
if (node) {
|
||||
if (node && $map) {
|
||||
if (node instanceof GPXFile) {
|
||||
let defaultColor = $gpxColors.get(item.getFileId());
|
||||
let defaultColor = undefined;
|
||||
|
||||
let layer = gpxLayers.getLayer(item.getFileId());
|
||||
if (layer) {
|
||||
defaultColor = layer.layerColor;
|
||||
}
|
||||
|
||||
let style = node.getStyle(defaultColor);
|
||||
colors = style.color;
|
||||
style.color.forEach((c) => {
|
||||
if (!colors.includes(c)) {
|
||||
colors.push(c);
|
||||
}
|
||||
});
|
||||
} else if (node instanceof Track) {
|
||||
let style = node.getStyle();
|
||||
if (
|
||||
style &&
|
||||
style['gpx_style:color'] &&
|
||||
!colors.includes(style['gpx_style:color'])
|
||||
) {
|
||||
colors.push(style['gpx_style:color']);
|
||||
if (style) {
|
||||
if (style['gpx_style:color'] && !colors.includes(style['gpx_style:color'])) {
|
||||
colors.push(style['gpx_style:color']);
|
||||
}
|
||||
}
|
||||
if (colors.length === 0) {
|
||||
let defaultColor = $gpxColors.get(item.getFileId());
|
||||
if (defaultColor) {
|
||||
colors.push(defaultColor);
|
||||
let layer = gpxLayers.getLayer(item.getFileId());
|
||||
if (layer) {
|
||||
colors.push(layer.layerColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return colors;
|
||||
nodeColors = colors;
|
||||
});
|
||||
|
||||
let symbolKey = $derived(node instanceof Waypoint ? getSymbolKey(node.sym) : undefined);
|
||||
@@ -164,7 +175,7 @@
|
||||
let file = fileStateCollection.getFile(item.getFileId());
|
||||
if (layer && file) {
|
||||
let waypoint = file.wpt[item.getWaypointIndex()];
|
||||
if (waypoint && !waypoint._data.hidden) {
|
||||
if (waypoint) {
|
||||
waypointPopup?.setItem({
|
||||
item: waypoint,
|
||||
fileId: item.getFileId(),
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
language = 'en';
|
||||
}
|
||||
|
||||
map.init(language, hash, geocoder, geolocate);
|
||||
map.init(PUBLIC_MAPBOX_TOKEN, language, hash, geocoder, geolocate);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
|
||||
@@ -16,8 +16,7 @@
|
||||
</script>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
class="justify-start {className}"
|
||||
class="p-1 has-[>svg]:px-2 h-8 justify-start {className}"
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { gpxLayers } from '$lib/components/map/gpx-layer/gpx-layers';
|
||||
import { DistanceMarkers } from '$lib/components/map/gpx-layer/distance-markers';
|
||||
import { StartEndMarkers } from '$lib/components/map/gpx-layer/start-end-markers';
|
||||
@@ -9,10 +9,13 @@
|
||||
let distanceMarkers: DistanceMarkers;
|
||||
let startEndMarkers: StartEndMarkers;
|
||||
|
||||
map.onLoad((map_) => {
|
||||
onMount(() => {
|
||||
gpxLayers.init();
|
||||
startEndMarkers = new StartEndMarkers();
|
||||
distanceMarkers = new DistanceMarkers();
|
||||
});
|
||||
|
||||
map.onLoad((map_) => {
|
||||
createPopups(map_);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
<script lang="ts">
|
||||
import type { TrackPoint } from 'gpx';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import CopyCoordinates from '$lib/components/map/gpx-layer/CopyCoordinates.svelte';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import WithUnits from '$lib/components/WithUnits.svelte';
|
||||
import { Compass, Earth, Mountain, Timer } from '@lucide/svelte';
|
||||
import { Compass, Mountain, Timer } from '@lucide/svelte';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import type { PopupItem } from '$lib/components/map/map-popup';
|
||||
import { map } from '$lib/components/map/map';
|
||||
|
||||
let { trackpoint }: { trackpoint: PopupItem<TrackPoint> } = $props();
|
||||
</script>
|
||||
@@ -37,17 +35,5 @@
|
||||
onCopy={() => trackpoint.hide?.()}
|
||||
class="mt-0.5"
|
||||
/>
|
||||
{#if trackpoint.fileId === undefined}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="justify-start"
|
||||
href={`https://www.openstreetmap.org/edit?#map=${(($map?.getZoom() ?? 17) + 1).toFixed(0)}/${trackpoint.item.getLatitude().toFixed(5)}/${trackpoint.item.getLongitude().toFixed(5)}`}
|
||||
target="_blank"
|
||||
>
|
||||
<Earth size="14" />
|
||||
{i18n._('menu.edit_osm')}
|
||||
</Button>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
@@ -13,8 +13,6 @@
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||
import { fileActions } from '$lib/logic/file-actions';
|
||||
import type { PopupItem } from '$lib/components/map/map-popup';
|
||||
import { selection } from '$lib/logic/selection';
|
||||
import { ListFileItem } from '$lib/components/file-list/file-list';
|
||||
|
||||
let {
|
||||
waypoint,
|
||||
@@ -22,9 +20,6 @@
|
||||
waypoint: PopupItem<Waypoint>;
|
||||
} = $props();
|
||||
|
||||
let selected = $derived(
|
||||
waypoint.fileId ? $selection.hasAnyChildren(new ListFileItem(waypoint.fileId)) : false
|
||||
);
|
||||
let symbolKey = $derived(waypoint ? getSymbolKey(waypoint.item.sym) : undefined);
|
||||
|
||||
function sanitize(text: string | undefined): string {
|
||||
@@ -86,7 +81,7 @@
|
||||
</ScrollArea>
|
||||
<div class="mt-2 flex flex-col gap-1">
|
||||
<CopyCoordinates coordinates={waypoint.item.attributes} />
|
||||
{#if $currentTool === Tool.WAYPOINT && selected}
|
||||
{#if $currentTool === Tool.WAYPOINT}
|
||||
<Button
|
||||
class="p-1 has-[>svg]:px-2 h-8"
|
||||
variant="outline"
|
||||
|
||||
@@ -3,12 +3,19 @@ import { gpxStatistics } from '$lib/logic/statistics';
|
||||
import { getConvertedDistanceToKilometers } from '$lib/units';
|
||||
import type { GeoJSONSource } from 'mapbox-gl';
|
||||
import { get } from 'svelte/store';
|
||||
import { ANCHOR_LAYER_KEY, map } from '$lib/components/map/map';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import { allHidden } from '$lib/logic/hidden';
|
||||
|
||||
const { distanceMarkers, distanceUnits } = settings;
|
||||
|
||||
const levels = [100, 50, 25, 10, 5, 1];
|
||||
const stops = [
|
||||
[100, 0],
|
||||
[50, 7],
|
||||
[25, 8, 10],
|
||||
[10, 10],
|
||||
[5, 11],
|
||||
[1, 13],
|
||||
];
|
||||
|
||||
export class DistanceMarkers {
|
||||
updateBinded: () => void = this.update.bind(this);
|
||||
@@ -43,33 +50,22 @@ export class DistanceMarkers {
|
||||
data: this.getDistanceMarkersGeoJSON(),
|
||||
});
|
||||
}
|
||||
if (!map_.getLayer('distance-markers')) {
|
||||
map_.addLayer(
|
||||
{
|
||||
id: 'distance-markers',
|
||||
stops.forEach(([d, minzoom, maxzoom]) => {
|
||||
if (!map_.getLayer(`distance-markers-${d}`)) {
|
||||
map_.addLayer({
|
||||
id: `distance-markers-${d}`,
|
||||
type: 'symbol',
|
||||
source: 'distance-markers',
|
||||
filter: [
|
||||
'match',
|
||||
['get', 'level'],
|
||||
100,
|
||||
['>=', ['zoom'], 0],
|
||||
50,
|
||||
['>=', ['zoom'], 7],
|
||||
25,
|
||||
[
|
||||
'any',
|
||||
['all', ['>=', ['zoom'], 8], ['<=', ['zoom'], 9]],
|
||||
['>=', ['zoom'], 11],
|
||||
],
|
||||
10,
|
||||
['>=', ['zoom'], 10],
|
||||
5,
|
||||
['>=', ['zoom'], 11],
|
||||
1,
|
||||
['>=', ['zoom'], 13],
|
||||
false,
|
||||
],
|
||||
filter:
|
||||
d === 5
|
||||
? [
|
||||
'any',
|
||||
['==', ['get', 'level'], 5],
|
||||
['==', ['get', 'level'], 25],
|
||||
]
|
||||
: ['==', ['get', 'level'], d],
|
||||
minzoom: minzoom,
|
||||
maxzoom: maxzoom ?? 24,
|
||||
layout: {
|
||||
'text-field': ['get', 'distance'],
|
||||
'text-size': 14,
|
||||
@@ -80,14 +76,17 @@ export class DistanceMarkers {
|
||||
'text-halo-width': 2,
|
||||
'text-halo-color': 'white',
|
||||
},
|
||||
},
|
||||
ANCHOR_LAYER_KEY.distanceMarkers
|
||||
);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
map_.moveLayer(`distance-markers-${d}`);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (map_.getLayer('distance-markers')) {
|
||||
map_.removeLayer('distance-markers');
|
||||
}
|
||||
stops.forEach(([d]) => {
|
||||
if (map_.getLayer(`distance-markers-${d}`)) {
|
||||
map_.removeLayer(`distance-markers-${d}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to add sources and layers
|
||||
@@ -102,26 +101,35 @@ export class DistanceMarkers {
|
||||
getDistanceMarkersGeoJSON(): GeoJSON.FeatureCollection {
|
||||
let statistics = get(gpxStatistics);
|
||||
|
||||
let features: GeoJSON.Feature[] = [];
|
||||
let features = [];
|
||||
let currentTargetDistance = 1;
|
||||
statistics.forEachTrackPoint((trkpt, dist) => {
|
||||
if (dist >= getConvertedDistanceToKilometers(currentTargetDistance)) {
|
||||
for (let i = 0; i < statistics.local.distance.total.length; i++) {
|
||||
if (
|
||||
statistics.local.distance.total[i] >=
|
||||
getConvertedDistanceToKilometers(currentTargetDistance)
|
||||
) {
|
||||
let distance = currentTargetDistance.toFixed(0);
|
||||
let level = levels.find((level) => currentTargetDistance % level === 0) || 1;
|
||||
let [level, minzoom] = stops.find(([d]) => currentTargetDistance % d === 0) ?? [
|
||||
0, 0,
|
||||
];
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [trkpt.getLongitude(), trkpt.getLatitude()],
|
||||
coordinates: [
|
||||
statistics.local.points[i].getLongitude(),
|
||||
statistics.local.points[i].getLatitude(),
|
||||
],
|
||||
},
|
||||
properties: {
|
||||
distance,
|
||||
level,
|
||||
minzoom,
|
||||
},
|
||||
} as GeoJSON.Feature);
|
||||
currentTargetDistance += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { get, type Readable } from 'svelte/store';
|
||||
import mapboxgl, { type FilterSpecification } from 'mapbox-gl';
|
||||
import { ANCHOR_LAYER_KEY, map } from '$lib/components/map/map';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import { waypointPopup, trackpointPopup } from './gpx-layer-popup';
|
||||
import {
|
||||
ListTrackSegmentItem,
|
||||
@@ -22,7 +22,6 @@ import { fileActionManager } from '$lib/logic/file-action-manager';
|
||||
import { fileActions } from '$lib/logic/file-actions';
|
||||
import { splitAs } from '$lib/components/toolbar/tools/scissors/scissors';
|
||||
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
||||
import { gpxColors } from '$lib/components/map/gpx-layer/gpx-layers';
|
||||
|
||||
const colors = [
|
||||
'#ff0000',
|
||||
@@ -44,49 +43,26 @@ for (let color of colors) {
|
||||
}
|
||||
|
||||
// Get the color with the least amount of uses
|
||||
function getColor(fileId: string) {
|
||||
function getColor() {
|
||||
let color = colors.reduce((a, b) => (colorCount[a] <= colorCount[b] ? a : b));
|
||||
colorCount[color]++;
|
||||
gpxColors.update((colors) => {
|
||||
colors.set(fileId, color);
|
||||
return colors;
|
||||
});
|
||||
return color;
|
||||
}
|
||||
|
||||
function replaceColor(fileId: string, oldColor: string, newColor: string) {
|
||||
if (colorCount.hasOwnProperty(oldColor)) {
|
||||
colorCount[oldColor]--;
|
||||
}
|
||||
colorCount[newColor]++;
|
||||
gpxColors.update((colors) => {
|
||||
colors.set(fileId, newColor);
|
||||
return colors;
|
||||
});
|
||||
}
|
||||
|
||||
function removeColor(fileId: string, color: string) {
|
||||
function decrementColor(color: string) {
|
||||
if (colorCount.hasOwnProperty(color)) {
|
||||
colorCount[color]--;
|
||||
}
|
||||
gpxColors.update((colors) => {
|
||||
colors.delete(fileId);
|
||||
return colors;
|
||||
});
|
||||
}
|
||||
|
||||
export function getSvgForSymbol(symbol?: string | undefined, layerColor?: string | undefined) {
|
||||
function getMarkerForSymbol(symbol: string | undefined, layerColor: string) {
|
||||
let symbolSvg = symbol ? symbols[symbol]?.iconSvg : undefined;
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
${
|
||||
layerColor
|
||||
? Square.replace('width="24"', 'width="12"')
|
||||
.replace('height="24"', 'height="12"')
|
||||
.replace('stroke="currentColor"', 'stroke="SteelBlue"')
|
||||
.replace('stroke-width="2"', 'stroke-width="1.5" x="9.6" y="0.4"')
|
||||
.replace('fill="none"', `fill="${layerColor}"`)
|
||||
: ''
|
||||
}
|
||||
${Square.replace('width="24"', 'width="12"')
|
||||
.replace('height="24"', 'height="12"')
|
||||
.replace('stroke="currentColor"', 'stroke="SteelBlue"')
|
||||
.replace('stroke-width="2"', 'stroke-width="1.5" x="9.6" y="0.4"')
|
||||
.replace('fill="none"', `fill="${layerColor}"`)}
|
||||
${MapPin.replace('width="24"', '')
|
||||
.replace('height="24"', '')
|
||||
.replace('stroke="currentColor"', '')
|
||||
@@ -111,10 +87,9 @@ export class GPXLayer {
|
||||
fileId: string;
|
||||
file: Readable<GPXFileWithStatistics | undefined>;
|
||||
layerColor: string;
|
||||
markers: mapboxgl.Marker[] = [];
|
||||
selected: boolean = false;
|
||||
currentWaypointData: GeoJSON.FeatureCollection | null = null;
|
||||
draggedWaypointIndex: number | null = null;
|
||||
draggingStartingPosition: mapboxgl.Point = new mapboxgl.Point(0, 0);
|
||||
draggable: boolean;
|
||||
unsubscribe: Function[] = [];
|
||||
|
||||
updateBinded: () => void = this.update.bind(this);
|
||||
@@ -123,25 +98,11 @@ export class GPXLayer {
|
||||
layerOnMouseMoveBinded: (e: any) => void = this.layerOnMouseMove.bind(this);
|
||||
layerOnClickBinded: (e: any) => void = this.layerOnClick.bind(this);
|
||||
layerOnContextMenuBinded: (e: any) => void = this.layerOnContextMenu.bind(this);
|
||||
waypointLayerOnMouseEnterBinded: (e: mapboxgl.MapMouseEvent) => void =
|
||||
this.waypointLayerOnMouseEnter.bind(this);
|
||||
waypointLayerOnMouseLeaveBinded: (e: mapboxgl.MapMouseEvent) => void =
|
||||
this.waypointLayerOnMouseLeave.bind(this);
|
||||
waypointLayerOnClickBinded: (e: mapboxgl.MapMouseEvent) => void =
|
||||
this.waypointLayerOnClick.bind(this);
|
||||
waypointLayerOnMouseDownBinded: (e: mapboxgl.MapMouseEvent) => void =
|
||||
this.waypointLayerOnMouseDown.bind(this);
|
||||
waypointLayerOnTouchStartBinded: (e: mapboxgl.MapTouchEvent) => void =
|
||||
this.waypointLayerOnTouchStart.bind(this);
|
||||
waypointLayerOnMouseMoveBinded: (e: mapboxgl.MapMouseEvent | mapboxgl.MapTouchEvent) => void =
|
||||
this.waypointLayerOnMouseMove.bind(this);
|
||||
waypointLayerOnMouseUpBinded: (e: mapboxgl.MapMouseEvent | mapboxgl.MapTouchEvent) => void =
|
||||
this.waypointLayerOnMouseUp.bind(this);
|
||||
|
||||
constructor(fileId: string, file: Readable<GPXFileWithStatistics | undefined>) {
|
||||
this.fileId = fileId;
|
||||
this.file = file;
|
||||
this.layerColor = getColor(fileId);
|
||||
this.layerColor = getColor();
|
||||
this.unsubscribe.push(
|
||||
map.subscribe(($map) => {
|
||||
if ($map) {
|
||||
@@ -164,6 +125,18 @@ export class GPXLayer {
|
||||
})
|
||||
);
|
||||
this.unsubscribe.push(directionMarkers.subscribe(this.updateBinded));
|
||||
this.unsubscribe.push(
|
||||
currentTool.subscribe((tool) => {
|
||||
if (tool === Tool.WAYPOINT && !this.draggable) {
|
||||
this.draggable = true;
|
||||
this.markers.forEach((marker) => marker.setDraggable(true));
|
||||
} else if (tool !== Tool.WAYPOINT && this.draggable) {
|
||||
this.draggable = false;
|
||||
this.markers.forEach((marker) => marker.setDraggable(false));
|
||||
}
|
||||
})
|
||||
);
|
||||
this.draggable = get(currentTool) === Tool.WAYPOINT;
|
||||
}
|
||||
|
||||
update() {
|
||||
@@ -178,12 +151,10 @@ export class GPXLayer {
|
||||
file._data.style.color &&
|
||||
this.layerColor !== `#${file._data.style.color}`
|
||||
) {
|
||||
replaceColor(this.fileId, this.layerColor, `#${file._data.style.color}`);
|
||||
decrementColor(this.layerColor);
|
||||
this.layerColor = `#${file._data.style.color}`;
|
||||
}
|
||||
|
||||
this.loadIcons();
|
||||
|
||||
try {
|
||||
let source = _map.getSource(this.fileId) as mapboxgl.GeoJSONSource | undefined;
|
||||
if (source) {
|
||||
@@ -196,23 +167,20 @@ export class GPXLayer {
|
||||
}
|
||||
|
||||
if (!_map.getLayer(this.fileId)) {
|
||||
_map.addLayer(
|
||||
{
|
||||
id: this.fileId,
|
||||
type: 'line',
|
||||
source: this.fileId,
|
||||
layout: {
|
||||
'line-join': 'round',
|
||||
'line-cap': 'round',
|
||||
},
|
||||
paint: {
|
||||
'line-color': ['get', 'color'],
|
||||
'line-width': ['get', 'width'],
|
||||
'line-opacity': ['get', 'opacity'],
|
||||
},
|
||||
_map.addLayer({
|
||||
id: this.fileId,
|
||||
type: 'line',
|
||||
source: this.fileId,
|
||||
layout: {
|
||||
'line-join': 'round',
|
||||
'line-cap': 'round',
|
||||
},
|
||||
ANCHOR_LAYER_KEY.tracks
|
||||
);
|
||||
paint: {
|
||||
'line-color': ['get', 'color'],
|
||||
'line-width': ['get', 'width'],
|
||||
'line-opacity': ['get', 'opacity'],
|
||||
},
|
||||
});
|
||||
|
||||
_map.on('click', this.fileId, this.layerOnClickBinded);
|
||||
_map.on('contextmenu', this.fileId, this.layerOnContextMenuBinded);
|
||||
@@ -221,59 +189,6 @@ export class GPXLayer {
|
||||
_map.on('mousemove', this.fileId, this.layerOnMouseMoveBinded);
|
||||
}
|
||||
|
||||
let waypointSource = _map.getSource(this.fileId + '-waypoints') as
|
||||
| mapboxgl.GeoJSONSource
|
||||
| undefined;
|
||||
this.currentWaypointData = this.getWaypointsGeoJSON();
|
||||
if (waypointSource) {
|
||||
waypointSource.setData(this.currentWaypointData);
|
||||
} else {
|
||||
_map.addSource(this.fileId + '-waypoints', {
|
||||
type: 'geojson',
|
||||
data: this.currentWaypointData,
|
||||
});
|
||||
}
|
||||
|
||||
if (!_map.getLayer(this.fileId + '-waypoints')) {
|
||||
_map.addLayer(
|
||||
{
|
||||
id: this.fileId + '-waypoints',
|
||||
type: 'symbol',
|
||||
source: this.fileId + '-waypoints',
|
||||
layout: {
|
||||
'icon-image': ['get', 'icon'],
|
||||
'icon-size': 0.3,
|
||||
'icon-anchor': 'bottom',
|
||||
'icon-padding': 0,
|
||||
'icon-allow-overlap': true,
|
||||
},
|
||||
},
|
||||
ANCHOR_LAYER_KEY.waypoints
|
||||
);
|
||||
|
||||
_map.on(
|
||||
'mouseenter',
|
||||
this.fileId + '-waypoints',
|
||||
this.waypointLayerOnMouseEnterBinded
|
||||
);
|
||||
_map.on(
|
||||
'mouseleave',
|
||||
this.fileId + '-waypoints',
|
||||
this.waypointLayerOnMouseLeaveBinded
|
||||
);
|
||||
_map.on('click', this.fileId + '-waypoints', this.waypointLayerOnClickBinded);
|
||||
_map.on(
|
||||
'mousedown',
|
||||
this.fileId + '-waypoints',
|
||||
this.waypointLayerOnMouseDownBinded
|
||||
);
|
||||
_map.on(
|
||||
'touchstart',
|
||||
this.fileId + '-waypoints',
|
||||
this.waypointLayerOnTouchStartBinded
|
||||
);
|
||||
}
|
||||
|
||||
if (get(directionMarkers)) {
|
||||
if (!_map.getLayer(this.fileId + '-direction')) {
|
||||
_map.addLayer(
|
||||
@@ -298,7 +213,7 @@ export class GPXLayer {
|
||||
'text-halo-color': 'white',
|
||||
},
|
||||
},
|
||||
ANCHOR_LAYER_KEY.directionMarkers
|
||||
_map.getLayer('distance-markers-100') ? 'distance-markers-100' : undefined
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -307,40 +222,151 @@ export class GPXLayer {
|
||||
}
|
||||
}
|
||||
|
||||
let visibleTrackSegmentIds: string[] = [];
|
||||
let visibleItems: [number, number][] = [];
|
||||
file.forEachSegment((segment, trackIndex, segmentIndex) => {
|
||||
if (!segment._data.hidden) {
|
||||
visibleTrackSegmentIds.push(`${trackIndex}-${segmentIndex}`);
|
||||
}
|
||||
});
|
||||
const segmentFilter: FilterSpecification = [
|
||||
'in',
|
||||
['get', 'trackSegmentId'],
|
||||
['literal', visibleTrackSegmentIds],
|
||||
];
|
||||
|
||||
_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) => {
|
||||
if (!waypoint._data.hidden) {
|
||||
visibleWaypoints.push(waypointIndex);
|
||||
visibleItems.push([trackIndex, segmentIndex]);
|
||||
}
|
||||
});
|
||||
|
||||
_map.setFilter(
|
||||
this.fileId + '-waypoints',
|
||||
['in', ['get', 'waypointIndex'], ['literal', visibleWaypoints]],
|
||||
this.fileId,
|
||||
[
|
||||
'any',
|
||||
...visibleItems.map(([trackIndex, segmentIndex]) => [
|
||||
'all',
|
||||
['==', 'trackIndex', trackIndex],
|
||||
['==', 'segmentIndex', segmentIndex],
|
||||
]),
|
||||
],
|
||||
{ validate: false }
|
||||
);
|
||||
if (_map.getLayer(this.fileId + '-direction')) {
|
||||
_map.setFilter(
|
||||
this.fileId + '-direction',
|
||||
[
|
||||
'any',
|
||||
...visibleItems.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;
|
||||
}
|
||||
|
||||
let markerIndex = 0;
|
||||
|
||||
if (get(selection).hasAnyChildren(new ListFileItem(this.fileId))) {
|
||||
file.wpt.forEach((waypoint) => {
|
||||
// Update markers
|
||||
let symbolKey = getSymbolKey(waypoint.sym);
|
||||
if (markerIndex < this.markers.length) {
|
||||
this.markers[markerIndex].getElement().innerHTML = getMarkerForSymbol(
|
||||
symbolKey,
|
||||
this.layerColor
|
||||
);
|
||||
this.markers[markerIndex].setLngLat(waypoint.getCoordinates());
|
||||
Object.defineProperty(this.markers[markerIndex], '_waypoint', {
|
||||
value: waypoint,
|
||||
writable: true,
|
||||
});
|
||||
} else {
|
||||
let element = document.createElement('div');
|
||||
element.classList.add('w-8', 'h-8', 'drop-shadow-xl');
|
||||
element.innerHTML = getMarkerForSymbol(symbolKey, this.layerColor);
|
||||
let marker = new mapboxgl.Marker({
|
||||
draggable: this.draggable,
|
||||
element,
|
||||
anchor: 'bottom',
|
||||
}).setLngLat(waypoint.getCoordinates());
|
||||
Object.defineProperty(marker, '_waypoint', { value: waypoint, writable: true });
|
||||
let dragEndTimestamp = 0;
|
||||
marker.getElement().addEventListener('mousemove', (e) => {
|
||||
if (marker._isDragging) {
|
||||
return;
|
||||
}
|
||||
waypointPopup?.setItem({ item: marker._waypoint, fileId: this.fileId });
|
||||
e.stopPropagation();
|
||||
});
|
||||
marker.getElement().addEventListener('click', (e) => {
|
||||
if (dragEndTimestamp && Date.now() - dragEndTimestamp < 1000) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (get(currentTool) === Tool.WAYPOINT && e.shiftKey) {
|
||||
fileActions.deleteWaypoint(this.fileId, marker._waypoint._data.index);
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (get(treeFileView)) {
|
||||
if (
|
||||
(e.ctrlKey || e.metaKey) &&
|
||||
get(selection).hasAnyChildren(
|
||||
new ListWaypointsItem(this.fileId),
|
||||
false
|
||||
)
|
||||
) {
|
||||
selection.addSelectItem(
|
||||
new ListWaypointItem(this.fileId, marker._waypoint._data.index)
|
||||
);
|
||||
} else {
|
||||
selection.selectItem(
|
||||
new ListWaypointItem(this.fileId, marker._waypoint._data.index)
|
||||
);
|
||||
}
|
||||
} else if (get(currentTool) === Tool.WAYPOINT) {
|
||||
selectedWaypoint.set([marker._waypoint, this.fileId]);
|
||||
} else {
|
||||
waypointPopup?.setItem({ item: marker._waypoint, fileId: this.fileId });
|
||||
}
|
||||
e.stopPropagation();
|
||||
});
|
||||
marker.on('dragstart', () => {
|
||||
mapCursor.notify(MapCursorState.WAYPOINT_DRAGGING, true);
|
||||
marker.getElement().style.cursor = 'grabbing';
|
||||
waypointPopup?.hide();
|
||||
});
|
||||
marker.on('dragend', (e) => {
|
||||
mapCursor.notify(MapCursorState.WAYPOINT_DRAGGING, false);
|
||||
marker.getElement().style.cursor = '';
|
||||
getElevation([marker._waypoint]).then((ele) => {
|
||||
fileActionManager.applyToFile(this.fileId, (file) => {
|
||||
let latLng = marker.getLngLat();
|
||||
let wpt = file.wpt[marker._waypoint._data.index];
|
||||
wpt.setCoordinates({
|
||||
lat: latLng.lat,
|
||||
lon: latLng.lng,
|
||||
});
|
||||
wpt.ele = ele[0];
|
||||
});
|
||||
});
|
||||
dragEndTimestamp = Date.now();
|
||||
});
|
||||
this.markers.push(marker);
|
||||
}
|
||||
markerIndex++;
|
||||
});
|
||||
}
|
||||
|
||||
while (markerIndex < this.markers.length) {
|
||||
// Remove extra markers
|
||||
this.markers.pop()?.remove();
|
||||
}
|
||||
|
||||
this.markers.forEach((marker) => {
|
||||
if (!marker._waypoint._data.hidden) {
|
||||
marker.addTo(_map);
|
||||
} else {
|
||||
marker.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
remove() {
|
||||
@@ -353,24 +379,6 @@ export class GPXLayer {
|
||||
_map.off('mousemove', this.fileId, this.layerOnMouseMoveBinded);
|
||||
_map.off('style.import.load', this.updateBinded);
|
||||
|
||||
_map.off(
|
||||
'mouseenter',
|
||||
this.fileId + '-waypoints',
|
||||
this.waypointLayerOnMouseEnterBinded
|
||||
);
|
||||
_map.off(
|
||||
'mouseleave',
|
||||
this.fileId + '-waypoints',
|
||||
this.waypointLayerOnMouseLeaveBinded
|
||||
);
|
||||
_map.off('click', this.fileId + '-waypoints', this.waypointLayerOnClickBinded);
|
||||
_map.off('mousedown', this.fileId + '-waypoints', this.waypointLayerOnMouseDownBinded);
|
||||
_map.off(
|
||||
'touchstart',
|
||||
this.fileId + '-waypoints',
|
||||
this.waypointLayerOnTouchStartBinded
|
||||
);
|
||||
|
||||
if (_map.getLayer(this.fileId + '-direction')) {
|
||||
_map.removeLayer(this.fileId + '-direction');
|
||||
}
|
||||
@@ -380,17 +388,15 @@ export class GPXLayer {
|
||||
if (_map.getSource(this.fileId)) {
|
||||
_map.removeSource(this.fileId);
|
||||
}
|
||||
if (_map.getLayer(this.fileId + '-waypoints')) {
|
||||
_map.removeLayer(this.fileId + '-waypoints');
|
||||
}
|
||||
if (_map.getSource(this.fileId + '-waypoints')) {
|
||||
_map.removeSource(this.fileId + '-waypoints');
|
||||
}
|
||||
}
|
||||
|
||||
this.markers.forEach((marker) => {
|
||||
marker.remove();
|
||||
});
|
||||
|
||||
this.unsubscribe.forEach((unsubscribe) => unsubscribe());
|
||||
|
||||
removeColor(this.fileId, this.layerColor);
|
||||
decrementColor(this.layerColor);
|
||||
}
|
||||
|
||||
moveToFront() {
|
||||
@@ -399,13 +405,10 @@ export class GPXLayer {
|
||||
return;
|
||||
}
|
||||
if (_map.getLayer(this.fileId)) {
|
||||
_map.moveLayer(this.fileId, ANCHOR_LAYER_KEY.tracks);
|
||||
}
|
||||
if (_map.getLayer(this.fileId + '-waypoints')) {
|
||||
_map.moveLayer(this.fileId + '-waypoints', ANCHOR_LAYER_KEY.waypoints);
|
||||
_map.moveLayer(this.fileId);
|
||||
}
|
||||
if (_map.getLayer(this.fileId + '-direction')) {
|
||||
_map.moveLayer(this.fileId + '-direction', ANCHOR_LAYER_KEY.directionMarkers);
|
||||
_map.moveLayer(this.fileId + '-direction');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,7 +449,7 @@ export class GPXLayer {
|
||||
}
|
||||
}
|
||||
|
||||
layerOnClick(e: mapboxgl.MapMouseEvent) {
|
||||
layerOnClick(e: any) {
|
||||
if (
|
||||
get(currentTool) === Tool.ROUTING &&
|
||||
get(selection).hasAnyChildren(new ListRootItem(), true, ['waypoints'])
|
||||
@@ -454,8 +457,8 @@ export class GPXLayer {
|
||||
return;
|
||||
}
|
||||
|
||||
let trackIndex = e.features![0].properties!.trackIndex;
|
||||
let segmentIndex = e.features![0].properties!.segmentIndex;
|
||||
let trackIndex = e.features[0].properties.trackIndex;
|
||||
let segmentIndex = e.features[0].properties.segmentIndex;
|
||||
|
||||
if (
|
||||
get(currentTool) === Tool.SCISSORS &&
|
||||
@@ -463,11 +466,6 @@ export class GPXLayer {
|
||||
new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
|
||||
)
|
||||
) {
|
||||
if (get(map)?.queryRenderedFeatures(e.point, { layers: ['split-controls'] }).length) {
|
||||
// Clicked on split control, ignoring
|
||||
return;
|
||||
}
|
||||
|
||||
fileActions.split(get(splitAs), this.fileId, trackIndex, segmentIndex, {
|
||||
lat: e.lngLat.lat,
|
||||
lon: e.lngLat.lng,
|
||||
@@ -504,160 +502,6 @@ export class GPXLayer {
|
||||
}
|
||||
}
|
||||
|
||||
waypointLayerOnMouseEnter(e: mapboxgl.MapMouseEvent) {
|
||||
if (this.draggedWaypointIndex !== null) {
|
||||
return;
|
||||
}
|
||||
let file = get(this.file)?.file;
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
let waypointIndex = e.features![0].properties!.waypointIndex;
|
||||
let waypoint = file.wpt[waypointIndex];
|
||||
waypointPopup?.setItem({ item: waypoint, fileId: this.fileId });
|
||||
|
||||
mapCursor.notify(MapCursorState.WAYPOINT_HOVER, true);
|
||||
}
|
||||
|
||||
waypointLayerOnMouseLeave() {
|
||||
mapCursor.notify(MapCursorState.WAYPOINT_HOVER, false);
|
||||
}
|
||||
|
||||
waypointLayerOnClick(e: mapboxgl.MapMouseEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
let waypointIndex = e.features![0].properties!.waypointIndex;
|
||||
let file = get(this.file)?.file;
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
let waypoint = file.wpt[waypointIndex];
|
||||
if (get(currentTool) === Tool.WAYPOINT) {
|
||||
if (this.selected) {
|
||||
if (e.originalEvent.shiftKey) {
|
||||
fileActions.deleteWaypoint(this.fileId, waypointIndex);
|
||||
} else {
|
||||
selection.selectItem(new ListWaypointItem(this.fileId, waypointIndex));
|
||||
selectedWaypoint.set([waypoint, this.fileId]);
|
||||
}
|
||||
} else {
|
||||
if (get(treeFileView)) {
|
||||
selection.selectItem(new ListWaypointItem(this.fileId, waypointIndex));
|
||||
} else {
|
||||
selection.selectItem(new ListFileItem(this.fileId));
|
||||
}
|
||||
selectedWaypoint.set([waypoint, this.fileId]);
|
||||
}
|
||||
} else {
|
||||
if (get(treeFileView)) {
|
||||
if ((e.originalEvent.ctrlKey || e.originalEvent.metaKey) && this.selected) {
|
||||
selection.addSelectItem(new ListWaypointItem(this.fileId, waypointIndex));
|
||||
} else {
|
||||
selection.selectItem(new ListWaypointItem(this.fileId, waypointIndex));
|
||||
}
|
||||
} else {
|
||||
if (!this.selected) {
|
||||
selection.selectItem(new ListFileItem(this.fileId));
|
||||
}
|
||||
waypointPopup?.setItem({ item: waypoint, fileId: this.fileId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
waypointLayerOnMouseDown(e: mapboxgl.MapMouseEvent) {
|
||||
if (get(currentTool) !== Tool.WAYPOINT || !this.selected) {
|
||||
return;
|
||||
}
|
||||
const _map = get(map);
|
||||
if (!_map) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
this.draggedWaypointIndex = e.features![0].properties!.waypointIndex;
|
||||
this.draggingStartingPosition = e.point;
|
||||
waypointPopup?.hide();
|
||||
|
||||
_map.on('mousemove', this.waypointLayerOnMouseMoveBinded);
|
||||
_map.once('mouseup', this.waypointLayerOnMouseUpBinded);
|
||||
}
|
||||
|
||||
waypointLayerOnTouchStart(e: mapboxgl.MapTouchEvent) {
|
||||
if (e.points.length !== 1 || get(currentTool) !== Tool.WAYPOINT || !this.selected) {
|
||||
return;
|
||||
}
|
||||
const _map = get(map);
|
||||
if (!_map) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.draggedWaypointIndex = e.features![0].properties!.waypointIndex;
|
||||
this.draggingStartingPosition = e.point;
|
||||
waypointPopup?.hide();
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
_map.on('touchmove', this.waypointLayerOnMouseMoveBinded);
|
||||
_map.once('touchend', this.waypointLayerOnMouseUpBinded);
|
||||
}
|
||||
|
||||
waypointLayerOnMouseMove(e: mapboxgl.MapMouseEvent | mapboxgl.MapTouchEvent) {
|
||||
if (this.draggedWaypointIndex === null || e.point.equals(this.draggingStartingPosition)) {
|
||||
return;
|
||||
}
|
||||
|
||||
mapCursor.notify(MapCursorState.WAYPOINT_DRAGGING, true);
|
||||
|
||||
(
|
||||
this.currentWaypointData!.features[this.draggedWaypointIndex].geometry as GeoJSON.Point
|
||||
).coordinates = [e.lngLat.lng, e.lngLat.lat];
|
||||
|
||||
let waypointSource = get(map)?.getSource(this.fileId + '-waypoints') as
|
||||
| mapboxgl.GeoJSONSource
|
||||
| undefined;
|
||||
if (waypointSource) {
|
||||
waypointSource.setData(this.currentWaypointData!);
|
||||
}
|
||||
}
|
||||
|
||||
waypointLayerOnMouseUp(e: mapboxgl.MapMouseEvent | mapboxgl.MapTouchEvent) {
|
||||
mapCursor.notify(MapCursorState.WAYPOINT_DRAGGING, false);
|
||||
|
||||
get(map)?.off('mousemove', this.waypointLayerOnMouseMoveBinded);
|
||||
get(map)?.off('touchmove', this.waypointLayerOnMouseMoveBinded);
|
||||
|
||||
if (this.draggedWaypointIndex === null) {
|
||||
return;
|
||||
}
|
||||
if (e.point.equals(this.draggingStartingPosition)) {
|
||||
this.draggedWaypointIndex = null;
|
||||
return;
|
||||
}
|
||||
|
||||
getElevation([
|
||||
{
|
||||
lat: e.lngLat.lat,
|
||||
lon: e.lngLat.lng,
|
||||
},
|
||||
]).then((ele) => {
|
||||
if (this.draggedWaypointIndex === null) {
|
||||
return;
|
||||
}
|
||||
fileActionManager.applyToFile(this.fileId, (file) => {
|
||||
let wpt = file.wpt[this.draggedWaypointIndex!];
|
||||
wpt.setCoordinates({
|
||||
lat: e.lngLat.lat,
|
||||
lon: e.lngLat.lng,
|
||||
});
|
||||
wpt.ele = ele[0];
|
||||
});
|
||||
this.draggedWaypointIndex = null;
|
||||
});
|
||||
}
|
||||
|
||||
getGeoJSON(): GeoJSON.FeatureCollection {
|
||||
let file = get(this.file)?.file;
|
||||
if (!file) {
|
||||
@@ -695,7 +539,6 @@ export class GPXLayer {
|
||||
}
|
||||
feature.properties.trackIndex = trackIndex;
|
||||
feature.properties.segmentIndex = segmentIndex;
|
||||
feature.properties.trackSegmentId = `${trackIndex}-${segmentIndex}`;
|
||||
|
||||
segmentIndex++;
|
||||
if (segmentIndex >= file.trk[trackIndex].trkseg.length) {
|
||||
@@ -705,65 +548,4 @@ export class GPXLayer {
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
getWaypointsGeoJSON(): GeoJSON.FeatureCollection {
|
||||
let file = get(this.file)?.file;
|
||||
|
||||
let data: GeoJSON.FeatureCollection = {
|
||||
type: 'FeatureCollection',
|
||||
features: [],
|
||||
};
|
||||
|
||||
if (!file) {
|
||||
return data;
|
||||
}
|
||||
|
||||
file.wpt.forEach((waypoint, index) => {
|
||||
data.features.push({
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [waypoint.getLongitude(), waypoint.getLatitude()],
|
||||
},
|
||||
properties: {
|
||||
fileId: this.fileId,
|
||||
waypointIndex: index,
|
||||
icon: `waypoint-${getSymbolKey(waypoint.sym) ?? 'default'}-${this.layerColor}`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
loadIcons() {
|
||||
const _map = get(map);
|
||||
let file = get(this.file)?.file;
|
||||
if (!_map || !file) {
|
||||
return;
|
||||
}
|
||||
|
||||
let symbols = new Set<string | undefined>();
|
||||
file.wpt.forEach((waypoint) => {
|
||||
symbols.add(getSymbolKey(waypoint.sym));
|
||||
});
|
||||
|
||||
symbols.forEach((symbol) => {
|
||||
const iconId = `waypoint-${symbol ?? 'default'}-${this.layerColor}`;
|
||||
if (!_map.hasImage(iconId)) {
|
||||
let icon = new Image(100, 100);
|
||||
icon.onload = () => {
|
||||
if (!_map.hasImage(iconId)) {
|
||||
_map.addImage(iconId, icon);
|
||||
}
|
||||
};
|
||||
|
||||
// Lucide icons are SVG files with a 24x24 viewBox
|
||||
// Create a new SVG with a 32x32 viewBox and center the icon in a circle
|
||||
icon.src =
|
||||
'data:image/svg+xml,' +
|
||||
encodeURIComponent(getSvgForSymbol(symbol, this.layerColor));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { GPXFileStateCollectionObserver } from '$lib/logic/file-state';
|
||||
import { writable } from 'svelte/store';
|
||||
import { GPXLayer } from './gpx-layer';
|
||||
|
||||
export class GPXLayerCollection {
|
||||
@@ -43,4 +42,3 @@ export class GPXLayerCollection {
|
||||
}
|
||||
|
||||
export const gpxLayers = new GPXLayerCollection();
|
||||
export const gpxColors = writable(new Map<string, string>());
|
||||
|
||||
@@ -34,20 +34,13 @@ export class StartEndMarkers {
|
||||
if (!map_) return;
|
||||
|
||||
const tool = get(currentTool);
|
||||
const statistics = get(gpxStatistics);
|
||||
const slicedStatistics = get(slicedGPXStatistics);
|
||||
const statistics = get(slicedGPXStatistics)?.[0] ?? get(gpxStatistics);
|
||||
const hidden = get(allHidden);
|
||||
if (statistics.global.length > 0 && tool !== Tool.ROUTING && !hidden) {
|
||||
this.start
|
||||
.setLngLat(
|
||||
statistics.getTrackPoint(slicedStatistics?.[1] ?? 0)!.trkpt.getCoordinates()
|
||||
)
|
||||
.addTo(map_);
|
||||
if (statistics.local.points.length > 0 && tool !== Tool.ROUTING && !hidden) {
|
||||
this.start.setLngLat(statistics.local.points[0].getCoordinates()).addTo(map_);
|
||||
this.end
|
||||
.setLngLat(
|
||||
statistics
|
||||
.getTrackPoint(slicedStatistics?.[2] ?? statistics.global.length - 1)!
|
||||
.trkpt.getCoordinates()
|
||||
statistics.local.points[statistics.local.points.length - 1].getCoordinates()
|
||||
)
|
||||
.addTo(map_);
|
||||
} else {
|
||||
|
||||
@@ -101,7 +101,9 @@
|
||||
acc: Record<string, ImportSpecification>,
|
||||
imprt: ImportSpecification
|
||||
) => {
|
||||
if (!['basemap', 'overlays'].includes(imprt.id)) {
|
||||
if (
|
||||
!['basemap', 'overlays', 'glyphs-and-sprite'].includes(imprt.id)
|
||||
) {
|
||||
acc[imprt.id] = imprt;
|
||||
}
|
||||
return acc;
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
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';
|
||||
@@ -32,7 +31,6 @@
|
||||
currentOverpassQueries,
|
||||
customLayers,
|
||||
opacities,
|
||||
terrainSource,
|
||||
} = settings;
|
||||
|
||||
const { isLayerFromExtension, getLayerName } = extensionAPI;
|
||||
@@ -56,7 +54,7 @@
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (open && $selectedBasemapTree && $currentBasemap) {
|
||||
if ($selectedBasemapTree && $currentBasemap) {
|
||||
if (!isSelected($selectedBasemapTree, $currentBasemap)) {
|
||||
if (!isSelected($selectedBasemapTree, defaultBasemap)) {
|
||||
$selectedBasemapTree = toggle($selectedBasemapTree, defaultBasemap);
|
||||
@@ -67,7 +65,7 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (open && $selectedOverlayTree) {
|
||||
if ($selectedOverlayTree) {
|
||||
untrack(() => {
|
||||
if ($currentOverlays) {
|
||||
let overlayLayers = getLayers($currentOverlays);
|
||||
@@ -88,7 +86,7 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (open && $selectedOverpassTree) {
|
||||
if ($selectedOverpassTree) {
|
||||
untrack(() => {
|
||||
if ($currentOverpassQueries) {
|
||||
let overlayLayers = getLayers($currentOverpassQueries);
|
||||
@@ -162,7 +160,7 @@
|
||||
type="single"
|
||||
onValueChange={setOpacityFromSelection}
|
||||
>
|
||||
<Select.Trigger class="mr-1 w-full" size="sm">
|
||||
<Select.Trigger class="h-8 mr-1 w-full">
|
||||
{#if selectedOverlay}
|
||||
{#if isSelected($selectedOverlayTree, selectedOverlay)}
|
||||
{#if $isLayerFromExtension(selectedOverlay)}
|
||||
@@ -235,23 +233,6 @@
|
||||
</ScrollArea>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value="terrain-source">
|
||||
<Accordion.Trigger>{i18n._('layers.terrain')}</Accordion.Trigger>
|
||||
<Accordion.Content class="flex flex-col gap-3 overflow-visible">
|
||||
<Select.Root bind:value={$terrainSource} type="single">
|
||||
<Select.Trigger class="mr-1 w-full" size="sm">
|
||||
{i18n._(`layers.label.${$terrainSource}`)}
|
||||
</Select.Trigger>
|
||||
<Select.Content class="h-fit max-h-[40dvh] overflow-y-auto">
|
||||
{#each Object.keys(terrainSources) as id}
|
||||
<Select.Item value={id}>
|
||||
{i18n._(`layers.label.${id}`)}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
</Accordion.Root>
|
||||
</ScrollArea>
|
||||
</Sheet.Header>
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
{:else if anySelectedLayer(node[id])}
|
||||
<CollapsibleTreeNode {id}>
|
||||
{#snippet trigger()}
|
||||
<span>{i18n._(`layers.label.${id}`, id)}</span>
|
||||
<span>{i18n._(`layers.label.${id}`)}</span>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<div class="ml-2">
|
||||
|
||||
@@ -54,27 +54,28 @@
|
||||
|
||||
<Card.Root class="border-none shadow-md text-base p-2 max-w-[50dvw] gap-0">
|
||||
<Card.Header class="p-0 gap-0">
|
||||
<Card.Title class="text-md flex flex-row">
|
||||
<div class="flex flex-col">
|
||||
<p>{name}</p>
|
||||
<div class="text-muted-foreground text-xs font-normal">
|
||||
{poi.item.lat.toFixed(6)}° {poi.item.lon.toFixed(6)}°
|
||||
<Card.Title class="text-md">
|
||||
<div class="flex flex-row gap-3">
|
||||
<div class="flex flex-col">
|
||||
{name}
|
||||
<div class="text-muted-foreground text-xs font-normal">
|
||||
{poi.item.lat.toFixed(6)}° {poi.item.lon.toFixed(6)}°
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
class="ml-auto"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
href="https://www.openstreetmap.org/edit?editor=id&{poi.item.type ??
|
||||
'node'}={poi.item.id}"
|
||||
target="_blank"
|
||||
>
|
||||
<PencilLine size="16" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
class="ml-auto"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
href="https://www.openstreetmap.org/edit?editor=id&{poi.item.type ?? 'node'}={poi
|
||||
.item.id}"
|
||||
target="_blank"
|
||||
>
|
||||
<PencilLine size="16" />
|
||||
</Button>
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-col gap-1 p-0 text-sm whitespace-normal break-all">
|
||||
<Card.Content class="flex flex-col p-0 text-sm mt-1 whitespace-normal break-all">
|
||||
<ScrollArea class="flex flex-col max-h-[30dvh]">
|
||||
{#if tags.image || tags['image:0']}
|
||||
<div class="w-full rounded-md overflow-clip my-2 max-w-96 mx-auto">
|
||||
@@ -99,14 +100,8 @@
|
||||
{/each}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<Button
|
||||
size="sm"
|
||||
class="mt-1 justify-start"
|
||||
variant="outline"
|
||||
disabled={$selection.size === 0}
|
||||
onclick={addToFile}
|
||||
>
|
||||
<MapPin size="14" />
|
||||
<Button class="mt-2" variant="outline" disabled={$selection.size === 0} onclick={addToFile}>
|
||||
<MapPin size="16" />
|
||||
{i18n._('toolbar.waypoint.add')}
|
||||
</Button>
|
||||
</Card.Content>
|
||||
|
||||
@@ -8,7 +8,6 @@ import { map } from '$lib/components/map/map';
|
||||
const { currentOverlays, previousOverlays, selectedOverlayTree } = settings;
|
||||
|
||||
export type CustomOverlay = {
|
||||
extensionName: string;
|
||||
id: string;
|
||||
name: string;
|
||||
tileUrls: string[];
|
||||
@@ -47,16 +46,8 @@ export class ExtensionAPI {
|
||||
}
|
||||
|
||||
addOrUpdateOverlay(overlay: CustomOverlay) {
|
||||
if (
|
||||
!overlay.extensionName ||
|
||||
!overlay.id ||
|
||||
!overlay.name ||
|
||||
!overlay.tileUrls ||
|
||||
overlay.tileUrls.length === 0
|
||||
) {
|
||||
throw new Error(
|
||||
'Overlay must have an extensionName, id, name, and at least one tile URL.'
|
||||
);
|
||||
if (!overlay.id || !overlay.name || !overlay.tileUrls || overlay.tileUrls.length === 0) {
|
||||
throw new Error('Overlay must have an id, name, and at least one tile URL.');
|
||||
}
|
||||
overlay.id = this.getOverlayId(overlay.id);
|
||||
|
||||
@@ -84,17 +75,10 @@ export class ExtensionAPI {
|
||||
],
|
||||
};
|
||||
|
||||
if (!overlayTree.overlays.hasOwnProperty(overlay.extensionName)) {
|
||||
overlayTree.overlays[overlay.extensionName] = {};
|
||||
}
|
||||
|
||||
overlayTree.overlays[overlay.extensionName][overlay.id] = true;
|
||||
overlayTree.overlays.world[overlay.id] = true;
|
||||
|
||||
selectedOverlayTree.update((selected) => {
|
||||
if (!selected.overlays.hasOwnProperty(overlay.extensionName)) {
|
||||
selected.overlays[overlay.extensionName] = {};
|
||||
}
|
||||
selected.overlays[overlay.extensionName][overlay.id] = true;
|
||||
selected.overlays.world[overlay.id] = true;
|
||||
return selected;
|
||||
});
|
||||
|
||||
@@ -110,10 +94,7 @@ export class ExtensionAPI {
|
||||
}
|
||||
|
||||
currentOverlays.update((current) => {
|
||||
if (!current.overlays.hasOwnProperty(overlay.extensionName)) {
|
||||
current.overlays[overlay.extensionName] = {};
|
||||
}
|
||||
current.overlays[overlay.extensionName][overlay.id] = show;
|
||||
current.overlays.world[overlay.id] = show;
|
||||
return current;
|
||||
});
|
||||
}
|
||||
@@ -152,29 +133,6 @@ export class ExtensionAPI {
|
||||
});
|
||||
}
|
||||
|
||||
updateOverlaysOrder(ids: string[]) {
|
||||
ids = ids.map((id) => this.getOverlayId(id));
|
||||
selectedOverlayTree.update((selected) => {
|
||||
let isSelected: Record<string, boolean> = {};
|
||||
ids.forEach((id) => {
|
||||
const overlay = get(this._overlays).get(id);
|
||||
if (
|
||||
overlay &&
|
||||
selected.overlays.hasOwnProperty(overlay.extensionName) &&
|
||||
selected.overlays[overlay.extensionName].hasOwnProperty(id)
|
||||
) {
|
||||
isSelected[id] = selected.overlays[overlay.extensionName][id];
|
||||
delete selected.overlays[overlay.extensionName][id];
|
||||
}
|
||||
});
|
||||
Object.entries(isSelected).forEach(([id, value]) => {
|
||||
const overlay = get(this._overlays).get(id)!;
|
||||
selected.overlays[overlay.extensionName][id] = value;
|
||||
});
|
||||
return selected;
|
||||
});
|
||||
}
|
||||
|
||||
isLayerFromExtension = derived(this._overlays, ($overlays) => {
|
||||
return (id: string) => $overlays.has(id);
|
||||
});
|
||||
|
||||
@@ -6,7 +6,6 @@ import { overpassQueryData } from '$lib/assets/layers';
|
||||
import { MapPopup } from '$lib/components/map/map-popup';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { db } from '$lib/db';
|
||||
import { ANCHOR_LAYER_KEY } from '$lib/components/map/map';
|
||||
|
||||
const { currentOverpassQueries } = settings;
|
||||
|
||||
@@ -86,28 +85,23 @@ export class OverpassLayer {
|
||||
}
|
||||
|
||||
if (!this.map.getLayer('overpass')) {
|
||||
this.map.addLayer(
|
||||
{
|
||||
id: 'overpass',
|
||||
type: 'symbol',
|
||||
source: 'overpass',
|
||||
layout: {
|
||||
'icon-image': ['get', 'icon'],
|
||||
'icon-size': 0.25,
|
||||
'icon-padding': 0,
|
||||
'icon-allow-overlap': ['step', ['zoom'], false, 14, true],
|
||||
},
|
||||
this.map.addLayer({
|
||||
id: 'overpass',
|
||||
type: 'symbol',
|
||||
source: 'overpass',
|
||||
layout: {
|
||||
'icon-image': ['get', 'icon'],
|
||||
'icon-size': 0.25,
|
||||
'icon-padding': 0,
|
||||
'icon-allow-overlap': ['step', ['zoom'], false, 14, true],
|
||||
},
|
||||
ANCHOR_LAYER_KEY.overpass
|
||||
);
|
||||
});
|
||||
|
||||
this.map.on('mouseenter', 'overpass', this.onHoverBinded);
|
||||
this.map.on('click', 'overpass', this.onHoverBinded);
|
||||
}
|
||||
|
||||
this.map.setFilter('overpass', ['in', 'query', ...getCurrentQueries()], {
|
||||
validate: false,
|
||||
});
|
||||
this.map.setFilter('overpass', ['in', 'query', ...getCurrentQueries()]);
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to add sources and layers
|
||||
}
|
||||
@@ -289,12 +283,10 @@ function getQuery(query: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function getQueryItem(tags: Record<string, string | string[]>) {
|
||||
let arrayEntry = Object.entries(tags).find((entry): entry is [string, string[]] =>
|
||||
Array.isArray(entry[1])
|
||||
);
|
||||
function getQueryItem(tags: Record<string, string | boolean | string[]>) {
|
||||
let arrayEntry = Object.values(tags).find((value) => Array.isArray(value));
|
||||
if (arrayEntry !== undefined) {
|
||||
return arrayEntry[1]
|
||||
return arrayEntry
|
||||
.map(
|
||||
(val) =>
|
||||
`nwr${Object.entries(tags)
|
||||
@@ -317,7 +309,7 @@ function belongsToQuery(element: any, query: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function belongsToQueryItem(element: any, tags: Record<string, string | string[]>) {
|
||||
function belongsToQueryItem(element: any, tags: Record<string, string | boolean | string[]>) {
|
||||
return Object.entries(tags).every(([tag, value]) =>
|
||||
Array.isArray(value) ? value.includes(element.tags[tag]) : element.tags[tag] === value
|
||||
);
|
||||
|
||||
@@ -3,16 +3,8 @@ 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,
|
||||
terrainSource,
|
||||
} = settings;
|
||||
const { treeFileView, elevationProfile, bottomPanelSize, rightPanelSize, distanceUnits } = settings;
|
||||
|
||||
let fitBoundsOptions: mapboxgl.MapOptions['fitBoundsOptions'] = {
|
||||
maxZoom: 15,
|
||||
@@ -20,28 +12,6 @@ let fitBoundsOptions: mapboxgl.MapOptions['fitBoundsOptions'] = {
|
||||
easing: () => 1,
|
||||
};
|
||||
|
||||
const emptySource: mapboxgl.GeoJSONSourceSpecification = {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'FeatureCollection',
|
||||
features: [],
|
||||
},
|
||||
};
|
||||
export const ANCHOR_LAYER_KEY = {
|
||||
mapillary: 'mapillary-end',
|
||||
tracks: 'tracks-end',
|
||||
directionMarkers: 'direction-markers-end',
|
||||
distanceMarkers: 'distance-markers-end',
|
||||
interactions: 'interactions-end',
|
||||
overpass: 'overpass-end',
|
||||
waypoints: 'waypoints-end',
|
||||
};
|
||||
const anchorLayers: mapboxgl.LayerSpecification[] = Object.values(ANCHOR_LAYER_KEY).map((id) => ({
|
||||
id: id,
|
||||
type: 'symbol',
|
||||
source: 'empty-source',
|
||||
}));
|
||||
|
||||
export class MapboxGLMap {
|
||||
private _map: Writable<mapboxgl.Map | null> = writable(null);
|
||||
private _onLoadCallbacks: ((map: mapboxgl.Map) => void)[] = [];
|
||||
@@ -51,16 +21,31 @@ export class MapboxGLMap {
|
||||
return this._map.subscribe(run, invalidate);
|
||||
}
|
||||
|
||||
init(language: string, hash: boolean, geocoder: boolean, geolocate: boolean) {
|
||||
init(
|
||||
accessToken: string,
|
||||
language: string,
|
||||
hash: boolean,
|
||||
geocoder: boolean,
|
||||
geolocate: boolean
|
||||
) {
|
||||
const map = new mapboxgl.Map({
|
||||
container: 'map',
|
||||
style: {
|
||||
version: 8,
|
||||
sources: {
|
||||
'empty-source': emptySource,
|
||||
},
|
||||
layers: anchorLayers,
|
||||
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: `https://api.mapbox.com/styles/v1/mapbox/outdoors-v12/sprite?access_token=${accessToken}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'basemap',
|
||||
url: '',
|
||||
@@ -68,6 +53,11 @@ export class MapboxGLMap {
|
||||
{
|
||||
id: 'overlays',
|
||||
url: '',
|
||||
data: {
|
||||
version: 8,
|
||||
sources: {},
|
||||
layers: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -144,26 +134,39 @@ 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', 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('pitch', () => {
|
||||
if (map.getPitch() > 0) {
|
||||
map.setTerrain({
|
||||
source: 'mapbox-dem',
|
||||
exaggeration: 1,
|
||||
});
|
||||
} else {
|
||||
map.setTerrain(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
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));
|
||||
@@ -179,7 +182,6 @@ export class MapboxGLMap {
|
||||
scaleControl.setUnit(units);
|
||||
})
|
||||
);
|
||||
this._unsubscribes.push(terrainSource.subscribe(() => this.setTerrain()));
|
||||
}
|
||||
|
||||
onLoad(callback: (map: mapboxgl.Map) => void) {
|
||||
@@ -220,29 +222,6 @@ export class MapboxGLMap {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setTerrain() {
|
||||
const map = get(this._map);
|
||||
if (map) {
|
||||
const source = get(terrainSource);
|
||||
try {
|
||||
if (!map.getSource(source)) {
|
||||
map.addSource(source, terrainSources[source]);
|
||||
}
|
||||
if (map.getPitch() > 0) {
|
||||
map.setTerrain({
|
||||
source: source,
|
||||
exaggeration: 1,
|
||||
});
|
||||
} else {
|
||||
map.setTerrain(null);
|
||||
}
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to add sources and layers
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const map = new MapboxGLMap();
|
||||
|
||||
@@ -2,7 +2,6 @@ import mapboxgl, { type LayerSpecification, type VectorSourceSpecification } fro
|
||||
import { Viewer, type ViewerBearingEvent } from 'mapillary-js/dist/mapillary.module';
|
||||
import 'mapillary-js/dist/mapillary.css';
|
||||
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
||||
import { ANCHOR_LAYER_KEY } from '$lib/components/map/map';
|
||||
|
||||
const mapillarySource: VectorSourceSpecification = {
|
||||
type: 'vector',
|
||||
@@ -100,10 +99,10 @@ export class MapillaryLayer {
|
||||
this.map.addSource('mapillary', mapillarySource);
|
||||
}
|
||||
if (!this.map.getLayer('mapillary-sequence')) {
|
||||
this.map.addLayer(mapillarySequenceLayer, ANCHOR_LAYER_KEY.mapillary);
|
||||
this.map.addLayer(mapillarySequenceLayer);
|
||||
}
|
||||
if (!this.map.getLayer('mapillary-image')) {
|
||||
this.map.addLayer(mapillaryImageLayer, ANCHOR_LAYER_KEY.mapillary);
|
||||
this.map.addLayer(mapillaryImageLayer);
|
||||
}
|
||||
this.map.on('style.load', this.addBinded);
|
||||
this.map.on('mouseenter', 'mapillary-image', this.onMouseEnterBinded);
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import { Trash2 } from '@lucide/svelte';
|
||||
import { ANCHOR_LAYER_KEY, map } from '$lib/components/map/map';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import type { GeoJSONSource } from 'mapbox-gl';
|
||||
import { selection } from '$lib/logic/selection';
|
||||
import { fileActions } from '$lib/logic/file-actions';
|
||||
@@ -63,18 +63,15 @@
|
||||
});
|
||||
}
|
||||
if (!$map.getLayer('rectangle')) {
|
||||
$map.addLayer(
|
||||
{
|
||||
id: 'rectangle',
|
||||
type: 'fill',
|
||||
source: 'rectangle',
|
||||
paint: {
|
||||
'fill-color': 'SteelBlue',
|
||||
'fill-opacity': 0.5,
|
||||
},
|
||||
$map.addLayer({
|
||||
id: 'rectangle',
|
||||
type: 'fill',
|
||||
source: 'rectangle',
|
||||
paint: {
|
||||
'fill-color': 'SteelBlue',
|
||||
'fill-opacity': 0.5,
|
||||
},
|
||||
ANCHOR_LAYER_KEY.interactions
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { MountainSnow } from '@lucide/svelte';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import { selection } from '$lib/logic/selection';
|
||||
@@ -19,7 +20,11 @@
|
||||
variant="outline"
|
||||
class="whitespace-normal h-fit"
|
||||
disabled={!validSelection}
|
||||
onclick={() => fileActions.addElevationToSelection()}
|
||||
onclick={() => {
|
||||
if ($map) {
|
||||
fileActions.addElevationToSelection($map);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MountainSnow size="16" class="shrink-0" />
|
||||
{i18n._('toolbar.elevation.button')}
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
let endTime: string | undefined = $state(undefined);
|
||||
let movingTime: number | undefined = $state(undefined);
|
||||
let speed: number | undefined = $state(undefined);
|
||||
let artificial = $state(true);
|
||||
let artificial = $state(false);
|
||||
|
||||
function toCalendarDate(date: Date): CalendarDate {
|
||||
return new CalendarDate(date.getFullYear(), date.getMonth() + 1, date.getDate());
|
||||
@@ -346,7 +346,7 @@
|
||||
let fileId = item.getFileId();
|
||||
fileActionManager.applyToFile(fileId, (file) => {
|
||||
if (item instanceof ListFileItem) {
|
||||
if (artificial && !$gpxStatistics.global.time.moving) {
|
||||
if (artificial || !$gpxStatistics.global.time.moving) {
|
||||
file.createArtificialTimestamps(
|
||||
getDate(startDate!, startTime!),
|
||||
movingTime!
|
||||
@@ -359,7 +359,7 @@
|
||||
);
|
||||
}
|
||||
} else if (item instanceof ListTrackItem) {
|
||||
if (artificial && !$gpxStatistics.global.time.moving) {
|
||||
if (artificial || !$gpxStatistics.global.time.moving) {
|
||||
file.createArtificialTimestamps(
|
||||
getDate(startDate!, startTime!),
|
||||
movingTime!,
|
||||
@@ -374,7 +374,7 @@
|
||||
);
|
||||
}
|
||||
} else if (item instanceof ListTrackSegmentItem) {
|
||||
if (artificial && !$gpxStatistics.global.time.moving) {
|
||||
if (artificial || !$gpxStatistics.global.time.moving) {
|
||||
file.createArtificialTimestamps(
|
||||
getDate(startDate!, startTime!),
|
||||
movingTime!,
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import { onDestroy } from 'svelte';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import { selection } from '$lib/logic/selection';
|
||||
import { minTolerance, ReducedGPXLayerCollection, tolerance } from './utils.svelte';
|
||||
import { minTolerance, ReducedGPXLayerCollection, tolerance } from './reduce.svelte';
|
||||
|
||||
let props: { class?: string } = $props();
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { ListItem, ListTrackSegmentItem } from '$lib/components/file-list/file-list';
|
||||
import { ANCHOR_LAYER_KEY, map } from '$lib/components/map/map';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import { fileActions } from '$lib/logic/file-actions';
|
||||
import { GPXFileStateCollectionObserver, type GPXFileState } from '$lib/logic/file-state';
|
||||
import { selection } from '$lib/logic/selection';
|
||||
import { ramerDouglasPeucker, TrackPoint, type SimplifiedTrackPoint } from 'gpx';
|
||||
import type { GeoJSONSource } from 'mapbox-gl';
|
||||
import { get, writable } from 'svelte/store';
|
||||
import { get, writable, type Writable } from 'svelte/store';
|
||||
|
||||
export const minTolerance = 0.1;
|
||||
|
||||
@@ -28,15 +28,17 @@ export class ReducedGPXLayer {
|
||||
|
||||
update() {
|
||||
const file = this._fileState.file;
|
||||
if (!file) {
|
||||
const stats = this._fileState.statistics;
|
||||
if (!file || !stats) {
|
||||
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,
|
||||
segment.trkpt.length,
|
||||
ramerDouglasPeucker(segment.trkpt, minTolerance),
|
||||
statistics.local.points.length,
|
||||
ramerDouglasPeucker(statistics.local.points, minTolerance),
|
||||
]);
|
||||
});
|
||||
}
|
||||
@@ -144,18 +146,17 @@ export class ReducedGPXLayerCollection {
|
||||
});
|
||||
}
|
||||
if (!map_.getLayer('simplified')) {
|
||||
map_.addLayer(
|
||||
{
|
||||
id: 'simplified',
|
||||
type: 'line',
|
||||
source: 'simplified',
|
||||
paint: {
|
||||
'line-color': 'white',
|
||||
'line-width': 3,
|
||||
},
|
||||
map_.addLayer({
|
||||
id: 'simplified',
|
||||
type: 'line',
|
||||
source: 'simplified',
|
||||
paint: {
|
||||
'line-color': 'white',
|
||||
'line-width': 3,
|
||||
},
|
||||
ANCHOR_LAYER_KEY.interactions
|
||||
);
|
||||
});
|
||||
} else {
|
||||
map_.moveLayer('simplified');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@
|
||||
{i18n._('toolbar.routing.activity')}
|
||||
</span>
|
||||
<Select.Root type="single" bind:value={$routingProfile}>
|
||||
<Select.Trigger class="grow" size="sm">
|
||||
<Select.Trigger class="h-8 grow">
|
||||
{i18n._(`toolbar.routing.activities.${$routingProfile}`)}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
@@ -195,7 +195,7 @@
|
||||
disabled={!validSelection}
|
||||
onclick={fileActions.reverseSelection}
|
||||
>
|
||||
<ArrowRightLeft class="size-3" />{i18n._('toolbar.routing.reverse.button')}
|
||||
<ArrowRightLeft size="12" />{i18n._('toolbar.routing.reverse.button')}
|
||||
</ButtonWithTooltip>
|
||||
<ButtonWithTooltip
|
||||
label={i18n._('toolbar.routing.route_back_to_start.tooltip')}
|
||||
@@ -231,7 +231,7 @@
|
||||
}
|
||||
}}
|
||||
>
|
||||
<House class="size-3" />{i18n._('toolbar.routing.route_back_to_start.button')}
|
||||
<House size="12" />{i18n._('toolbar.routing.route_back_to_start.button')}
|
||||
</ButtonWithTooltip>
|
||||
<ButtonWithTooltip
|
||||
label={i18n._('toolbar.routing.round_trip.tooltip')}
|
||||
@@ -240,7 +240,7 @@
|
||||
disabled={!validSelection}
|
||||
onclick={fileActions.createRoundTripForSelection}
|
||||
>
|
||||
<Repeat class="size-3" />{i18n._('toolbar.routing.round_trip.button')}
|
||||
<Repeat size="12" />{i18n._('toolbar.routing.round_trip.button')}
|
||||
</ButtonWithTooltip>
|
||||
</div>
|
||||
<div class="w-full flex flex-row gap-2 items-end justify-between">
|
||||
|
||||
@@ -793,25 +793,24 @@ 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 =
|
||||
endAnchorStats.distance.moving - startAnchorStats.distance.moving;
|
||||
stats.local.distance.moving[anchors[anchors.length - 1].point._data.index] -
|
||||
stats.local.distance.moving[anchors[0].point._data.index];
|
||||
|
||||
let newDistance = stats.global.distance.moving + replacingDistance - replacedDistance;
|
||||
let newTime = (newDistance / stats.global.speed.moving) * 3600;
|
||||
|
||||
let remainingTime =
|
||||
stats.global.time.moving -
|
||||
(endAnchorStats.time.moving - startAnchorStats.time.moving);
|
||||
(stats.local.time.moving[anchors[anchors.length - 1].point._data.index] -
|
||||
stats.local.time.moving[anchors[0].point._data.index]);
|
||||
let replacingTime = newTime - remainingTime;
|
||||
|
||||
if (replacingTime <= 0) {
|
||||
// Fallback to simple time difference
|
||||
replacingTime = endAnchorStats.time.total - startAnchorStats.time.total;
|
||||
replacingTime =
|
||||
stats.local.time.total[anchors[anchors.length - 1].point._data.index] -
|
||||
stats.local.time.total[anchors[0].point._data.index];
|
||||
}
|
||||
|
||||
speed = (replacingDistance / replacingTime) * 3600;
|
||||
@@ -821,7 +820,9 @@ export class RoutingControls {
|
||||
let endIndex = anchors[anchors.length - 1].point._data.index;
|
||||
startTime = new Date(
|
||||
(segment.trkpt[endIndex].time?.getTime() ?? 0) -
|
||||
(replacingTime + endAnchorStats.time.total - endAnchorStats.time.moving) *
|
||||
(replacingTime +
|
||||
stats.local.time.total[endIndex] -
|
||||
stats.local.time.moving[endIndex]) *
|
||||
1000
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,10 +26,12 @@
|
||||
|
||||
let validSelection = $derived(
|
||||
$selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']) &&
|
||||
$gpxStatistics.global.length > 0
|
||||
$gpxStatistics.local.points.length > 0
|
||||
);
|
||||
let maxSliderValue = $derived(
|
||||
validSelection && $gpxStatistics.global.length > 0 ? $gpxStatistics.global.length - 1 : 1
|
||||
validSelection && $gpxStatistics.local.points.length > 0
|
||||
? $gpxStatistics.local.points.length - 1
|
||||
: 1
|
||||
);
|
||||
let sliderValues = $derived([0, maxSliderValue]);
|
||||
let canCrop = $derived(sliderValues[0] != 0 || sliderValues[1] != maxSliderValue);
|
||||
@@ -43,7 +45,7 @@
|
||||
function updateSlicedGPXStatistics() {
|
||||
if (validSelection && canCrop) {
|
||||
$slicedGPXStatistics = [
|
||||
get(gpxStatistics).sliced(sliderValues[0], sliderValues[1]),
|
||||
get(gpxStatistics).slice(sliderValues[0], sliderValues[1]),
|
||||
sliderValues[0],
|
||||
sliderValues[1],
|
||||
];
|
||||
@@ -105,7 +107,7 @@
|
||||
{i18n._('toolbar.scissors.split_as')}
|
||||
</span>
|
||||
<Select.Root bind:value={$splitAs} type="single">
|
||||
<Select.Trigger class="w-fit grow" size="sm">
|
||||
<Select.Trigger class="h-8 w-fit grow">
|
||||
{i18n._('gpx.' + $splitAs)}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { TrackPoint, TrackSegment } from 'gpx';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import { ListTrackSegmentItem } from '$lib/components/file-list/file-list';
|
||||
import { currentTool, Tool } from '$lib/components/toolbar/tools';
|
||||
import { splitAs } from '$lib/components/toolbar/tools/scissors/scissors';
|
||||
@@ -7,42 +9,20 @@ import { gpxStatistics } from '$lib/logic/statistics';
|
||||
import { get } from 'svelte/store';
|
||||
import { fileStateCollection } from '$lib/logic/file-state';
|
||||
import { fileActions } from '$lib/logic/file-actions';
|
||||
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
||||
import { ANCHOR_LAYER_KEY } from '$lib/components/map/map';
|
||||
|
||||
export class SplitControls {
|
||||
active: boolean = false;
|
||||
map: mapboxgl.Map;
|
||||
controls: ControlWithMarker[] = [];
|
||||
shownControls: ControlWithMarker[] = [];
|
||||
unsubscribes: Function[] = [];
|
||||
|
||||
layerOnMouseEnterBinded: (e: any) => void = this.layerOnMouseEnter.bind(this);
|
||||
layerOnMouseLeaveBinded: () => void = this.layerOnMouseLeave.bind(this);
|
||||
layerOnClickBinded: (e: any) => void = this.layerOnClick.bind(this);
|
||||
toggleControlsForZoomLevelAndBoundsBinded: () => void =
|
||||
this.toggleControlsForZoomLevelAndBounds.bind(this);
|
||||
|
||||
constructor(map: mapboxgl.Map) {
|
||||
this.map = map;
|
||||
|
||||
if (!this.map.hasImage('split-control')) {
|
||||
let icon = new Image(100, 100);
|
||||
icon.onload = () => {
|
||||
if (!this.map.hasImage('split-control')) {
|
||||
this.map.addImage('split-control', icon);
|
||||
}
|
||||
};
|
||||
|
||||
// Lucide icons are SVG files with a 24x24 viewBox
|
||||
// Create a new SVG with a 32x32 viewBox and center the icon in a circle
|
||||
icon.src =
|
||||
'data:image/svg+xml,' +
|
||||
encodeURIComponent(`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
|
||||
<circle cx="20" cy="20" r="20" fill="white" />
|
||||
<g transform="translate(8 8)">
|
||||
${Scissors.replace('stroke="currentColor"', 'stroke="black"')}
|
||||
</g>
|
||||
</svg>
|
||||
`);
|
||||
}
|
||||
|
||||
this.unsubscribes.push(gpxStatistics.subscribe(this.addIfNeeded.bind(this)));
|
||||
this.unsubscribes.push(currentTool.subscribe(this.addIfNeeded.bind(this)));
|
||||
this.unsubscribes.push(selection.subscribe(this.addIfNeeded.bind(this)));
|
||||
@@ -51,18 +31,29 @@ export class SplitControls {
|
||||
addIfNeeded() {
|
||||
let scissors = get(currentTool) === Tool.SCISSORS;
|
||||
if (!scissors) {
|
||||
this.remove();
|
||||
if (this.active) {
|
||||
this.remove();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateControls();
|
||||
if (this.active) {
|
||||
this.updateControls();
|
||||
} else {
|
||||
this.add();
|
||||
}
|
||||
}
|
||||
|
||||
add() {
|
||||
this.active = true;
|
||||
|
||||
this.map.on('zoom', this.toggleControlsForZoomLevelAndBoundsBinded);
|
||||
this.map.on('move', this.toggleControlsForZoomLevelAndBoundsBinded);
|
||||
}
|
||||
|
||||
updateControls() {
|
||||
let data: GeoJSON.FeatureCollection = {
|
||||
type: 'FeatureCollection',
|
||||
features: [],
|
||||
};
|
||||
// Update the markers when the files change
|
||||
let controlIndex = 0;
|
||||
selection.applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
|
||||
let file = fileStateCollection.getFile(fileId);
|
||||
|
||||
@@ -73,23 +64,30 @@ export class SplitControls {
|
||||
new ListTrackSegmentItem(fileId, trackIndex, segmentIndex)
|
||||
)
|
||||
) {
|
||||
for (let i = 1; i < segment.trkpt.length - 1; i++) {
|
||||
let point = segment.trkpt[i];
|
||||
for (let point of segment.trkpt.slice(1, -1)) {
|
||||
// Update the existing controls (could be improved by matching the existing controls with the new ones?)
|
||||
if (point._data.anchor) {
|
||||
data.features.push({
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [point.getLongitude(), point.getLatitude()],
|
||||
},
|
||||
properties: {
|
||||
fileId: fileId,
|
||||
trackIndex: trackIndex,
|
||||
segmentIndex: segmentIndex,
|
||||
pointIndex: i,
|
||||
minZoom: point._data.zoom,
|
||||
},
|
||||
});
|
||||
if (controlIndex < this.controls.length) {
|
||||
this.controls[controlIndex].fileId = fileId;
|
||||
this.controls[controlIndex].point = point;
|
||||
this.controls[controlIndex].segment = segment;
|
||||
this.controls[controlIndex].trackIndex = trackIndex;
|
||||
this.controls[controlIndex].segmentIndex = segmentIndex;
|
||||
this.controls[controlIndex].marker.setLngLat(
|
||||
point.getCoordinates()
|
||||
);
|
||||
} else {
|
||||
this.controls.push(
|
||||
this.createControl(
|
||||
point,
|
||||
segment,
|
||||
fileId,
|
||||
trackIndex,
|
||||
segmentIndex
|
||||
)
|
||||
);
|
||||
}
|
||||
controlIndex++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,78 +95,86 @@ export class SplitControls {
|
||||
}
|
||||
}, false);
|
||||
|
||||
try {
|
||||
let source = this.map.getSource('split-controls') as mapboxgl.GeoJSONSource | undefined;
|
||||
if (source) {
|
||||
source.setData(data);
|
||||
} else {
|
||||
this.map.addSource('split-controls', {
|
||||
type: 'geojson',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.map.getLayer('split-controls')) {
|
||||
this.map.addLayer(
|
||||
{
|
||||
id: 'split-controls',
|
||||
type: 'symbol',
|
||||
source: 'split-controls',
|
||||
layout: {
|
||||
'icon-image': 'split-control',
|
||||
'icon-size': 0.25,
|
||||
'icon-padding': 0,
|
||||
},
|
||||
filter: ['<=', ['get', 'minZoom'], ['zoom']],
|
||||
},
|
||||
ANCHOR_LAYER_KEY.interactions
|
||||
);
|
||||
|
||||
this.map.on('mouseenter', 'split-controls', this.layerOnMouseEnterBinded);
|
||||
this.map.on('mouseleave', 'split-controls', this.layerOnMouseLeaveBinded);
|
||||
this.map.on('click', 'split-controls', this.layerOnClickBinded);
|
||||
}
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to add sources and layers
|
||||
while (controlIndex < this.controls.length) {
|
||||
// Remove the extra controls
|
||||
this.controls.pop()?.marker.remove();
|
||||
}
|
||||
|
||||
this.toggleControlsForZoomLevelAndBounds();
|
||||
}
|
||||
|
||||
remove() {
|
||||
this.map.off('mouseenter', 'split-controls', this.layerOnMouseEnterBinded);
|
||||
this.map.off('mouseleave', 'split-controls', this.layerOnMouseLeaveBinded);
|
||||
this.map.off('click', 'split-controls', this.layerOnClickBinded);
|
||||
this.active = false;
|
||||
|
||||
try {
|
||||
if (this.map.getLayer('split-controls')) {
|
||||
this.map.removeLayer('split-controls');
|
||||
}
|
||||
|
||||
if (this.map.getSource('split-controls')) {
|
||||
this.map.removeSource('split-controls');
|
||||
}
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to remove sources and layers
|
||||
for (let control of this.controls) {
|
||||
control.marker.remove();
|
||||
}
|
||||
this.map.off('zoom', this.toggleControlsForZoomLevelAndBoundsBinded);
|
||||
this.map.off('move', this.toggleControlsForZoomLevelAndBoundsBinded);
|
||||
}
|
||||
|
||||
layerOnMouseEnter(e: any) {
|
||||
mapCursor.notify(MapCursorState.SPLIT_CONTROL, true);
|
||||
toggleControlsForZoomLevelAndBounds() {
|
||||
// Show markers only if they are in the current zoom level and bounds
|
||||
this.shownControls.splice(0, this.shownControls.length);
|
||||
|
||||
let southWest = this.map.unproject([0, this.map.getCanvas().height]);
|
||||
let northEast = this.map.unproject([this.map.getCanvas().width, 0]);
|
||||
let bounds = new mapboxgl.LngLatBounds(southWest, northEast);
|
||||
|
||||
let zoom = this.map.getZoom();
|
||||
this.controls.forEach((control) => {
|
||||
control.inZoom = control.point._data.zoom <= zoom;
|
||||
if (control.inZoom && bounds.contains(control.marker.getLngLat())) {
|
||||
control.marker.addTo(this.map);
|
||||
this.shownControls.push(control);
|
||||
} else {
|
||||
control.marker.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
layerOnMouseLeave() {
|
||||
mapCursor.notify(MapCursorState.SPLIT_CONTROL, false);
|
||||
}
|
||||
createControl(
|
||||
point: TrackPoint,
|
||||
segment: TrackSegment,
|
||||
fileId: string,
|
||||
trackIndex: number,
|
||||
segmentIndex: number
|
||||
): ControlWithMarker {
|
||||
let element = document.createElement('div');
|
||||
element.className = `h-6 w-6 p-0.5 rounded-full bg-white border-2 border-black cursor-pointer`;
|
||||
element.innerHTML = Scissors.replace('width="24"', '')
|
||||
.replace('height="24"', '')
|
||||
.replace('stroke="currentColor"', 'stroke="black"');
|
||||
|
||||
layerOnClick(e: mapboxgl.MapMouseEvent) {
|
||||
let coordinates = (e.features![0].geometry as GeoJSON.Point).coordinates;
|
||||
fileActions.split(
|
||||
get(splitAs),
|
||||
e.features![0].properties!.fileId,
|
||||
e.features![0].properties!.trackIndex,
|
||||
e.features![0].properties!.segmentIndex,
|
||||
{ lon: coordinates[0], lat: coordinates[1] },
|
||||
e.features![0].properties!.pointIndex
|
||||
);
|
||||
let marker = new mapboxgl.Marker({
|
||||
draggable: true,
|
||||
className: 'z-10',
|
||||
element,
|
||||
}).setLngLat(point.getCoordinates());
|
||||
|
||||
let control = {
|
||||
point,
|
||||
segment,
|
||||
fileId,
|
||||
trackIndex,
|
||||
segmentIndex,
|
||||
marker,
|
||||
inZoom: false,
|
||||
};
|
||||
|
||||
marker.getElement().addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
fileActions.split(
|
||||
get(splitAs),
|
||||
control.fileId,
|
||||
control.trackIndex,
|
||||
control.segmentIndex,
|
||||
control.point.getCoordinates(),
|
||||
control.point._data.index
|
||||
);
|
||||
});
|
||||
|
||||
return control;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
@@ -176,3 +182,16 @@ export class SplitControls {
|
||||
this.unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
}
|
||||
}
|
||||
|
||||
type Control = {
|
||||
segment: TrackSegment;
|
||||
fileId: string;
|
||||
trackIndex: number;
|
||||
segmentIndex: number;
|
||||
point: TrackPoint;
|
||||
};
|
||||
|
||||
type ControlWithMarker = Control & {
|
||||
marker: mapboxgl.Marker;
|
||||
inZoom: boolean;
|
||||
};
|
||||
|
||||
@@ -16,8 +16,6 @@
|
||||
import { fileActions } from '$lib/logic/file-actions';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import { getSvgForSymbol } from '$lib/components/map/gpx-layer/gpx-layer';
|
||||
|
||||
let props: {
|
||||
class?: string;
|
||||
@@ -41,21 +39,6 @@
|
||||
})
|
||||
);
|
||||
|
||||
let marker: mapboxgl.Marker | null = null;
|
||||
|
||||
function reset() {
|
||||
if ($selectedWaypoint) {
|
||||
selectedWaypoint.reset();
|
||||
} else {
|
||||
name = '';
|
||||
description = '';
|
||||
link = '';
|
||||
sym = '';
|
||||
longitude = 0;
|
||||
latitude = 0;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if ($selectedWaypoint) {
|
||||
const wpt = $selectedWaypoint[0];
|
||||
@@ -71,7 +54,14 @@
|
||||
latitude = parseFloat(wpt.getLatitude().toFixed(6));
|
||||
});
|
||||
} else {
|
||||
untrack(reset);
|
||||
untrack(() => {
|
||||
name = '';
|
||||
description = '';
|
||||
link = '';
|
||||
sym = '';
|
||||
longitude = 0;
|
||||
latitude = 0;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -95,14 +85,14 @@
|
||||
desc: description.length > 0 ? description : undefined,
|
||||
cmt: description.length > 0 ? description : undefined,
|
||||
link: link.length > 0 ? { attributes: { href: link } } : undefined,
|
||||
sym: sym.length > 0 ? sym : undefined,
|
||||
sym: sym,
|
||||
},
|
||||
selectedWaypoint.wpt && selectedWaypoint.fileId
|
||||
? new ListWaypointItem(selectedWaypoint.fileId, selectedWaypoint.wpt._data.index)
|
||||
: undefined
|
||||
);
|
||||
|
||||
reset();
|
||||
selectedWaypoint.reset();
|
||||
}
|
||||
|
||||
function setCoordinates(e: any) {
|
||||
@@ -110,37 +100,6 @@
|
||||
longitude = e.lngLat.lng.toFixed(6);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if ($selectedWaypoint) {
|
||||
if (marker) {
|
||||
marker.remove();
|
||||
marker = null;
|
||||
}
|
||||
} else if (latitude != 0 || longitude != 0) {
|
||||
if ($map) {
|
||||
if (marker) {
|
||||
marker.setLngLat([longitude, latitude]).getElement().innerHTML =
|
||||
getSvgForSymbol(symbolKey);
|
||||
} else {
|
||||
let element = document.createElement('div');
|
||||
element.classList.add('w-8', 'h-8');
|
||||
element.innerHTML = getSvgForSymbol(symbolKey);
|
||||
marker = new mapboxgl.Marker({
|
||||
element,
|
||||
anchor: 'bottom',
|
||||
})
|
||||
.setLngLat([longitude, latitude])
|
||||
.addTo($map);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (marker) {
|
||||
marker.remove();
|
||||
marker = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
if ($map) {
|
||||
$map.on('click', setCoordinates);
|
||||
@@ -153,10 +112,6 @@
|
||||
$map.off('click', setCoordinates);
|
||||
mapCursor.notify(MapCursorState.TOOL_WITH_CROSSHAIR, false);
|
||||
}
|
||||
if (marker) {
|
||||
marker.remove();
|
||||
marker = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -174,27 +129,19 @@
|
||||
bind:value={description}
|
||||
id="description"
|
||||
disabled={!canCreate && !$selectedWaypoint}
|
||||
class="min-h-8 h-8 py-1 px-3 text-sm"
|
||||
/>
|
||||
<Label for="symbol">{i18n._('toolbar.waypoint.icon')}</Label>
|
||||
<Select.Root bind:value={sym} type="single">
|
||||
<Select.Trigger
|
||||
id="symbol"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
class="w-full h-8"
|
||||
disabled={!canCreate && !$selectedWaypoint}
|
||||
>
|
||||
<span class="flex flex-row gap-1.5 items-center">
|
||||
{#if symbolKey}
|
||||
{#if symbols[symbolKey].icon}
|
||||
{@const Component = symbols[symbolKey].icon}
|
||||
<Component size="14" />
|
||||
{/if}
|
||||
{i18n._(`gpx.symbol.${symbolKey}`)}
|
||||
{:else}
|
||||
{sym}
|
||||
{/if}
|
||||
</span>
|
||||
{#if symbolKey}
|
||||
{i18n._(`gpx.symbol.${symbolKey}`)}
|
||||
{:else}
|
||||
{sym}
|
||||
{/if}
|
||||
</Select.Trigger>
|
||||
<Select.Content class="max-h-60 overflow-y-scroll">
|
||||
{#each sortedSymbols as [key, symbol]}
|
||||
@@ -202,7 +149,7 @@
|
||||
<span>
|
||||
{#if symbol.icon}
|
||||
{@const Component = symbol.icon}
|
||||
<Component size="14" class="inline-block align-sub" />
|
||||
<Component size="14" class="inline-block align-sub mr-0.5" />
|
||||
{:else}
|
||||
<span class="w-4 inline-block"></span>
|
||||
{/if}
|
||||
@@ -263,7 +210,7 @@
|
||||
{i18n._('toolbar.waypoint.create')}
|
||||
{/if}
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" onclick={reset}>
|
||||
<Button variant="outline" size="icon" onclick={() => selectedWaypoint.reset()}>
|
||||
<CircleX size="16" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -29,13 +29,13 @@ Pots arrossegar y deixar arxius directament des del seu sistema d'arxius cap a l
|
||||
|
||||
Crear una còpia dels arxius seleccionats.
|
||||
|
||||
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Esborra
|
||||
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Delete
|
||||
|
||||
Esborra l'arxiu seleccinat.
|
||||
Delete the currently selected files.
|
||||
|
||||
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Esborra-ho tot
|
||||
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Delete all
|
||||
|
||||
Esborra tots els fitxers.
|
||||
Delete all files.
|
||||
|
||||
### <Download size="16" class="inline-block" style="margin-bottom: 2px" /> Exportar...
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Mapbox stellt einige der auf dieser Website verwendeten Karten bereit.
|
||||
Sie entwickeln auch die <a href="https://github.com/mapbox/mapbox-gl-js" target="_blank">Karten-Engine</a>, die **gpx.studio** unterstützt.
|
||||
Mapbox ist das Unternehmen, das einige der schönen Karten auf dieser Website zur Verfügung stellt.
|
||||
Sie entwickeln auch die <a href="https://github.com/mapbox/mapbox-gl-js" target="_blank">Karten-Engine</a> welche **gpx.studio** unterstützt.
|
||||
|
||||
Wir sind froh und dankbar, Teil ihres <a href="https://mapbox.com/community" target="_blank">Community</a> Programms zu sein, das gemeinnützige Organisationen, Bildungseinrichtungen und Organisationen unterstützt.
|
||||
Wir sind äusserst glücklich und dankbar, Teil ihres <a href="https://mapbox.com/community" target="_blank">Community</a> 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.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: Opciones de vista
|
||||
title: View options
|
||||
---
|
||||
|
||||
<script lang="ts">
|
||||
|
||||
@@ -29,13 +29,13 @@ Beste era batez, fitxategiak zuzenean arrastatu eta jaregin ditzakezu zure fitxa
|
||||
|
||||
Sortu hautatutako fitxategien kopia bat.
|
||||
|
||||
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Ezabatu
|
||||
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Delete
|
||||
|
||||
Ezabatu hautatutako fitxategiak.
|
||||
Delete the currently selected files.
|
||||
|
||||
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Ezabatu guztiak
|
||||
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Delete all
|
||||
|
||||
Ezabatu fitxategi guztiak.
|
||||
Delete all files.
|
||||
|
||||
### <Download size="16" class="inline-block" style="margin-bottom: 2px" /> Esportatu...
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ Supprimer les fichiers sélectionnés.
|
||||
|
||||
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Supprimer tout
|
||||
|
||||
Supprimer tous les fichiers.
|
||||
Supprimer toutes les fichiers.
|
||||
|
||||
### <Download size="16" class="inline-block" style="margin-bottom: 2px" /> Exporter...
|
||||
|
||||
|
||||
@@ -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 d'ispezionare [tracce, segmenti e punti di interesse](./gpx) all'interno dei file attraverso sezioni espandibili.
|
||||
Inoltre, la vista ad albero dei file consente di 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 <kbd>Maiuscolo</kbd>.
|
||||
È 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 <kbd>Maiusc</kbd>.
|
||||
|
||||
<div class="h-48 w-full">
|
||||
<ElevationProfile
|
||||
|
||||
@@ -21,7 +21,7 @@ Queste sono organizzate in una struttura gerarchica, con le tracce stesse al liv
|
||||
- Una **traccia** è composta da una sequenza di segmenti scollegati.
|
||||
Inoltre, può contenere metadati come un **nome**, una **descrizione**, e **proprietà di visualizzazione**.
|
||||
- Un **segmento** è una sequenza di punti GPS che formano un percorso continuo.
|
||||
- Un **punto GPS** è una posizione con una latitudine, una longitudine, ed eventualmente una marcatura temporale e un'altitudine.
|
||||
- Un **punto GPS** è una posizione con una latitudine, una longitudine, ed eventualmente un timestamp e un'altitudine.
|
||||
Alcuni dispositivi memorizzano anche informazioni aggiuntive come frequenza cardiaca, cadenza, temperatura e potenza.
|
||||
|
||||
Nella maggior parte dei casi, i file GPX contengono una singola traccia con un singolo segmento.
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
## <HeartHandshake size="18" class="inline-block align-baseline" /> 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 <a href="https://mapbox.com" target="_blank">Mapbox</a> per visualizzare mappe stupende, recuperare i dati altimetrici e consentire la ricerca di luoghi.
|
||||
Utilizziamo anche le API di <a href="https://mapbox.com" target="_blank">Mapbox</a> per visualizzare mappe gradevoli, recuperare i dati altimetrici e consentire la ricerca di luoghi.
|
||||
|
||||
Sfortunatamente, fare tutto ciò è costoso.
|
||||
Sfortunatamente, questo è 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! ❤️
|
||||
|
||||
@@ -29,13 +29,13 @@ cÈ inoltre possibile trascinare i file direttamente dal file system del tuo Pc
|
||||
|
||||
Crea una copia dei file attualmente selezionati.
|
||||
|
||||
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" />Elimina
|
||||
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Delete
|
||||
|
||||
Elimina i file attualmente selezionati.
|
||||
Delete the currently selected files.
|
||||
|
||||
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" />Cancella tutto
|
||||
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Delete all
|
||||
|
||||
Elimina tutti i file.
|
||||
Delete all files.
|
||||
|
||||
### <Download size="16" class="inline-block" style="margin-bottom: 2px" /> Esporta...
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ Deze handleiding zal je door alle componenten en gereedschappen van de interface
|
||||
<DocsImage src="getting-started/interface" alt="De gpx.studio interface." />
|
||||
|
||||
Zoals weergegeven in bovenstaande scherm, is de interface verdeeld in vier hoofddelen rond de kaart.
|
||||
Voordat we in de details van elke sectie duiken, eerst een snel overzicht van de interface.
|
||||
Voordat we in de details van elke sectie duiken, hebben we een snel overzicht van de interface.
|
||||
|
||||
## Menu
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ Deze actie is alleen beschikbaar wanneer de verticale indeling van de bestandsli
|
||||
|
||||
### <ClipboardPaste size="16" class="inline-block" style="margin-bottom: 2px" /> Plakken
|
||||
|
||||
Plak de bestandsitems van het klembord naar het huidige hiërarchieniveau indien compatibel.
|
||||
Plak de bestandsitems van het klembord naar het huidige hiërarchie niveau indien compatibel.
|
||||
|
||||
<DocsNote>
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ Clicando com o botão direito em uma aba arquivo, você pode acessar as mesmas a
|
||||
|
||||
As mentioned in the [view options section](./menu/view), you can switch to a tree layout for the files list.
|
||||
This layout is ideal for managing a large number of open files, as it organizes them into a vertical list on the right side of the map.
|
||||
Além disso, a exibição de árvore de arquivos permite que você inspecione as [faixas, segmentos, e pontos de interesse](./gpx) contidos dentro dos arquivos através de seções recolhidas.
|
||||
In addition, the file tree view enables you to inspect the [tracks, segments, and points of interest](./gpx) contained inside the files through collapsible sections.
|
||||
|
||||
Você também pode aplicar as [ações de edição](./menu/edit) e [ferramentas](./toolbar) para itens de arquivos internos.
|
||||
Além disso, você pode arrastar e soltar os itens internos para reordená-los, ou movê-los na hierarquia ou até mesmo para outro arquivo.
|
||||
@@ -105,6 +105,6 @@ Using the <kbd><ChartNoAxesColumn size="16" class="inline-block" style="margin-b
|
||||
|
||||
- **slope** information computed from the elevation data, or
|
||||
- **surface** or **category** data coming from <a href="https://www.openstreetmap.org/" target="_blank">OpenStreetMap</a>'s <a href="https://wiki.openstreetmap.org/wiki/Key:surface" target="_blank">surface</a> and <a href="https://wiki.openstreetmap.org/wiki/Key:highway" target="_blank">highway</a> tags.
|
||||
Isso só está disponível para arquivos criados com **gpx.studio**.
|
||||
This is only available for files created with **gpx.studio**.
|
||||
|
||||
If your selection includes it, you can also visualize: **speed**, **heart rate**, **cadence**, **temperature** and **power** data on the elevation profile.
|
||||
|
||||
@@ -35,7 +35,7 @@ Delete the currently selected files.
|
||||
|
||||
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Delete all
|
||||
|
||||
Apagar todos os arquivos.
|
||||
Delete all files.
|
||||
|
||||
### <Download size="16" class="inline-block" style="margin-bottom: 2px" /> Exportar...
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
import { HeartHandshake } from '@lucide/svelte';
|
||||
</script>
|
||||
|
||||
## <HeartHandshake size="18" class="inline-block align-baseline" />
|
||||
Допоможіть нам залишати цей сайт безкоштовним (та без реклами)
|
||||
## <HeartHandshake size="18" class="inline-block align-baseline" /> Help keep the website free (and ad-free)
|
||||
|
||||
Кожного разу, коли ви додаєте або переміщуєте GPS точки, наші сервери обчислюють найкращий маршрут на мережі доріг.
|
||||
Ми також використовуємо API від <a href="https://mapbox.com" target="_blank">Mapbox</a> для зображення красивих карт, отримання даних висот та можливості пошуку місць.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Languages } from '@lucide/svelte';
|
||||
</script>
|
||||
|
||||
## <Languages size="18" class="inline-block align-baseline" /> Переклад
|
||||
## <Languages size="18" class="inline-block align-baseline" /> Translation
|
||||
|
||||
Сайт перекладається волонтерами з використанням платформи для спільного перекладу.
|
||||
Ви можете зробити свій внесок, додаючи або покращуючи переклади в нашому <a href="https://crowdin.com/project/gpxstudio" target="_blank">проєкті Crowdin</a>.
|
||||
|
||||
@@ -47,6 +47,6 @@ Open the export dialog to save all files to your computer.
|
||||
|
||||
<DocsNote type="warning">
|
||||
|
||||
Якщо завантаження не починається після натискання кнопки завантаження, будь ласка, перевірте налаштування браузера, щоб дозволити завантаження з <b>gpx.studio</b>.
|
||||
If your download does not start after clicking the download button, please check your browser settings to allow downloads from <b>gpx.studio</b>.
|
||||
|
||||
</DocsNote>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { HeartHandshake } from '@lucide/svelte';
|
||||
</script>
|
||||
|
||||
## <HeartHandshake size="18" class="inline-block align-baseline" /> Hãy giúp duy trì trang web miễn phí (và không có quảng cáo)
|
||||
## <HeartHandshake size="18" class="inline-block align-baseline" /> Help keep the website free (and ad-free)
|
||||
|
||||
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ừ <a href="https://mapbox.com" target="_blank">Mapbox</a> để 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.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 <a href="https://github.com/mapbox/mapbox-gl-js" target="_blank">công cụ bản đồ</a> cung cấp sức mạnh cho **gpx.studio**.
|
||||
Mapbox is the company that provides some of the beautiful maps on this website.
|
||||
They also develop the <a href="https://github.com/mapbox/mapbox-gl-js" target="_blank">map engine</a> which powers **gpx.studio**.
|
||||
|
||||
Chúng tôi vô cùng may mắn và biết ơn khi được tham gia chương trình <a href="https://mapbox.com/community" target="_blank">Cộng đồng</a> 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ể.
|
||||
We are incredibly fortunate and grateful to be part of their <a href="https://mapbox.com/community" target="_blank">Community</a> 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.
|
||||
|
||||
@@ -9,8 +9,8 @@ title: Edit actions
|
||||
|
||||
# { title }
|
||||
|
||||
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).
|
||||
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).
|
||||
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.
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ Create a copy of the currently selected files.
|
||||
|
||||
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Delete
|
||||
|
||||
.
|
||||
Delete the currently selected files.
|
||||
|
||||
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Delete all
|
||||
|
||||
|
||||
@@ -29,13 +29,13 @@ title: 文件
|
||||
|
||||
创建当前选中文件的副本。
|
||||
|
||||
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> 删除
|
||||
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Delete
|
||||
|
||||
删除当前选中的文件。
|
||||
Delete the currently selected files.
|
||||
|
||||
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> 删除全部
|
||||
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Delete all
|
||||
|
||||
删除全部文件。
|
||||
Delete all files.
|
||||
|
||||
### <Download size="16" class="inline-block" style="margin-bottom: 2px" /> 导出...
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ class Locale {
|
||||
private _isLoadingInitial = $state(true);
|
||||
private _isLoading = $state(true);
|
||||
private dictionary: Dictionary = $state({});
|
||||
private _t = $derived((key: string, fallback?: string) => {
|
||||
private _t = $derived((key: string) => {
|
||||
const keys = key.split('.');
|
||||
let value: string | Dictionary = this.dictionary;
|
||||
|
||||
@@ -22,7 +22,7 @@ class Locale {
|
||||
if (value && typeof value === 'object' && k in value) {
|
||||
value = value[k];
|
||||
} else {
|
||||
return fallback || key;
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -66,8 +66,10 @@ export class BoundsManager {
|
||||
|
||||
finalizeFitBounds() {
|
||||
if (
|
||||
this._bounds.getSouth() >= this._bounds.getNorth() &&
|
||||
this._bounds.getWest() >= this._bounds.getEast()
|
||||
this._bounds.getSouth() === 90 &&
|
||||
this._bounds.getWest() === 180 &&
|
||||
this._bounds.getNorth() === -90 &&
|
||||
this._bounds.getEast() === -180
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { i18n } from '$lib/i18n.svelte';
|
||||
import { freeze, type WritableDraft } from 'immer';
|
||||
import {
|
||||
distance,
|
||||
GPXFile,
|
||||
parseGPX,
|
||||
Track,
|
||||
@@ -29,7 +30,7 @@ import {
|
||||
} from 'gpx';
|
||||
import { get } from 'svelte/store';
|
||||
import { settings } from '$lib/logic/settings';
|
||||
import { getClosestLinePoint, getClosestTrackSegments, getElevation } from '$lib/utils';
|
||||
import { getClosestLinePoint, getElevation } from '$lib/utils';
|
||||
import { gpxStatistics } from '$lib/logic/statistics';
|
||||
import { boundsManager } from './bounds';
|
||||
|
||||
@@ -215,7 +216,7 @@ export const fileActions = {
|
||||
reverseSelection: () => {
|
||||
if (
|
||||
!get(selection).hasAnyChildren(new ListRootItem(), true, ['waypoints']) ||
|
||||
get(gpxStatistics).global.length <= 1
|
||||
get(gpxStatistics).local.points?.length <= 1
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -345,20 +346,19 @@ export const fileActions = {
|
||||
let startTime: Date | undefined = undefined;
|
||||
if (speed !== undefined) {
|
||||
if (
|
||||
statistics.global.length > 0 &&
|
||||
statistics.getTrackPoint(0)!.trkpt.time !== undefined
|
||||
statistics.local.points.length > 0 &&
|
||||
statistics.local.points[0].time !== undefined
|
||||
) {
|
||||
startTime = statistics.getTrackPoint(0)!.trkpt.time;
|
||||
startTime = statistics.local.points[0].time;
|
||||
} else {
|
||||
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;
|
||||
}
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -453,13 +453,34 @@ export const fileActions = {
|
||||
selection.applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
|
||||
if (level === ListLevel.FILE) {
|
||||
let file = fileStateCollection.getFile(fileId);
|
||||
let statistics = fileStateCollection.getStatistics(fileId);
|
||||
if (file && statistics) {
|
||||
if (file) {
|
||||
if (file.trk.length > 1) {
|
||||
let fileIds = getFileIds(file.trk.length);
|
||||
let closest = file.wpt.map((wpt) =>
|
||||
getClosestTrackSegments(file, statistics, wpt.getCoordinates())
|
||||
);
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
file.trk.forEach((track, index) => {
|
||||
let newFile = file.clone();
|
||||
let tracks = track.trkseg.map((segment, segmentIndex) => {
|
||||
@@ -474,11 +495,9 @@ export const fileActions = {
|
||||
newFile.replaceWaypoints(
|
||||
0,
|
||||
file.wpt.length - 1,
|
||||
file.wpt.filter((wpt, wptIndex) =>
|
||||
closest[wptIndex].some(
|
||||
([trackIndex, segmentIndex]) => trackIndex === index
|
||||
)
|
||||
)
|
||||
closest
|
||||
.filter((c) => c.index.includes(index))
|
||||
.map((c) => file.wpt[c.wptIndex])
|
||||
);
|
||||
newFile._data.id = fileIds[index];
|
||||
newFile.metadata.name =
|
||||
@@ -487,9 +506,29 @@ export const fileActions = {
|
||||
});
|
||||
} else if (file.trk.length === 1) {
|
||||
let fileIds = getFileIds(file.trk[0].trkseg.length);
|
||||
let closest = file.wpt.map((wpt) =>
|
||||
getClosestTrackSegments(file, statistics, wpt.getCoordinates())
|
||||
);
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
file.trk[0].trkseg.forEach((segment, index) => {
|
||||
let newFile = file.clone();
|
||||
newFile.replaceTrackSegments(0, 0, file.trk[0].trkseg.length - 1, [
|
||||
@@ -498,11 +537,9 @@ export const fileActions = {
|
||||
newFile.replaceWaypoints(
|
||||
0,
|
||||
file.wpt.length - 1,
|
||||
file.wpt.filter((wpt, wptIndex) =>
|
||||
closest[wptIndex].some(
|
||||
([trackIndex, segmentIndex]) => segmentIndex === index
|
||||
)
|
||||
)
|
||||
closest
|
||||
.filter((c) => c.index.includes(index))
|
||||
.map((c) => file.wpt[c.wptIndex])
|
||||
);
|
||||
newFile._data.id = fileIds[index];
|
||||
newFile.metadata.name = `${file.trk[0].name ?? file.metadata.name} (${index + 1})`;
|
||||
@@ -807,7 +844,7 @@ export const fileActions = {
|
||||
});
|
||||
});
|
||||
},
|
||||
addElevationToSelection: async () => {
|
||||
addElevationToSelection: async (map: mapboxgl.Map) => {
|
||||
if (get(selection).size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4,12 +4,10 @@ import { get, writable, type Writable } from 'svelte/store';
|
||||
export enum MapCursorState {
|
||||
DEFAULT,
|
||||
LAYER_HOVER,
|
||||
TOOL_WITH_CROSSHAIR,
|
||||
WAYPOINT_HOVER,
|
||||
WAYPOINT_DRAGGING,
|
||||
TRACKPOINT_DRAGGING,
|
||||
TOOL_WITH_CROSSHAIR,
|
||||
SCISSORS,
|
||||
SPLIT_CONTROL,
|
||||
MAPILLARY_HOVER,
|
||||
STREET_VIEW_CROSSHAIR,
|
||||
}
|
||||
@@ -18,12 +16,10 @@ const scissorsCursor = `url('data:image/svg+xml,<svg xmlns="http://www.w3.org/20
|
||||
const cursorStyles = {
|
||||
[MapCursorState.DEFAULT]: 'default',
|
||||
[MapCursorState.LAYER_HOVER]: 'pointer',
|
||||
[MapCursorState.WAYPOINT_HOVER]: 'pointer',
|
||||
[MapCursorState.WAYPOINT_DRAGGING]: 'grabbing',
|
||||
[MapCursorState.TRACKPOINT_DRAGGING]: 'grabbing',
|
||||
[MapCursorState.TOOL_WITH_CROSSHAIR]: 'crosshair',
|
||||
[MapCursorState.SCISSORS]: scissorsCursor,
|
||||
[MapCursorState.SPLIT_CONTROL]: 'pointer',
|
||||
[MapCursorState.MAPILLARY_HOVER]: 'pointer',
|
||||
[MapCursorState.STREET_VIEW_CROSSHAIR]: 'crosshair',
|
||||
};
|
||||
@@ -34,8 +30,8 @@ export class MapCursor {
|
||||
constructor() {
|
||||
this._states = writable(new Set());
|
||||
this._states.subscribe((states) => {
|
||||
let state = Array.from(states.values()).reduce((max, value) => {
|
||||
return value > max ? value : max;
|
||||
let state = states.entries().reduce((max, entry) => {
|
||||
return entry[0] > max ? entry[0] : max;
|
||||
}, MapCursorState.DEFAULT);
|
||||
let canvas = get(map)?.getCanvas();
|
||||
if (canvas) {
|
||||
|
||||
@@ -179,112 +179,6 @@ export class Selection {
|
||||
}
|
||||
}
|
||||
|
||||
updateFromKey(down: boolean, shift: boolean) {
|
||||
let selected = get(this._selection).getSelected();
|
||||
if (selected.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let next: ListItem | undefined = undefined;
|
||||
if (selected[0] instanceof ListFileItem) {
|
||||
let order = get(settings.fileOrder);
|
||||
let limitIndex: number | undefined = undefined;
|
||||
selected.forEach((item) => {
|
||||
let index = order.indexOf(item.getFileId());
|
||||
if (
|
||||
limitIndex === undefined ||
|
||||
(down && index > limitIndex) ||
|
||||
(!down && index < limitIndex)
|
||||
) {
|
||||
limitIndex = index;
|
||||
}
|
||||
});
|
||||
|
||||
if (limitIndex !== undefined) {
|
||||
let nextIndex = down ? limitIndex + 1 : limitIndex - 1;
|
||||
|
||||
while (true) {
|
||||
if (nextIndex < 0) {
|
||||
nextIndex = order.length - 1;
|
||||
} else if (nextIndex >= order.length) {
|
||||
nextIndex = 0;
|
||||
}
|
||||
|
||||
if (nextIndex === limitIndex) {
|
||||
break;
|
||||
}
|
||||
|
||||
next = new ListFileItem(order[nextIndex]);
|
||||
if (!get(selection).has(next)) {
|
||||
break;
|
||||
}
|
||||
|
||||
nextIndex += down ? 1 : -1;
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
selected[0] instanceof ListTrackItem &&
|
||||
selected[selected.length - 1] instanceof ListTrackItem
|
||||
) {
|
||||
let fileId = selected[0].getFileId();
|
||||
let file = fileStateCollection.getFile(fileId);
|
||||
if (file) {
|
||||
let numberOfTracks = file.trk.length;
|
||||
let trackIndex = down
|
||||
? selected[selected.length - 1].getTrackIndex()
|
||||
: selected[0].getTrackIndex();
|
||||
if (down && trackIndex < numberOfTracks - 1) {
|
||||
next = new ListTrackItem(fileId, trackIndex + 1);
|
||||
} else if (!down && trackIndex > 0) {
|
||||
next = new ListTrackItem(fileId, trackIndex - 1);
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
selected[0] instanceof ListTrackSegmentItem &&
|
||||
selected[selected.length - 1] instanceof ListTrackSegmentItem
|
||||
) {
|
||||
let fileId = selected[0].getFileId();
|
||||
let file = fileStateCollection.getFile(fileId);
|
||||
if (file) {
|
||||
let trackIndex = selected[0].getTrackIndex();
|
||||
let numberOfSegments = file.trk[trackIndex].trkseg.length;
|
||||
let segmentIndex = down
|
||||
? selected[selected.length - 1].getSegmentIndex()
|
||||
: selected[0].getSegmentIndex();
|
||||
if (down && segmentIndex < numberOfSegments - 1) {
|
||||
next = new ListTrackSegmentItem(fileId, trackIndex, segmentIndex + 1);
|
||||
} else if (!down && segmentIndex > 0) {
|
||||
next = new ListTrackSegmentItem(fileId, trackIndex, segmentIndex - 1);
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
selected[0] instanceof ListWaypointItem &&
|
||||
selected[selected.length - 1] instanceof ListWaypointItem
|
||||
) {
|
||||
let fileId = selected[0].getFileId();
|
||||
let file = fileStateCollection.getFile(fileId);
|
||||
if (file) {
|
||||
let numberOfWaypoints = file.wpt.length;
|
||||
let waypointIndex = down
|
||||
? selected[selected.length - 1].getWaypointIndex()
|
||||
: selected[0].getWaypointIndex();
|
||||
if (down && waypointIndex < numberOfWaypoints - 1) {
|
||||
next = new ListWaypointItem(fileId, waypointIndex + 1);
|
||||
} else if (!down && waypointIndex > 0) {
|
||||
next = new ListWaypointItem(fileId, waypointIndex - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (next && (!get(this._selection).has(next) || !shift)) {
|
||||
if (shift) {
|
||||
this.addSelectItem(next);
|
||||
} else {
|
||||
this.selectItem(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getOrderedSelection(reverse: boolean = false): ListItem[] {
|
||||
let selected: ListItem[] = [];
|
||||
this.applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
defaultOverlayTree,
|
||||
defaultOverpassQueries,
|
||||
defaultOverpassTree,
|
||||
defaultTerrainSource,
|
||||
type CustomLayer,
|
||||
} from '$lib/assets/layers';
|
||||
import { browser } from '$app/environment';
|
||||
@@ -155,7 +154,6 @@ export const settings = {
|
||||
customLayers: new Setting<Record<string, CustomLayer>>('customLayers', {}),
|
||||
customBasemapOrder: new Setting<string[]>('customBasemapOrder', []),
|
||||
customOverlayOrder: new Setting<string[]>('customOverlayOrder', []),
|
||||
terrainSource: new Setting('terrainSource', defaultTerrainSource),
|
||||
directionMarkers: new Setting('directionMarkers', false),
|
||||
distanceMarkers: new Setting('distanceMarkers', false),
|
||||
streetViewSource: new Setting('streetViewSource', 'mapillary'),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ListItem, ListLevel } from '$lib/components/file-list/file-list';
|
||||
import { GPXFile, GPXStatistics, GPXStatisticsGroup, type Track } from 'gpx';
|
||||
import { GPXFile, GPXStatistics, type Track } from 'gpx';
|
||||
|
||||
export class GPXStatisticsTree {
|
||||
level: ListLevel;
|
||||
@@ -21,23 +21,23 @@ export class GPXStatisticsTree {
|
||||
}
|
||||
}
|
||||
|
||||
getStatisticsFor(item: ListItem): GPXStatisticsGroup {
|
||||
let statistics = new GPXStatisticsGroup();
|
||||
getStatisticsFor(item: ListItem): GPXStatistics {
|
||||
let statistics = new GPXStatistics();
|
||||
let id = item.getIdAtLevel(this.level);
|
||||
if (id === undefined || id === 'waypoints') {
|
||||
Object.keys(this.statistics).forEach((key) => {
|
||||
if (this.statistics[key] instanceof GPXStatistics) {
|
||||
statistics.add(this.statistics[key]);
|
||||
statistics.mergeWith(this.statistics[key]);
|
||||
} else {
|
||||
statistics.add(this.statistics[key].getStatisticsFor(item));
|
||||
statistics.mergeWith(this.statistics[key].getStatisticsFor(item));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
let child = this.statistics[id];
|
||||
if (child instanceof GPXStatistics) {
|
||||
statistics.add(child);
|
||||
statistics.mergeWith(child);
|
||||
} else if (child !== undefined) {
|
||||
statistics.add(child.getStatisticsFor(item));
|
||||
statistics.mergeWith(child.getStatisticsFor(item));
|
||||
}
|
||||
}
|
||||
return statistics;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { selection } from '$lib/logic/selection';
|
||||
import { GPXGlobalStatistics, GPXStatisticsGroup } from 'gpx';
|
||||
import { GPXStatistics } 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<GPXStatisticsGroup>;
|
||||
private _statistics: Writable<GPXStatistics>;
|
||||
private _files: Map<
|
||||
string,
|
||||
{
|
||||
@@ -22,21 +22,18 @@ export class SelectedGPXStatistics {
|
||||
>;
|
||||
|
||||
constructor() {
|
||||
this._statistics = writable(new GPXStatisticsGroup());
|
||||
this._statistics = writable(new GPXStatistics());
|
||||
this._files = new Map();
|
||||
selection.subscribe(() => this.update());
|
||||
fileOrder.subscribe(() => this.update());
|
||||
}
|
||||
|
||||
subscribe(
|
||||
run: (value: GPXStatisticsGroup) => void,
|
||||
invalidate?: (value?: GPXStatisticsGroup) => void
|
||||
) {
|
||||
subscribe(run: (value: GPXStatistics) => void, invalidate?: (value?: GPXStatistics) => void) {
|
||||
return this._statistics.subscribe(run, invalidate);
|
||||
}
|
||||
|
||||
update() {
|
||||
let statistics = new GPXStatisticsGroup();
|
||||
let statistics = new GPXStatistics();
|
||||
selection.applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
|
||||
let stats = fileStateCollection.getStatistics(fileId);
|
||||
if (stats) {
|
||||
@@ -46,7 +43,7 @@ export class SelectedGPXStatistics {
|
||||
!(item instanceof ListWaypointItem || item instanceof ListWaypointsItem) ||
|
||||
first
|
||||
) {
|
||||
statistics.add(stats.getStatisticsFor(item));
|
||||
statistics.mergeWith(stats.getStatisticsFor(item));
|
||||
first = false;
|
||||
}
|
||||
});
|
||||
@@ -79,7 +76,7 @@ export class SelectedGPXStatistics {
|
||||
|
||||
export const gpxStatistics = new SelectedGPXStatistics();
|
||||
|
||||
export const slicedGPXStatistics: Writable<[GPXGlobalStatistics, number, number] | undefined> =
|
||||
export const slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined> =
|
||||
writable(undefined);
|
||||
|
||||
gpxStatistics.subscribe(() => {
|
||||
|
||||
@@ -229,9 +229,6 @@ export function getConvertedVelocity(
|
||||
}
|
||||
}
|
||||
|
||||
export function getConvertedTemperature(
|
||||
value: number,
|
||||
targetTemperatureUnits = get(temperatureUnits)
|
||||
) {
|
||||
return targetTemperatureUnits === 'celsius' ? value : celsiusToFahrenheit(value);
|
||||
export function getConvertedTemperature(value: number) {
|
||||
return get(temperatureUnits) === 'celsius' ? value : celsiusToFahrenheit(value);
|
||||
}
|
||||
|
||||
@@ -2,13 +2,11 @@ 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, GPXFile } from 'gpx';
|
||||
import { TrackPoint, Waypoint, type Coordinates, crossarcDistance, distance } 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));
|
||||
@@ -49,59 +47,6 @@ 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,
|
||||
|
||||
@@ -79,8 +79,7 @@
|
||||
"unhide": "Паказаць",
|
||||
"center": "Center",
|
||||
"open_in": "Адчыніць у",
|
||||
"copy_coordinates": "Copy coordinates",
|
||||
"edit_osm": "Edit in OpenStreetMap"
|
||||
"copy_coordinates": "Copy coordinates"
|
||||
},
|
||||
"toolbar": {
|
||||
"routing": {
|
||||
@@ -282,7 +281,6 @@
|
||||
"update": "Update layer"
|
||||
},
|
||||
"opacity": "Overlay opacity",
|
||||
"terrain": "Terrain source",
|
||||
"label": {
|
||||
"basemaps": "Basemaps",
|
||||
"overlays": "Overlays",
|
||||
@@ -326,8 +324,6 @@
|
||||
"usgs": "USGS",
|
||||
"bikerouterGravel": "bikerouter.de Gravel",
|
||||
"cyclOSMlite": "CyclOSM Lite",
|
||||
"mapterhornHillshade": "Mapterhorn Hillshade",
|
||||
"openRailwayMap": "OpenRailwayMap",
|
||||
"swisstopoSlope": "swisstopo Slope",
|
||||
"swisstopoHiking": "swisstopo Hiking",
|
||||
"swisstopoHikingClosures": "swisstopo Hiking Closures",
|
||||
@@ -356,7 +352,6 @@
|
||||
"water": "Water",
|
||||
"shower": "Shower",
|
||||
"shelter": "Shelter",
|
||||
"cemetery": "Cemetery",
|
||||
"motorized": "Cars and Motorcycles",
|
||||
"fuel-station": "Fuel Station",
|
||||
"parking": "Parking",
|
||||
@@ -380,9 +375,7 @@
|
||||
"railway-station": "Railway Station",
|
||||
"tram-stop": "Tram Stop",
|
||||
"bus-stop": "Bus Stop",
|
||||
"ferry": "Ferry",
|
||||
"mapbox-dem": "Mapbox DEM",
|
||||
"mapterhorn": "Mapterhorn"
|
||||
"ferry": "Ferry"
|
||||
}
|
||||
},
|
||||
"chart": {
|
||||
@@ -482,6 +475,7 @@
|
||||
"app": "App",
|
||||
"contact": "Contact",
|
||||
"reddit": "Reddit",
|
||||
"x": "X",
|
||||
"facebook": "Facebook",
|
||||
"github": "GitHub",
|
||||
"crowdin": "Crowdin",
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"undo": "Desfer",
|
||||
"redo": "Refer",
|
||||
"delete": "Elimina el track",
|
||||
"delete_all": "Esborra-ho tot",
|
||||
"delete_all": "Delete all",
|
||||
"select_all": "Seleccionar-ho tot",
|
||||
"view": "Vista",
|
||||
"elevation_profile": "Perfil d’elevacions",
|
||||
@@ -79,8 +79,7 @@
|
||||
"unhide": "Veure",
|
||||
"center": "Centrar",
|
||||
"open_in": "Obrir amb",
|
||||
"copy_coordinates": "Copiar coordenades",
|
||||
"edit_osm": "Edita a OpenStreetMap"
|
||||
"copy_coordinates": "Copiar coordenades"
|
||||
},
|
||||
"toolbar": {
|
||||
"routing": {
|
||||
@@ -282,7 +281,6 @@
|
||||
"update": "Actualitza la capa"
|
||||
},
|
||||
"opacity": "Opacitat de la superposició",
|
||||
"terrain": "Terreny",
|
||||
"label": {
|
||||
"basemaps": "Mapes base",
|
||||
"overlays": "Capes",
|
||||
@@ -326,8 +324,6 @@
|
||||
"usgs": "USGS",
|
||||
"bikerouterGravel": "bikerouter.de Gravel",
|
||||
"cyclOSMlite": "CyclOSM Lite",
|
||||
"mapterhornHillshade": "Mapterhorn Hillshade",
|
||||
"openRailwayMap": "OpenRailwayMap",
|
||||
"swisstopoSlope": "swisstopo Slope",
|
||||
"swisstopoHiking": "swisstopo Hiking",
|
||||
"swisstopoHikingClosures": "swisstopo Hiking Closures",
|
||||
@@ -356,7 +352,6 @@
|
||||
"water": "Aigua",
|
||||
"shower": "Dutxa",
|
||||
"shelter": "Refugi",
|
||||
"cemetery": "Cementiri",
|
||||
"motorized": "Cotxes i motos",
|
||||
"fuel-station": "Gasolinera",
|
||||
"parking": "Aparcament",
|
||||
@@ -380,9 +375,7 @@
|
||||
"railway-station": "Estació de tren",
|
||||
"tram-stop": "Parada de tramvia",
|
||||
"bus-stop": "Parada d'autobús",
|
||||
"ferry": "Ferri",
|
||||
"mapbox-dem": "Mapbox DEM",
|
||||
"mapterhorn": "Mapterhorn"
|
||||
"ferry": "Ferri"
|
||||
}
|
||||
},
|
||||
"chart": {
|
||||
@@ -482,6 +475,7 @@
|
||||
"app": "App",
|
||||
"contact": "Contacte",
|
||||
"reddit": "Reddit",
|
||||
"x": "X",
|
||||
"facebook": "Facebook",
|
||||
"github": "GitHub",
|
||||
"crowdin": "Crowdin",
|
||||
|
||||
@@ -79,8 +79,7 @@
|
||||
"unhide": "Zobrazit skryté",
|
||||
"center": "Vycentrovat",
|
||||
"open_in": "Otevřít v",
|
||||
"copy_coordinates": "Zkopírovat souřadnice",
|
||||
"edit_osm": "Upravit v OpenStreetMap"
|
||||
"copy_coordinates": "Zkopírovat souřadnice"
|
||||
},
|
||||
"toolbar": {
|
||||
"routing": {
|
||||
@@ -282,7 +281,6 @@
|
||||
"update": "Aktualizovat vrstvu"
|
||||
},
|
||||
"opacity": "Průhlednost překryvu",
|
||||
"terrain": "Zdroj terénu",
|
||||
"label": {
|
||||
"basemaps": "Základní mapy",
|
||||
"overlays": "Překrytí",
|
||||
@@ -326,8 +324,6 @@
|
||||
"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",
|
||||
@@ -356,7 +352,6 @@
|
||||
"water": "Voda",
|
||||
"shower": "Sprcha",
|
||||
"shelter": "Přístřeší",
|
||||
"cemetery": "Hřbitov",
|
||||
"motorized": "Automobily a motocykly",
|
||||
"fuel-station": "Čerpací stanice",
|
||||
"parking": "Parkoviště",
|
||||
@@ -380,9 +375,7 @@
|
||||
"railway-station": "Železniční stanice",
|
||||
"tram-stop": "Zastávka tramvaje",
|
||||
"bus-stop": "Autobusová zastávka",
|
||||
"ferry": "Trajekt",
|
||||
"mapbox-dem": "Mapbox DEM",
|
||||
"mapterhorn": "Mapterhorn"
|
||||
"ferry": "Trajekt"
|
||||
}
|
||||
},
|
||||
"chart": {
|
||||
@@ -482,6 +475,7 @@
|
||||
"app": "Aplikace",
|
||||
"contact": "Kontakt",
|
||||
"reddit": "Reddit",
|
||||
"x": "X",
|
||||
"facebook": "Facebook",
|
||||
"github": "GitHub",
|
||||
"crowdin": "Crowdin",
|
||||
|
||||
@@ -79,8 +79,7 @@
|
||||
"unhide": "Unhide",
|
||||
"center": "Center",
|
||||
"open_in": "Open in",
|
||||
"copy_coordinates": "Kopier koordinater",
|
||||
"edit_osm": "Edit in OpenStreetMap"
|
||||
"copy_coordinates": "Kopier koordinater"
|
||||
},
|
||||
"toolbar": {
|
||||
"routing": {
|
||||
@@ -282,7 +281,6 @@
|
||||
"update": "Update layer"
|
||||
},
|
||||
"opacity": "Overlay opacity",
|
||||
"terrain": "Terrain source",
|
||||
"label": {
|
||||
"basemaps": "Basemaps",
|
||||
"overlays": "Overlays",
|
||||
@@ -326,8 +324,6 @@
|
||||
"usgs": "USGS",
|
||||
"bikerouterGravel": "bikerouter.de Gravel",
|
||||
"cyclOSMlite": "CyclOSM Lite",
|
||||
"mapterhornHillshade": "Mapterhorn Hillshade",
|
||||
"openRailwayMap": "OpenRailwayMap",
|
||||
"swisstopoSlope": "swisstopo Slope",
|
||||
"swisstopoHiking": "swisstopo Hiking",
|
||||
"swisstopoHikingClosures": "swisstopo Hiking Closures",
|
||||
@@ -356,7 +352,6 @@
|
||||
"water": "Water",
|
||||
"shower": "Shower",
|
||||
"shelter": "Shelter",
|
||||
"cemetery": "Cemetery",
|
||||
"motorized": "Cars and Motorcycles",
|
||||
"fuel-station": "Fuel Station",
|
||||
"parking": "Parking",
|
||||
@@ -380,9 +375,7 @@
|
||||
"railway-station": "Railway Station",
|
||||
"tram-stop": "Tram Stop",
|
||||
"bus-stop": "Bus Stop",
|
||||
"ferry": "Ferry",
|
||||
"mapbox-dem": "Mapbox DEM",
|
||||
"mapterhorn": "Mapterhorn"
|
||||
"ferry": "Ferry"
|
||||
}
|
||||
},
|
||||
"chart": {
|
||||
@@ -482,6 +475,7 @@
|
||||
"app": "App",
|
||||
"contact": "Contact",
|
||||
"reddit": "Reddit",
|
||||
"x": "X",
|
||||
"facebook": "Facebook",
|
||||
"github": "GitHub",
|
||||
"crowdin": "Crowdin",
|
||||
|
||||
@@ -21,14 +21,14 @@
|
||||
"export_all": "Alle exportieren...",
|
||||
"export_options": "Export-Einstellungen",
|
||||
"support_message": "Das Tool darf frei benutzt werden, aber es darf nicht woanders aufgesetzt werden. Bitte unterstützen Sie die Website, wenn Sie sie häufig benutzen. Vielen Dank!",
|
||||
"support_button": "Hilf uns, die Website weiterhin kostenlos bereitzustellen",
|
||||
"support_button": "Hilf dabei, die Webseite kostenlos zu belassen",
|
||||
"download_file": "Datei herunterladen",
|
||||
"download_files": "Dateien herunterladen",
|
||||
"edit": "Bearbeiten",
|
||||
"undo": "Rückgängig",
|
||||
"redo": "Wiederholen",
|
||||
"delete": "Löschen",
|
||||
"delete_all": "Alle löschen",
|
||||
"delete_all": "Delete all",
|
||||
"select_all": "Alle auswählen",
|
||||
"view": "Ansicht",
|
||||
"elevation_profile": "Höhenprofil",
|
||||
@@ -79,8 +79,7 @@
|
||||
"unhide": "Einblenden",
|
||||
"center": "Zentrieren",
|
||||
"open_in": "Öffnen in",
|
||||
"copy_coordinates": "Koordinaten kopieren",
|
||||
"edit_osm": "In OpenStreetMap bearbeiten"
|
||||
"copy_coordinates": "Koordinaten kopieren"
|
||||
},
|
||||
"toolbar": {
|
||||
"routing": {
|
||||
@@ -282,7 +281,6 @@
|
||||
"update": "Layer aktualisieren"
|
||||
},
|
||||
"opacity": "Deckkraft der Überlagerung",
|
||||
"terrain": "Geländequelle",
|
||||
"label": {
|
||||
"basemaps": "Basiskarte",
|
||||
"overlays": "Ebenen",
|
||||
@@ -326,8 +324,6 @@
|
||||
"usgs": "USGS",
|
||||
"bikerouterGravel": "bikerouter.de Gravel",
|
||||
"cyclOSMlite": "CyclOSM Lite",
|
||||
"mapterhornHillshade": "MapTiler Hillshade",
|
||||
"openRailwayMap": "OpenRailwayMap",
|
||||
"swisstopoSlope": "swisstopo Neigung",
|
||||
"swisstopoHiking": "swisstopo Wandern",
|
||||
"swisstopoHikingClosures": "swisstopo Wanderungen Schließungen",
|
||||
@@ -356,7 +352,6 @@
|
||||
"water": "Trinkwasser",
|
||||
"shower": "Dusche",
|
||||
"shelter": "Unterstand",
|
||||
"cemetery": "Friedhof",
|
||||
"motorized": "Autos und Motorräder",
|
||||
"fuel-station": "Tankstelle",
|
||||
"parking": "Parken",
|
||||
@@ -380,9 +375,7 @@
|
||||
"railway-station": "Bahnhof",
|
||||
"tram-stop": "Straßenbahnhaltestelle",
|
||||
"bus-stop": "Bushaltestelle",
|
||||
"ferry": "Fähre",
|
||||
"mapbox-dem": "Mapbox DEM",
|
||||
"mapterhorn": "Mapterhorn"
|
||||
"ferry": "Fähre"
|
||||
}
|
||||
},
|
||||
"chart": {
|
||||
@@ -478,10 +471,11 @@
|
||||
},
|
||||
"homepage": {
|
||||
"website": "Webseite",
|
||||
"home": "Startseite",
|
||||
"home": "Zuhause",
|
||||
"app": "App",
|
||||
"contact": "Kontakt",
|
||||
"reddit": "Reddit",
|
||||
"x": "X",
|
||||
"facebook": "Facebook",
|
||||
"github": "GitHub",
|
||||
"crowdin": "Crowdin",
|
||||
|
||||
@@ -79,8 +79,7 @@
|
||||
"unhide": "Unhide",
|
||||
"center": "Center",
|
||||
"open_in": "Open in",
|
||||
"copy_coordinates": "Copy coordinates",
|
||||
"edit_osm": "Edit in OpenStreetMap"
|
||||
"copy_coordinates": "Copy coordinates"
|
||||
},
|
||||
"toolbar": {
|
||||
"routing": {
|
||||
@@ -282,7 +281,6 @@
|
||||
"update": "Update layer"
|
||||
},
|
||||
"opacity": "Overlay opacity",
|
||||
"terrain": "Terrain source",
|
||||
"label": {
|
||||
"basemaps": "Basemaps",
|
||||
"overlays": "Overlays",
|
||||
@@ -326,8 +324,6 @@
|
||||
"usgs": "USGS",
|
||||
"bikerouterGravel": "bikerouter.de Gravel",
|
||||
"cyclOSMlite": "CyclOSM Lite",
|
||||
"mapterhornHillshade": "Mapterhorn Hillshade",
|
||||
"openRailwayMap": "OpenRailwayMap",
|
||||
"swisstopoSlope": "swisstopo Slope",
|
||||
"swisstopoHiking": "swisstopo Hiking",
|
||||
"swisstopoHikingClosures": "swisstopo Hiking Closures",
|
||||
@@ -356,7 +352,6 @@
|
||||
"water": "Water",
|
||||
"shower": "Shower",
|
||||
"shelter": "Shelter",
|
||||
"cemetery": "Cemetery",
|
||||
"motorized": "Cars and Motorcycles",
|
||||
"fuel-station": "Fuel Station",
|
||||
"parking": "Parking",
|
||||
@@ -380,9 +375,7 @@
|
||||
"railway-station": "Railway Station",
|
||||
"tram-stop": "Tram Stop",
|
||||
"bus-stop": "Bus Stop",
|
||||
"ferry": "Ferry",
|
||||
"mapbox-dem": "Mapbox DEM",
|
||||
"mapterhorn": "Mapterhorn"
|
||||
"ferry": "Ferry"
|
||||
}
|
||||
},
|
||||
"chart": {
|
||||
@@ -482,6 +475,7 @@
|
||||
"app": "App",
|
||||
"contact": "Contact",
|
||||
"reddit": "Reddit",
|
||||
"x": "X",
|
||||
"facebook": "Facebook",
|
||||
"github": "GitHub",
|
||||
"crowdin": "Crowdin",
|
||||
|
||||
@@ -79,8 +79,7 @@
|
||||
"unhide": "Unhide",
|
||||
"center": "Center",
|
||||
"open_in": "Open in",
|
||||
"copy_coordinates": "Copy coordinates",
|
||||
"edit_osm": "Edit in OpenStreetMap"
|
||||
"copy_coordinates": "Copy coordinates"
|
||||
},
|
||||
"toolbar": {
|
||||
"routing": {
|
||||
@@ -282,7 +281,6 @@
|
||||
"update": "Update layer"
|
||||
},
|
||||
"opacity": "Overlay opacity",
|
||||
"terrain": "Terrain source",
|
||||
"label": {
|
||||
"basemaps": "Basemaps",
|
||||
"overlays": "Overlays",
|
||||
@@ -326,8 +324,6 @@
|
||||
"usgs": "USGS",
|
||||
"bikerouterGravel": "bikerouter.de Gravel",
|
||||
"cyclOSMlite": "CyclOSM Lite",
|
||||
"mapterhornHillshade": "Mapterhorn Hillshade",
|
||||
"openRailwayMap": "OpenRailwayMap",
|
||||
"swisstopoSlope": "swisstopo Slope",
|
||||
"swisstopoHiking": "swisstopo Hiking",
|
||||
"swisstopoHikingClosures": "swisstopo Hiking Closures",
|
||||
@@ -356,7 +352,6 @@
|
||||
"water": "Water",
|
||||
"shower": "Shower",
|
||||
"shelter": "Shelter",
|
||||
"cemetery": "Cemetery",
|
||||
"motorized": "Cars and Motorcycles",
|
||||
"fuel-station": "Fuel Station",
|
||||
"parking": "Parking",
|
||||
@@ -380,9 +375,7 @@
|
||||
"railway-station": "Railway Station",
|
||||
"tram-stop": "Tram Stop",
|
||||
"bus-stop": "Bus Stop",
|
||||
"ferry": "Ferry",
|
||||
"mapbox-dem": "Mapbox DEM",
|
||||
"mapterhorn": "Mapterhorn"
|
||||
"ferry": "Ferry"
|
||||
}
|
||||
},
|
||||
"chart": {
|
||||
@@ -482,6 +475,7 @@
|
||||
"app": "App",
|
||||
"contact": "Contact",
|
||||
"reddit": "Reddit",
|
||||
"x": "X",
|
||||
"facebook": "Facebook",
|
||||
"github": "GitHub",
|
||||
"crowdin": "Crowdin",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"metadata": {
|
||||
"home_title": "el editor online de archivos GPX",
|
||||
"app_title": "app",
|
||||
"embed_title": " editor online de archivos GPX",
|
||||
"embed_title": "El 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."
|
||||
@@ -36,7 +36,7 @@
|
||||
"switch_basemap": "Cambiar al mapa base anterior",
|
||||
"toggle_overlays": "Alternar capas",
|
||||
"toggle_3d": "Alternar 3D",
|
||||
"settings": "Configuración",
|
||||
"settings": "Configuraciones",
|
||||
"distance_units": "Unidades de distancia",
|
||||
"metric": "Métrico",
|
||||
"imperial": "Imperial",
|
||||
@@ -79,8 +79,7 @@
|
||||
"unhide": "Mostrar",
|
||||
"center": "Centrar",
|
||||
"open_in": "Abrir en",
|
||||
"copy_coordinates": "Copiar coordenadas",
|
||||
"edit_osm": "Editar en OpenStreetMap"
|
||||
"copy_coordinates": "Copiar coordenadas"
|
||||
},
|
||||
"toolbar": {
|
||||
"routing": {
|
||||
@@ -282,7 +281,6 @@
|
||||
"update": "Actualizar capa"
|
||||
},
|
||||
"opacity": "Opacidad de la capa superpuesta",
|
||||
"terrain": "Origen del terreno",
|
||||
"label": {
|
||||
"basemaps": "Mapas base",
|
||||
"overlays": "Capas",
|
||||
@@ -326,8 +324,6 @@
|
||||
"usgs": "USGS",
|
||||
"bikerouterGravel": "Gravel bikerouter.de",
|
||||
"cyclOSMlite": "CyclOSM Lite",
|
||||
"mapterhornHillshade": "Mapterhorn Hillshade",
|
||||
"openRailwayMap": "OpenRailwayMap",
|
||||
"swisstopoSlope": "swisstopo Slope",
|
||||
"swisstopoHiking": "swisstopo Senderismo",
|
||||
"swisstopoHikingClosures": "swisstopo Rutas Senderismo",
|
||||
@@ -356,7 +352,6 @@
|
||||
"water": "Agua",
|
||||
"shower": "Ducha",
|
||||
"shelter": "Refugio",
|
||||
"cemetery": "Cementerio",
|
||||
"motorized": "Coches y motos",
|
||||
"fuel-station": "Gasolinera",
|
||||
"parking": "Aparcamiento",
|
||||
@@ -380,9 +375,7 @@
|
||||
"railway-station": "Estación de tren",
|
||||
"tram-stop": "Parada de tranvía",
|
||||
"bus-stop": "Parada de autobús",
|
||||
"ferry": "Ferri",
|
||||
"mapbox-dem": "Mapbox DEM",
|
||||
"mapterhorn": "Mapterhorn"
|
||||
"ferry": "Ferri"
|
||||
}
|
||||
},
|
||||
"chart": {
|
||||
@@ -482,6 +475,7 @@
|
||||
"app": "App",
|
||||
"contact": "Contacto",
|
||||
"reddit": "Reddit",
|
||||
"x": "X",
|
||||
"facebook": "Facebook",
|
||||
"github": "GitHub",
|
||||
"crowdin": "Crowdin",
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"undo": "Desegin",
|
||||
"redo": "Berregin",
|
||||
"delete": "Ezabatu",
|
||||
"delete_all": "Ezabatu guztiak",
|
||||
"delete_all": "Delete all",
|
||||
"select_all": "Hautatu dena",
|
||||
"view": "Ikusi",
|
||||
"elevation_profile": "Altuera profila",
|
||||
@@ -79,8 +79,7 @@
|
||||
"unhide": "Erakutsi",
|
||||
"center": "Erdiratu",
|
||||
"open_in": "Ireki hemen",
|
||||
"copy_coordinates": "Kopiatu koordenatuak",
|
||||
"edit_osm": "Editatu OpenStreeMapen"
|
||||
"copy_coordinates": "Kopiatu koordenatuak"
|
||||
},
|
||||
"toolbar": {
|
||||
"routing": {
|
||||
@@ -282,7 +281,6 @@
|
||||
"update": "Eguneratu geruza"
|
||||
},
|
||||
"opacity": "Geruzaren opakutasuna",
|
||||
"terrain": "Lurrazala",
|
||||
"label": {
|
||||
"basemaps": "Oinarrizko mapak",
|
||||
"overlays": "Geruzak",
|
||||
@@ -326,8 +324,6 @@
|
||||
"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",
|
||||
@@ -356,7 +352,6 @@
|
||||
"water": "Ura",
|
||||
"shower": "Dutxa",
|
||||
"shelter": "Babeslekua",
|
||||
"cemetery": "Hilerria",
|
||||
"motorized": "Kotxeak eta motorrak",
|
||||
"fuel-station": "Gasolindegia",
|
||||
"parking": "Aparkalekua",
|
||||
@@ -380,9 +375,7 @@
|
||||
"railway-station": "Tren geltokia",
|
||||
"tram-stop": "Tranbia geltokia",
|
||||
"bus-stop": "Autobus geltokia",
|
||||
"ferry": "Ferria",
|
||||
"mapbox-dem": "Mapbox DEM",
|
||||
"mapterhorn": "Mapterhorn"
|
||||
"ferry": "Ferria"
|
||||
}
|
||||
},
|
||||
"chart": {
|
||||
@@ -482,6 +475,7 @@
|
||||
"app": "App",
|
||||
"contact": "Kontaktua",
|
||||
"reddit": "Reddit",
|
||||
"x": "X",
|
||||
"facebook": "Facebook",
|
||||
"github": "GitHub",
|
||||
"crowdin": "Crowdin",
|
||||
|
||||
@@ -79,8 +79,7 @@
|
||||
"unhide": "Näytä",
|
||||
"center": "Keskitä",
|
||||
"open_in": "Avaa",
|
||||
"copy_coordinates": "Copy coordinates",
|
||||
"edit_osm": "Edit in OpenStreetMap"
|
||||
"copy_coordinates": "Copy coordinates"
|
||||
},
|
||||
"toolbar": {
|
||||
"routing": {
|
||||
@@ -282,7 +281,6 @@
|
||||
"update": "Päivitä karttataso"
|
||||
},
|
||||
"opacity": "Peitetason läpinäkyvyys",
|
||||
"terrain": "Terrain source",
|
||||
"label": {
|
||||
"basemaps": "Taustakartat",
|
||||
"overlays": "Peitetasot",
|
||||
@@ -326,8 +324,6 @@
|
||||
"usgs": "USGS",
|
||||
"bikerouterGravel": "bikerouter.de Gravel",
|
||||
"cyclOSMlite": "CyclOSM Lite",
|
||||
"mapterhornHillshade": "Mapterhorn Hillshade",
|
||||
"openRailwayMap": "OpenRailwayMap",
|
||||
"swisstopoSlope": "swisstopo Rinnekaltevuus",
|
||||
"swisstopoHiking": "swisstopo Retkeilyreitit",
|
||||
"swisstopoHikingClosures": "swisstopo Hiking Closures",
|
||||
@@ -356,7 +352,6 @@
|
||||
"water": "Water",
|
||||
"shower": "Shower",
|
||||
"shelter": "Shelter",
|
||||
"cemetery": "Cemetery",
|
||||
"motorized": "Cars and Motorcycles",
|
||||
"fuel-station": "Fuel Station",
|
||||
"parking": "Parking",
|
||||
@@ -380,9 +375,7 @@
|
||||
"railway-station": "Rautatieasemat",
|
||||
"tram-stop": "Raitiovaunupysäkit",
|
||||
"bus-stop": "Linja-autopysäkit",
|
||||
"ferry": "Lautat",
|
||||
"mapbox-dem": "Mapbox DEM",
|
||||
"mapterhorn": "Mapterhorn"
|
||||
"ferry": "Lautat"
|
||||
}
|
||||
},
|
||||
"chart": {
|
||||
@@ -482,6 +475,7 @@
|
||||
"app": "App",
|
||||
"contact": "Yhteystiedot",
|
||||
"reddit": "Reddit",
|
||||
"x": "X",
|
||||
"facebook": "Facebook",
|
||||
"github": "GitHub",
|
||||
"crowdin": "Crowdin",
|
||||
|
||||
@@ -79,8 +79,7 @@
|
||||
"unhide": "Afficher",
|
||||
"center": "Centrer",
|
||||
"open_in": "Ouvrir avec",
|
||||
"copy_coordinates": "Copier les coordonnées",
|
||||
"edit_osm": "Éditer dans OpenStreetMap"
|
||||
"copy_coordinates": "Copier les coordonnées"
|
||||
},
|
||||
"toolbar": {
|
||||
"routing": {
|
||||
@@ -282,7 +281,6 @@
|
||||
"update": "Mettre à jour la couche"
|
||||
},
|
||||
"opacity": "Opacité de la surcouche",
|
||||
"terrain": "Source du relief",
|
||||
"label": {
|
||||
"basemaps": "Fonds de carte",
|
||||
"overlays": "Surcouches",
|
||||
@@ -326,8 +324,6 @@
|
||||
"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",
|
||||
@@ -353,10 +349,9 @@
|
||||
"eat-and-drink": "Nourriture et boissons",
|
||||
"amenities": "Commodités",
|
||||
"toilets": "Toilettes",
|
||||
"water": "Eau potable",
|
||||
"water": "Cours d'eau",
|
||||
"shower": "Douche",
|
||||
"shelter": "Abri",
|
||||
"cemetery": "Cimetière",
|
||||
"motorized": "Voitures et motos",
|
||||
"fuel-station": "Station-service",
|
||||
"parking": "Parking",
|
||||
@@ -380,9 +375,7 @@
|
||||
"railway-station": "Gare",
|
||||
"tram-stop": "Arrêt de tram",
|
||||
"bus-stop": "Arrêt de bus",
|
||||
"ferry": "Ferry",
|
||||
"mapbox-dem": "Mapbox DEM",
|
||||
"mapterhorn": "Mapterhorn"
|
||||
"ferry": "Ferry"
|
||||
}
|
||||
},
|
||||
"chart": {
|
||||
@@ -448,7 +441,7 @@
|
||||
"convenience_store": "Épicerie",
|
||||
"crossing": "Croisement",
|
||||
"department_store": "Grand magasin",
|
||||
"drinking_water": "Eau potable",
|
||||
"drinking_water": "Cours d'eau",
|
||||
"exit": "Sortie",
|
||||
"lodge": "Refuge",
|
||||
"lodging": "Hébergement",
|
||||
@@ -473,7 +466,7 @@
|
||||
"summit": "Sommet",
|
||||
"telephone": "Téléphone",
|
||||
"tunnel": "Tunnel",
|
||||
"water_source": "Point d'eau"
|
||||
"water_source": "Source d'eau"
|
||||
}
|
||||
},
|
||||
"homepage": {
|
||||
@@ -482,6 +475,7 @@
|
||||
"app": "Application",
|
||||
"contact": "Contact",
|
||||
"reddit": "Reddit",
|
||||
"x": "X",
|
||||
"facebook": "Facebook",
|
||||
"github": "GitHub",
|
||||
"crowdin": "Crowdin",
|
||||
|
||||
@@ -79,8 +79,7 @@
|
||||
"unhide": "Unhide",
|
||||
"center": "Center",
|
||||
"open_in": "Open in",
|
||||
"copy_coordinates": "Copy coordinates",
|
||||
"edit_osm": "Edit in OpenStreetMap"
|
||||
"copy_coordinates": "Copy coordinates"
|
||||
},
|
||||
"toolbar": {
|
||||
"routing": {
|
||||
@@ -282,7 +281,6 @@
|
||||
"update": "Update layer"
|
||||
},
|
||||
"opacity": "Overlay opacity",
|
||||
"terrain": "Terrain source",
|
||||
"label": {
|
||||
"basemaps": "Basemaps",
|
||||
"overlays": "Overlays",
|
||||
@@ -326,8 +324,6 @@
|
||||
"usgs": "USGS",
|
||||
"bikerouterGravel": "bikerouter.de Gravel",
|
||||
"cyclOSMlite": "CyclOSM Lite",
|
||||
"mapterhornHillshade": "Mapterhorn Hillshade",
|
||||
"openRailwayMap": "OpenRailwayMap",
|
||||
"swisstopoSlope": "swisstopo Slope",
|
||||
"swisstopoHiking": "swisstopo Hiking",
|
||||
"swisstopoHikingClosures": "swisstopo Hiking Closures",
|
||||
@@ -356,7 +352,6 @@
|
||||
"water": "Water",
|
||||
"shower": "גשם",
|
||||
"shelter": "Shelter",
|
||||
"cemetery": "Cemetery",
|
||||
"motorized": "Cars and Motorcycles",
|
||||
"fuel-station": "Fuel Station",
|
||||
"parking": "Parking",
|
||||
@@ -380,9 +375,7 @@
|
||||
"railway-station": "Railway Station",
|
||||
"tram-stop": "Tram Stop",
|
||||
"bus-stop": "Bus Stop",
|
||||
"ferry": "Ferry",
|
||||
"mapbox-dem": "Mapbox DEM",
|
||||
"mapterhorn": "Mapterhorn"
|
||||
"ferry": "Ferry"
|
||||
}
|
||||
},
|
||||
"chart": {
|
||||
@@ -482,6 +475,7 @@
|
||||
"app": "App",
|
||||
"contact": "Contact",
|
||||
"reddit": "Reddit",
|
||||
"x": "X",
|
||||
"facebook": "Facebook",
|
||||
"github": "GitHub",
|
||||
"crowdin": "Crowdin",
|
||||
|
||||
@@ -79,8 +79,7 @@
|
||||
"unhide": "Felfedés ",
|
||||
"center": "Középre ",
|
||||
"open_in": "Megnyitás itt ",
|
||||
"copy_coordinates": "Koordináták másolása",
|
||||
"edit_osm": "Edit in OpenStreetMap"
|
||||
"copy_coordinates": "Koordináták másolása"
|
||||
},
|
||||
"toolbar": {
|
||||
"routing": {
|
||||
@@ -235,13 +234,13 @@
|
||||
"help_no_selection": "Select a file item to request elevation data."
|
||||
},
|
||||
"waypoint": {
|
||||
"tooltip": "PoI létrehozása és módosítása",
|
||||
"icon": "Ikon",
|
||||
"tooltip": "Create and edit points of interest",
|
||||
"icon": "Icon",
|
||||
"link": "Link",
|
||||
"longitude": "Földrajzi hosszúság",
|
||||
"longitude": "Longitude",
|
||||
"latitude": "Szélesség",
|
||||
"create": "POI fájlba mentése",
|
||||
"add": "PoI fájlhoz adása",
|
||||
"add": "Add point of interest to file",
|
||||
"help": "Töltsd ki az űrlapot egy új POI létrehozásához, vagy kattints egy meglévőre a szerkesztéshez. Kattints a térképre a koordináták megadásához, vagy áthelyezéshez egérrel húzd el a POI-kat.",
|
||||
"help_no_selection": "Select a file to create or edit points of interest."
|
||||
},
|
||||
@@ -249,8 +248,8 @@
|
||||
"tooltip": "Reduce the number of GPS points",
|
||||
"tolerance": "Tűréshatár",
|
||||
"number_of_points": "GPS pontok száma",
|
||||
"button": "Minimalizálás",
|
||||
"help": "",
|
||||
"button": "Minify",
|
||||
"help": "Use the slider to choose the number of GPS points to keep.",
|
||||
"help_no_selection": "Select a trace to reduce the number of its GPS points."
|
||||
},
|
||||
"clean": {
|
||||
@@ -282,7 +281,6 @@
|
||||
"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",
|
||||
@@ -326,8 +324,6 @@
|
||||
"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",
|
||||
@@ -356,7 +352,6 @@
|
||||
"water": "Víz",
|
||||
"shower": "Zuhanyozó",
|
||||
"shelter": "Menedék",
|
||||
"cemetery": "Cemetery",
|
||||
"motorized": "Autók és Motorok",
|
||||
"fuel-station": "Benzinkút",
|
||||
"parking": "Parkoló",
|
||||
@@ -380,9 +375,7 @@
|
||||
"railway-station": "Vasútállomás",
|
||||
"tram-stop": "Villamos megálló",
|
||||
"bus-stop": "Buszmegálló",
|
||||
"ferry": "Komp",
|
||||
"mapbox-dem": "Mapbox DEM",
|
||||
"mapterhorn": "Mapterhorn"
|
||||
"ferry": "Komp"
|
||||
}
|
||||
},
|
||||
"chart": {
|
||||
@@ -482,6 +475,7 @@
|
||||
"app": "App",
|
||||
"contact": "Kapcsolat",
|
||||
"reddit": "Reddit",
|
||||
"x": "X",
|
||||
"facebook": "Facebook",
|
||||
"github": "GitHub",
|
||||
"crowdin": "Crowdin",
|
||||
|
||||
@@ -79,8 +79,7 @@
|
||||
"unhide": "Tampilkan",
|
||||
"center": "Tengah",
|
||||
"open_in": "Buka di",
|
||||
"copy_coordinates": "Salin koordinat",
|
||||
"edit_osm": "Edit in OpenStreetMap"
|
||||
"copy_coordinates": "Salin koordinat"
|
||||
},
|
||||
"toolbar": {
|
||||
"routing": {
|
||||
@@ -282,7 +281,6 @@
|
||||
"update": "Perbarui lapisan"
|
||||
},
|
||||
"opacity": "Opasitas Overlay",
|
||||
"terrain": "Terrain source",
|
||||
"label": {
|
||||
"basemaps": "Peta dasar",
|
||||
"overlays": "Overlay",
|
||||
@@ -326,8 +324,6 @@
|
||||
"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",
|
||||
@@ -356,7 +352,6 @@
|
||||
"water": "Air",
|
||||
"shower": "Mandi",
|
||||
"shelter": "Penampungan",
|
||||
"cemetery": "Cemetery",
|
||||
"motorized": "Mobil dan Motor",
|
||||
"fuel-station": "Stasiun bahan bakar",
|
||||
"parking": "Parkir",
|
||||
@@ -380,9 +375,7 @@
|
||||
"railway-station": "Stasiun kereta api",
|
||||
"tram-stop": "Halt trem",
|
||||
"bus-stop": "Pemberhentian Bus",
|
||||
"ferry": "Feri",
|
||||
"mapbox-dem": "Mapbox DEM",
|
||||
"mapterhorn": "Mapterhorn"
|
||||
"ferry": "Feri"
|
||||
}
|
||||
},
|
||||
"chart": {
|
||||
@@ -482,6 +475,7 @@
|
||||
"app": "App",
|
||||
"contact": "Contact",
|
||||
"reddit": "Reddit",
|
||||
"x": "X",
|
||||
"facebook": "Facebook",
|
||||
"github": "GitHub",
|
||||
"crowdin": "Crowdin",
|
||||
@@ -491,7 +485,7 @@
|
||||
"support_button": "Support gpx.studio on Ko-fi",
|
||||
"route_planning": "Route planning",
|
||||
"route_planning_description": "An intuitive interface to create itineraries tailored to each sport, based on OpenStreetMap data.",
|
||||
"file_processing": "Pengolahan file lanjutan",
|
||||
"file_processing": "Advanced file processing",
|
||||
"file_processing_description": "A suite of tools for performing all common file processing tasks, and which can be applied to multiple files at once.",
|
||||
"maps": "Global and local maps",
|
||||
"maps_description": "A large collection of basemaps, overlays and points of interest to help you craft your next outdoor adventure, or visualize your latest achievement.",
|
||||
|
||||
@@ -28,14 +28,14 @@
|
||||
"undo": "Annulla",
|
||||
"redo": "Ripeti",
|
||||
"delete": "Elimina",
|
||||
"delete_all": "Cancella tutto",
|
||||
"delete_all": "Delete all",
|
||||
"select_all": "Seleziona tutto",
|
||||
"view": "Visualizza",
|
||||
"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": "Vista stradale",
|
||||
"toggle_street_view": "Street View",
|
||||
"layers": "Livelli della mappa...",
|
||||
"distance_markers": "Indicatori di distanza",
|
||||
"direction_markers": "Frecce direzionali",
|
||||
@@ -79,15 +79,14 @@
|
||||
"unhide": "Mostra",
|
||||
"center": "Centra",
|
||||
"open_in": "Apri con",
|
||||
"copy_coordinates": "Copia le coordinate",
|
||||
"edit_osm": "Modifica in OpenStreetMap"
|
||||
"copy_coordinates": "Copia le coordinate"
|
||||
},
|
||||
"toolbar": {
|
||||
"routing": {
|
||||
"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 +234,18 @@
|
||||
"help_no_selection": "Seleziona un file per richiedere i dati di altitudine."
|
||||
},
|
||||
"waypoint": {
|
||||
"tooltip": "Creare e modificare punti d'interesse",
|
||||
"tooltip": "Creare e modificare punti di interesse",
|
||||
"icon": "Icona",
|
||||
"link": "Collegamento",
|
||||
"longitude": "Longitudine",
|
||||
"latitude": "Latitudine",
|
||||
"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."
|
||||
"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."
|
||||
},
|
||||
"reduce": {
|
||||
"tooltip": "Riduci il numero di punti GPS",
|
||||
"tooltip": "Riduci il numero di punti della traccia",
|
||||
"tolerance": "Tolleranza",
|
||||
"number_of_points": "Numero di punti GPS",
|
||||
"button": "Minimizza",
|
||||
@@ -254,14 +253,14 @@
|
||||
"help_no_selection": "Selezionare una traccia per ridurre il numero dei suoi punti GPS."
|
||||
},
|
||||
"clean": {
|
||||
"tooltip": "Pulire i punti GPS e i punti d'interesse con una selezione rettangolare",
|
||||
"tooltip": "Pulire i punti GPS e i punti di 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 d'interesse.",
|
||||
"help_no_selection": "Seleziona una traccia per pulire i punti GPS e i punti d'interesse."
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"layers": {
|
||||
@@ -273,7 +272,7 @@
|
||||
"new": "Nuovo livello personalizzato",
|
||||
"edit": "Modifica livello personalizzato",
|
||||
"urls": "URL(s)",
|
||||
"url_placeholder": "WMTS, WMS o JSON in stile Mapbox",
|
||||
"url_placeholder": "WMTS, WMS o Mapbox stile JSON",
|
||||
"max_zoom": "Zoom massimo",
|
||||
"layer_type": "Tipo del layer",
|
||||
"basemap": "Mappa Base",
|
||||
@@ -282,7 +281,6 @@
|
||||
"update": "Aggiorna livello"
|
||||
},
|
||||
"opacity": "Opacità di sovrapposizione",
|
||||
"terrain": "Terrain source",
|
||||
"label": {
|
||||
"basemaps": "Mappe di base",
|
||||
"overlays": "Sovrapposizioni",
|
||||
@@ -310,7 +308,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",
|
||||
@@ -319,27 +317,25 @@
|
||||
"ignFrSatellite": "Satellitare IGN",
|
||||
"ignEs": "IGN Topo",
|
||||
"ignEsSatellite": "Satellitare IGN",
|
||||
"ordnanceSurvey": "Ordnance Survey",
|
||||
"ordnanceSurvey": "Sondaggio Ordnance",
|
||||
"norwayTopo": "Topografisk Norgeskart 4",
|
||||
"finlandTopo": "Lantmäteriverket Terrängkarta",
|
||||
"finlandTopo": "Carta topografica del vecchio Catasto svedese",
|
||||
"bgMountains": "BGMountains",
|
||||
"usgs": "USGS",
|
||||
"bikerouterGravel": "bikerouter.de Gravel",
|
||||
"cyclOSMlite": "CyclOSM Lite",
|
||||
"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",
|
||||
"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",
|
||||
"ignFrCadastre": "IGN Catasto",
|
||||
"ignSlope": "IGN Pendenza",
|
||||
"ignSkiTouring": "IGN Sci Alpinismo",
|
||||
"waymarked_trails": "Sentieri Segnalati",
|
||||
"ignSlope": "Pendenza IGN",
|
||||
"ignSkiTouring": "IGN Sciescursionismo",
|
||||
"waymarked_trails": "Waymarked Trails",
|
||||
"waymarkedTrailsHiking": "Escursionismo",
|
||||
"waymarkedTrailsCycling": "Ciclismo",
|
||||
"waymarkedTrailsMTB": "MTB",
|
||||
@@ -356,7 +352,6 @@
|
||||
"water": "Acqua",
|
||||
"shower": "Doccia",
|
||||
"shelter": "Riparo",
|
||||
"cemetery": "Cimitero",
|
||||
"motorized": "Auto e Motocicli",
|
||||
"fuel-station": "Stazione di Rifornimento",
|
||||
"parking": "Parcheggio",
|
||||
@@ -380,9 +375,7 @@
|
||||
"railway-station": "Stazione ferroviaria",
|
||||
"tram-stop": "Fermata del tram",
|
||||
"bus-stop": "Fermata dell'autobus",
|
||||
"ferry": "Traghetto",
|
||||
"mapbox-dem": "Mapbox DEM",
|
||||
"mapterhorn": "Mapterhorn"
|
||||
"ferry": "Traghetto"
|
||||
}
|
||||
},
|
||||
"chart": {
|
||||
@@ -411,11 +404,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": "mi/h",
|
||||
"miles_per_hour": "mph",
|
||||
"minutes_per_kilometer": "min/km",
|
||||
"minutes_per_mile": "min/mi",
|
||||
"minutes_per_nautical_mile": "min/nm",
|
||||
@@ -431,8 +424,8 @@
|
||||
"tracks": "Tracce",
|
||||
"segment": "Segmento",
|
||||
"segments": "Segmenti",
|
||||
"waypoint": "Punto d'interesse",
|
||||
"waypoints": "Punti d'interesse",
|
||||
"waypoint": "Punto di interesse",
|
||||
"waypoints": "Punti di interesse",
|
||||
"symbol": {
|
||||
"alert": "Avviso",
|
||||
"anchor": "Ancora",
|
||||
@@ -482,19 +475,20 @@
|
||||
"app": "App",
|
||||
"contact": "Contatto",
|
||||
"reddit": "Reddit",
|
||||
"x": "X",
|
||||
"facebook": "Facebook",
|
||||
"github": "GitHub",
|
||||
"crowdin": "Crowdin",
|
||||
"email": "Email",
|
||||
"contribute": "Contribuire",
|
||||
"supported_by": "supportato da",
|
||||
"support_button": "Supporta gpx.studio su Ko-fi",
|
||||
"support_button": "Supporto di gpx.studio su Ko-fi",
|
||||
"route_planning": "Pianificazione del percorso",
|
||||
"route_planning_description": "Un'interfaccia intuitiva per creare itinerari su misura per ogni sport, basata sui dati OpenStreetMap.",
|
||||
"route_planning_description": "Un'interfaccia intuitiva per creare itinerari su misura per ogni sport, basati 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 la tua ultima impresa.",
|
||||
"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.",
|
||||
"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",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user