Merge branch 'dev' into elevation-tool

This commit is contained in:
vcoppe
2024-09-04 15:18:44 +02:00
609 changed files with 76321 additions and 6965 deletions

View File

@@ -36,7 +36,7 @@ jobs:
- name: Build website
env:
BASE_PATH: '/${{ github.event.repository.name }}'
BASE_PATH: ''
run: |
npm run build --prefix website

View File

@@ -3,11 +3,11 @@
<img alt="Logo of gpx.studio." src="website/static/logo.svg">
</picture>
**gpx.studio** is an online tool for creating and editing GPX files.
[**gpx.studio**](https://gpx.studio) is an online tool for creating and editing GPX files.
![gpx.studio screenshot](website/src/lib/assets/img/docs/getting-started/interface.png)
This repository contains the source code of the new website, currently available [here](https://gpx.studio/gpx.studio).
This repository contains the source code of the website.
## Contributing
@@ -17,8 +17,6 @@ Code contributions are also welcome, but except for obvious bug fixes, please op
## Translation
***Translations for the new website will start once the new version is stable.***
The website is translated by volunteers on a collaborative translation platform.
You can help complete and improve the translations by joining the [Crowdin project](https://crowdin.com/project/gpxstudio).
If you would like to start the translation in a new language, please contact me or create an issue.
@@ -54,7 +52,7 @@ npm run dev
## Credits
This project has been made possible thanks to the following open-source projects:
This project has been made possible thanks to the following open source projects:
- Development:
- [Svelte](https://github.com/sveltejs/svelte) and [SvelteKit](https://github.com/sveltejs/kit) — seamless development experience

5
crowdin.yml Normal file
View File

@@ -0,0 +1,5 @@
files:
- source: /website/src/locales/en.json
translation: /website/src/locales/%two_letters_code%.json
- source: /website/src/lib/docs/en/**/*.mdx
translation: /website/src/lib/docs/%two_letters_code%/**/%original_file_name%

View File

@@ -1,198 +0,0 @@
/**
* For a detailed explanation regarding each configuration property, visit:
* https://jestjs.io/docs/configuration
*/
/** @type {import('jest').Config} */
const config = {
// All imported modules in your tests should be mocked automatically
// automock: false,
// Stop running tests after `n` failures
// bail: 0,
// The directory where Jest should store its cached dependency information
// cacheDirectory: "/private/var/folders/hf/0lj0fwd15m55qqlzd3d29mtw0000gp/T/jest_dy",
// Automatically clear mock calls, instances, contexts and results before every test
// clearMocks: false,
// Indicates whether the coverage information should be collected while executing the test
// collectCoverage: false,
// An array of glob patterns indicating a set of files for which coverage information should be collected
// collectCoverageFrom: undefined,
// The directory where Jest should output its coverage files
// coverageDirectory: undefined,
// An array of regexp pattern strings used to skip coverage collection
// coveragePathIgnorePatterns: [
// "/node_modules/"
// ],
// Indicates which provider should be used to instrument code for coverage
coverageProvider: "v8",
// A list of reporter names that Jest uses when writing coverage reports
// coverageReporters: [
// "json",
// "text",
// "lcov",
// "clover"
// ],
// An object that configures minimum threshold enforcement for coverage results
// coverageThreshold: undefined,
// A path to a custom dependency extractor
// dependencyExtractor: undefined,
// Make calling deprecated APIs throw helpful error messages
// errorOnDeprecated: false,
// The default configuration for fake timers
// fakeTimers: {
// "enableGlobally": false
// },
// Force coverage collection from ignored files using an array of glob patterns
// forceCoverageMatch: [],
// A path to a module which exports an async function that is triggered once before all test suites
// globalSetup: undefined,
// A path to a module which exports an async function that is triggered once after all test suites
// globalTeardown: undefined,
// A set of global variables that need to be available in all test environments
// globals: {},
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
// maxWorkers: "50%",
// An array of directory names to be searched recursively up from the requiring module's location
// moduleDirectories: [
// "node_modules"
// ],
// An array of file extensions your modules use
// moduleFileExtensions: [
// "js",
// "mjs",
// "cjs",
// "jsx",
// "ts",
// "tsx",
// "json",
// "node"
// ],
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
// moduleNameMapper: {},
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
// modulePathIgnorePatterns: [],
// Activates notifications for test results
// notify: false,
// An enum that specifies notification mode. Requires { notify: true }
// notifyMode: "failure-change",
// A preset that is used as a base for Jest's configuration
preset: "ts-jest",
// Run tests from one or more projects
// projects: undefined,
// Use this configuration option to add custom reporters to Jest
// reporters: undefined,
// Automatically reset mock state before every test
// resetMocks: false,
// Reset the module registry before running each individual test
// resetModules: false,
// A path to a custom resolver
// resolver: undefined,
// Automatically restore mock state and implementation before every test
// restoreMocks: false,
// The root directory that Jest should scan for tests and modules within
rootDir: "test",
// A list of paths to directories that Jest should use to search for files in
// roots: [
// "<rootDir>"
// ],
// Allows you to use a custom runner instead of Jest's default test runner
// runner: "jest-runner",
// The paths to modules that run some code to configure or set up the testing environment before each test
// setupFiles: [],
// A list of paths to modules that run some code to configure or set up the testing framework before each test
// setupFilesAfterEnv: [],
// The number of seconds after which a test is considered as slow and reported as such in the results.
// slowTestThreshold: 5,
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
// snapshotSerializers: [],
// The test environment that will be used for testing
// testEnvironment: "jest-environment-node",
// Options that will be passed to the testEnvironment
// testEnvironmentOptions: {},
// Adds a location field to test results
// testLocationInResults: false,
// The glob patterns Jest uses to detect test files
// testMatch: [
// "**/__tests__/**/*.[jt]s?(x)",
// "**/?(*.)+(spec|test).[tj]s?(x)"
// ],
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
// testPathIgnorePatterns: [
// "/node_modules/"
// ],
// The regexp pattern or array of patterns that Jest uses to detect test files
// testRegex: [],
// This option allows the use of a custom results processor
// testResultsProcessor: undefined,
// This option allows use of a custom test runner
// testRunner: "jest-circus/runner",
// A map from regular expressions to paths to transformers
// transform: undefined,
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
// transformIgnorePatterns: [
// "/node_modules/",
// "\\.pnp\\.[^\\/]+$"
// ],
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
// unmockedModulePathPatterns: undefined,
// Indicates whether each individual test should be reported during the run
// verbose: undefined,
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
// watchPathIgnorePatterns: [],
// Whether to use watchman for file crawling
// watchman: true,
};
export default config;

3615
gpx/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,8 +2,8 @@
"name": "gpx",
"version": "1.0.0",
"type": "module",
"exports": "./dist/src/index.js",
"types": "dist/src/index.d.ts",
"exports": "./dist/index.js",
"types": "dist/index.d.ts",
"repository": {
"type": "git",
"url": "https://github.com/gpxstudio/gpx.studio.git",
@@ -16,15 +16,11 @@
"ts-node": "^10.9.2"
},
"scripts": {
"build": "tsc",
"test": "jest"
"build": "tsc"
},
"devDependencies": {
"@types/geojson": "^7946.0.14",
"@types/jest": "^29.5.12",
"@types/node": "^20.14.6",
"jest": "^29.7.0",
"ts-jest": "^29.1.5",
"typescript": "^5.4.5"
}
}

View File

@@ -117,7 +117,15 @@ export class GPXFile extends GPXTreeNode<Track>{
super();
if (gpx) {
this.attributes = gpx.attributes
this.metadata = gpx.metadata;
this.metadata = gpx.metadata ?? {};
this.metadata.author = {
name: 'gpx.studio',
link: {
attributes: {
href: 'https://gpx.studio',
}
}
};
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) {
@@ -125,6 +133,23 @@ export class GPXFile extends GPXTreeNode<Track>{
}
if (gpx.hasOwnProperty('_data')) {
this._data = gpx._data;
if (!this._data.hasOwnProperty('style')) {
let style = this.getStyle();
let fileStyle = {};
if (style.color.length === 1) {
fileStyle['color'] = style.color[0];
}
if (style.weight.length === 1) {
fileStyle['weight'] = style.weight[0];
}
if (style.opacity.length === 1) {
fileStyle['opacity'] = style.opacity[0];
}
if (Object.keys(fileStyle).length > 0) {
this.setStyle(fileStyle);
}
}
}
} else {
this.attributes = {};
@@ -200,14 +225,32 @@ export class GPXFile extends GPXTreeNode<Track>{
};
}
toGPXFileType(): GPXFileType {
return {
toGPXFileType(exclude: string[] = []): GPXFileType {
let file: GPXFileType = {
attributes: cloneJSON(this.attributes),
metadata: cloneJSON(this.metadata),
wpt: this.wpt,
trk: this.trk.map((track) => track.toTrackType()),
metadata: {},
wpt: this.wpt.map((wpt) => wpt.toWaypointType(exclude)),
trk: this.trk.map((track) => track.toTrackType(exclude)),
rte: [],
};
if (this.metadata) {
if (this.metadata.name) {
file.metadata.name = this.metadata.name;
}
if (this.metadata.desc) {
file.metadata.desc = this.metadata.desc;
}
if (this.metadata.author) {
file.metadata.author = cloneJSON(this.metadata.author);
}
if (this.metadata.link) {
file.metadata.link = cloneJSON(this.metadata.link);
}
if (this.metadata.time && !exclude.includes('time')) {
file.metadata.time = this.metadata.time;
}
}
return file;
}
// Producers
@@ -320,6 +363,15 @@ export class GPXFile extends GPXTreeNode<Track>{
});
}
createArtificialTimestamps(startTime: Date, totalTime: number, trackIndex?: number, segmentIndex?: number) {
let lastPoint = undefined;
this.trk.forEach((track, index) => {
if (trackIndex === undefined || trackIndex === index) {
track.createArtificialTimestamps(startTime, totalTime, lastPoint, segmentIndex);
}
});
}
addElevation(callback: (Coordinates) => number, trackIndices?: number[], segmentIndices?: number[], waypointIndices?: number[]) {
let og = getOriginal(this); // Read as much as possible from the original object because it is faster
this.trk.forEach((track, trackIndex) => {
@@ -348,7 +400,7 @@ export class GPXFile extends GPXTreeNode<Track>{
this._data.style = {};
}
if (style.color) {
this._data.style.color = style.color;
this._data.style.color = style.color.replace('#', '');
}
if (style.opacity) {
this._data.style.opacity = style.opacity;
@@ -412,8 +464,8 @@ export class Track extends GPXTreeNode<TrackSegment> {
src?: string;
link?: Link;
type?: string;
trkseg: TrackSegment[];
extensions?: TrackExtensions;
trkseg: TrackSegment[];
constructor(track?: TrackType & { _data?: any } | Track) {
super();
@@ -446,14 +498,23 @@ export class Track extends GPXTreeNode<TrackSegment> {
src: this.src,
link: cloneJSON(this.link),
type: this.type,
trkseg: this.trkseg.map((seg) => seg.clone()),
extensions: cloneJSON(this.extensions),
trkseg: this.trkseg.map((seg) => seg.clone()),
_data: cloneJSON(this._data),
});
}
getStyle(): LineStyleExtension | undefined {
return this.extensions && this.extensions['gpx_style:line'];
if (this.extensions && this.extensions['gpx_style:line']) {
if (this.extensions["gpx_style:line"].color) {
return {
...this.extensions["gpx_style:line"],
color: `#${this.extensions["gpx_style:line"].color}`
}
}
return this.extensions['gpx_style:line'];
}
return undefined;
}
toGeoJSON(): GeoJSON.Feature[] {
@@ -461,7 +522,7 @@ export class Track extends GPXTreeNode<TrackSegment> {
let geoJSON = child.toGeoJSON();
if (this.extensions && this.extensions['gpx_style:line']) {
if (this.extensions['gpx_style:line'].color) {
geoJSON.properties['color'] = this.extensions['gpx_style:line'].color;
geoJSON.properties['color'] = `#${this.extensions['gpx_style:line'].color}`;
}
if (this.extensions['gpx_style:line'].opacity) {
geoJSON.properties['opacity'] = this.extensions['gpx_style:line'].opacity;
@@ -474,7 +535,7 @@ export class Track extends GPXTreeNode<TrackSegment> {
});
}
toTrackType(): TrackType {
toTrackType(exclude: string[] = []): TrackType {
return {
name: this.name,
cmt: this.cmt,
@@ -482,8 +543,8 @@ export class Track extends GPXTreeNode<TrackSegment> {
src: this.src,
link: this.link,
type: this.type,
trkseg: this.trkseg.map((seg) => seg.toTrackSegmentType()),
extensions: this.extensions,
trkseg: this.trkseg.map((seg) => seg.toTrackSegmentType(exclude)),
};
}
@@ -562,6 +623,17 @@ export class Track extends GPXTreeNode<TrackSegment> {
});
}
createArtificialTimestamps(startTime: Date, totalTime: number, lastPoint: TrackPoint | undefined, segmentIndex?: number) {
this.trkseg.forEach((segment, index) => {
if (segmentIndex === undefined || segmentIndex === index) {
segment.createArtificialTimestamps(startTime, totalTime, lastPoint);
if (segment.trkpt.length > 0) {
lastPoint = segment.trkpt[segment.trkpt.length - 1];
}
}
});
}
setStyle(style: LineStyleExtension, force: boolean = true) {
if (!this.extensions) {
this.extensions = {};
@@ -570,7 +642,7 @@ export class Track extends GPXTreeNode<TrackSegment> {
this.extensions['gpx_style:line'] = {};
}
if (style.color !== undefined && (force || this.extensions['gpx_style:line'].color === undefined)) {
this.extensions['gpx_style:line'].color = style.color;
this.extensions['gpx_style:line'].color = style.color.replace('#', '');
}
if (style.opacity !== undefined && (force || this.extensions['gpx_style:line'].opacity === undefined)) {
this.extensions['gpx_style:line'].opacity = style.opacity;
@@ -663,7 +735,7 @@ export class TrackSegment extends GPXTreeLeaf {
const time = (points[i].time.getTime() - points[i - 1].time.getTime()) / 1000;
speed = dist / (time / 3600);
if (speed >= 0.5) {
if (speed >= 0.5 && speed <= 1500) {
statistics.global.distance.moving += dist;
statistics.global.time.moving += time;
}
@@ -677,6 +749,30 @@ export class TrackSegment extends GPXTreeLeaf {
statistics.global.bounds.southWest.lon = Math.min(statistics.global.bounds.southWest.lon, points[i].attributes.lon);
statistics.global.bounds.northEast.lat = Math.max(statistics.global.bounds.northEast.lat, points[i].attributes.lat);
statistics.global.bounds.northEast.lon = Math.max(statistics.global.bounds.northEast.lon, points[i].attributes.lon);
// extensions
if (points[i].extensions) {
if (points[i].extensions["gpxtpx:TrackPointExtension"] && points[i].extensions["gpxtpx:TrackPointExtension"]["gpxtpx:atemp"]) {
let atemp = points[i].extensions["gpxtpx:TrackPointExtension"]["gpxtpx:atemp"];
statistics.global.atemp.avg = (statistics.global.atemp.count * statistics.global.atemp.avg + atemp) / (statistics.global.atemp.count + 1);
statistics.global.atemp.count++;
}
if (points[i].extensions["gpxtpx:TrackPointExtension"] && points[i].extensions["gpxtpx:TrackPointExtension"]["gpxtpx:hr"]) {
let hr = points[i].extensions["gpxtpx:TrackPointExtension"]["gpxtpx:hr"];
statistics.global.hr.avg = (statistics.global.hr.count * statistics.global.hr.avg + hr) / (statistics.global.hr.count + 1);
statistics.global.hr.count++;
}
if (points[i].extensions["gpxtpx:TrackPointExtension"] && points[i].extensions["gpxtpx:TrackPointExtension"]["gpxtpx:cad"]) {
let cad = points[i].extensions["gpxtpx:TrackPointExtension"]["gpxtpx:cad"];
statistics.global.cad.avg = (statistics.global.cad.count * statistics.global.cad.avg + cad) / (statistics.global.cad.count + 1);
statistics.global.cad.count++;
}
if (points[i].extensions["gpxpx:PowerExtension"] && points[i].extensions["gpxpx:PowerExtension"]["gpxpx:PowerInWatts"]) {
let power = points[i].extensions["gpxpx:PowerExtension"]["gpxpx:PowerInWatts"];
statistics.global.power.avg = (statistics.global.power.count * statistics.global.power.avg + power) / (statistics.global.power.count + 1);
statistics.global.power.count++;
}
}
}
[statistics.local.slope.segment, statistics.local.slope.length] = this._computeSlopeSegments(statistics);
@@ -741,7 +837,7 @@ export class TrackSegment extends GPXTreeLeaf {
let start = simplified[i].point._data.index;
let end = simplified[i + 1].point._data.index;
let dist = statistics.local.distance.total[end] - statistics.local.distance.total[start];
let ele = simplified[i + 1].point.ele - simplified[i].point.ele;
let ele = (simplified[i + 1].point.ele ?? 0) - (simplified[i].point.ele ?? 0);
for (let j = start; j < end + (i + 1 === simplified.length - 1 ? 1 : 0); j++) {
slope.push(0.1 * ele / dist);
@@ -793,9 +889,9 @@ export class TrackSegment extends GPXTreeLeaf {
};
}
toTrackSegmentType(): TrackSegmentType {
toTrackSegmentType(exclude: string[] = []): TrackSegmentType {
return {
trkpt: this.trkpt.map((point) => point.toTrackPointType())
trkpt: this.trkpt.map((point) => point.toTrackPointType(exclude))
};
}
@@ -905,6 +1001,14 @@ export class TrackSegment extends GPXTreeLeaf {
this.trkpt = freeze(trkpt); // Pre-freeze the array, faster as well
}
}
createArtificialTimestamps(startTime: Date, totalTime: number, lastPoint: TrackPoint | undefined) {
let og = getOriginal(this); // Read as much as possible from the original object because it is faster
let slope = og._computeSlope();
let trkpt = withArtificialTimestamps(og.trkpt, totalTime, lastPoint, startTime, slope);
this.trkpt = freeze(trkpt); // Pre-freeze the array, faster as well
}
setHidden(hidden: boolean) {
this._data.hidden = hidden;
}
@@ -945,6 +1049,10 @@ export class TrackPoint {
return this.attributes.lon;
}
getTemperature(): number {
return this.extensions && this.extensions['gpxtpx:TrackPointExtension'] && this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:atemp'] ? this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:atemp'] : undefined;
}
getHeartRate(): number {
return this.extensions && this.extensions['gpxtpx:TrackPointExtension'] && this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:hr'] ? this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:hr'] : undefined;
}
@@ -953,10 +1061,6 @@ export class TrackPoint {
return this.extensions && this.extensions['gpxtpx:TrackPointExtension'] && this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:cad'] ? this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:cad'] : undefined;
}
getTemperature(): number {
return this.extensions && this.extensions['gpxtpx:TrackPointExtension'] && this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:atemp'] ? this.extensions['gpxtpx:TrackPointExtension']['gpxtpx:atemp'] : undefined;
}
getPower(): number {
return this.extensions && this.extensions["gpxpx:PowerExtension"] && this.extensions["gpxpx:PowerExtension"]["gpxpx:PowerInWatts"] ? this.extensions["gpxpx:PowerExtension"]["gpxpx:PowerInWatts"] : undefined;
}
@@ -978,13 +1082,38 @@ export class TrackPoint {
this.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:Extensions"]["surface"] = surface;
}
toTrackPointType(): TrackPointType {
return {
toTrackPointType(exclude: string[] = []): TrackPointType {
let trkpt: TrackPointType = {
attributes: this.attributes,
ele: this.ele,
time: this.time,
extensions: this.extensions,
};
if (!exclude.includes('time')) {
trkpt = { ...trkpt, time: this.time };
}
if (this.extensions) {
trkpt = {
...trkpt, extensions: {
"gpxtpx:TrackPointExtension": {},
"gpxpx:PowerExtension": {},
}
};
if (this.extensions["gpxtpx:TrackPointExtension"] && this.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:atemp"] && !exclude.includes('atemp')) {
trkpt.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:atemp"] = this.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:atemp"];
}
if (this.extensions["gpxtpx:TrackPointExtension"] && this.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:hr"] && !exclude.includes('hr')) {
trkpt.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:hr"] = this.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:hr"];
}
if (this.extensions["gpxtpx:TrackPointExtension"] && this.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:cad"] && !exclude.includes('cad')) {
trkpt.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:cad"] = this.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:cad"];
}
if (this.extensions["gpxpx:PowerExtension"] && this.extensions["gpxpx:PowerExtension"]["gpxpx:PowerInWatts"] && !exclude.includes('power')) {
trkpt.extensions["gpxpx:PowerExtension"]["gpxpx:PowerInWatts"] = this.extensions["gpxpx:PowerExtension"]["gpxpx:PowerInWatts"];
}
if (this.extensions["gpxtpx:TrackPointExtension"] && this.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:Extensions"] && this.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:Extensions"].surface && !exclude.includes('surface')) {
trkpt.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:Extensions"] = { surface: this.extensions["gpxtpx:TrackPointExtension"]["gpxtpx:Extensions"].surface };
}
}
return trkpt;
}
clone(): TrackPoint {
@@ -1043,6 +1172,34 @@ export class Waypoint {
return this.attributes.lon;
}
toWaypointType(exclude: string[] = []): WaypointType {
if (!exclude.includes('time')) {
return {
attributes: this.attributes,
ele: this.ele,
time: this.time,
name: this.name,
cmt: this.cmt,
desc: this.desc,
link: this.link,
sym: this.sym,
type: this.type,
}
} else {
return {
attributes: this.attributes,
ele: this.ele,
name: this.name,
cmt: this.cmt,
desc: this.desc,
link: this.link,
sym: this.sym,
type: this.type,
};
}
}
clone(): Waypoint {
return new Waypoint({
attributes: cloneJSON(this.attributes),
@@ -1087,6 +1244,22 @@ export class GPXStatistics {
southWest: Coordinates,
northEast: Coordinates,
},
atemp: {
avg: number,
count: number,
},
hr: {
avg: number,
count: number,
},
cad: {
avg: number,
count: number,
},
power: {
avg: number,
count: number,
}
};
local: {
points: TrackPoint[],
@@ -1141,6 +1314,22 @@ export class GPXStatistics {
lon: -180,
},
},
atemp: {
avg: 0,
count: 0,
},
hr: {
avg: 0,
count: 0,
},
cad: {
avg: 0,
count: 0,
},
power: {
avg: 0,
count: 0,
}
};
this.local = {
points: [],
@@ -1201,9 +1390,29 @@ export class GPXStatistics {
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;
}
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);
@@ -1228,12 +1437,23 @@ export class GPXStatistics {
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: Coordinates, coord2: Coordinates): number {
export function distance(coord1: TrackPoint | Coordinates, coord2: TrackPoint | Coordinates): number {
if (coord1 instanceof TrackPoint) {
coord1 = coord1.getCoordinates();
}
if (coord2 instanceof TrackPoint) {
coord2 = coord2.getCoordinates();
}
const rad = Math.PI / 180;
const lat1 = coord1.lat * rad;
const lat2 = coord2.lat * rad;
@@ -1285,9 +1505,39 @@ function withTimestamps(points: TrackPoint[], speed: number, lastPoint: TrackPoi
function withShiftedAndCompressedTimestamps(points: TrackPoint[], speed: number, ratio: number, lastPoint: TrackPoint): TrackPoint[] {
let start = getTimestamp(lastPoint, points[0], speed);
let last = points[0];
return points.map((point) => {
let pt = point.clone();
if (point.time === undefined) {
pt.time = getTimestamp(last, point, speed);
} else {
pt.time = new Date(start.getTime() + ratio * (point.time.getTime() - points[0].time.getTime()));
}
last = pt;
return pt;
});
}
function withArtificialTimestamps(points: TrackPoint[], totalTime: number, lastPoint: TrackPoint | undefined, startTime: Date, 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 * slope[i])));
weight.push(w);
totalWeight += w;
}
let last = lastPoint;
return points.map((point, i) => {
let pt = point.clone();
if (i === 0) {
pt.time = lastPoint?.time ?? startTime;
} else {
pt.time = new Date(last.time.getTime() + totalTime * 1000 * weight[i - 1] / totalWeight);
}
last = pt;
return pt;
});
}
@@ -1318,8 +1568,8 @@ function convertRouteToTrack(route: RouteType): Track {
src: route.src,
link: route.link,
type: route.type,
trkseg: [],
extensions: route.extensions,
trkseg: [],
});
if (route.rtept) {

View File

@@ -23,6 +23,7 @@ export function parseGPX(gpxData: string): GPXFile {
}
return tagName;
},
parseTagValue: false,
tagValueProcessor(tagName, tagValue, jPath, hasAttributes, isLeafNode) {
if (isLeafNode) {
if (tagName === 'ele') {
@@ -33,7 +34,7 @@ export function parseGPX(gpxData: string): GPXFile {
return new Date(tagValue);
}
if (tagName === 'gpxtpx:hr' || tagName === 'gpxtpx:cad' || tagName === 'gpxtpx:atemp' || tagName === 'gpxpx:PowerInWatts' || tagName === 'opacity' || tagName === 'weight') {
if (tagName === 'gpxtpx:atemp' || tagName === 'gpxtpx:hr' || tagName === 'gpxtpx:cad' || tagName === 'gpxpx:PowerInWatts' || tagName === 'opacity' || tagName === 'weight') {
return parseFloat(tagValue);
}
@@ -60,9 +61,8 @@ export function parseGPX(gpxData: string): GPXFile {
return new GPXFile(parsed);
}
export function buildGPX(file: GPXFile): string {
const gpx = file.toGPXFileType();
export function buildGPX(file: GPXFile, exclude: string[]): string {
const gpx = file.toGPXFileType(exclude);
const builder = new XMLBuilder({
format: true,
@@ -87,14 +87,6 @@ export function buildGPX(file: GPXFile): string {
gpx.attributes['xmlns:gpxx'] = 'http://www.garmin.com/xmlschemas/GpxExtensions/v3';
gpx.attributes['xmlns:gpxpx'] = 'http://www.garmin.com/xmlschemas/PowerExtension/v1';
gpx.attributes['xmlns:gpx_style'] = 'http://www.topografix.com/GPX/gpx_style/0/2';
gpx.metadata.author = {
name: 'gpx.studio',
link: {
attributes: {
href: 'https://gpx.studio',
}
}
};
if (gpx.trk.length === 1 && (gpx.trk[0].name === undefined || gpx.trk[0].name === '')) {
gpx.trk[0].name = gpx.metadata.name;

View File

@@ -5,7 +5,7 @@ export type SimplifiedTrackPoint = { point: TrackPoint, distance?: number };
const earthRadius = 6371008.8;
export function ramerDouglasPeucker(points: TrackPoint[], epsilon: number = 50, measure: (a: TrackPoint, b: TrackPoint, c: TrackPoint) => number = computeCrossarc): SimplifiedTrackPoint[] {
export function ramerDouglasPeucker(points: TrackPoint[], epsilon: number = 50, measure: (a: TrackPoint, b: TrackPoint, c: TrackPoint) => number = crossarcDistance): SimplifiedTrackPoint[] {
if (points.length == 0) {
return [];
} else if (points.length == 1) {
@@ -45,8 +45,8 @@ function ramerDouglasPeuckerRecursive(points: TrackPoint[], epsilon: number, mea
}
}
function computeCrossarc(point1: TrackPoint, point2: TrackPoint, point3: TrackPoint): number {
return crossarc(point1.getCoordinates(), point2.getCoordinates(), point3.getCoordinates());
export function crossarcDistance(point1: TrackPoint, point2: TrackPoint, point3: TrackPoint | Coordinates): number {
return crossarc(point1.getCoordinates(), point2.getCoordinates(), point3 instanceof TrackPoint ? point3.getCoordinates() : point3);
}
function crossarc(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates): number {
@@ -102,3 +102,54 @@ function bearing(latA: number, lonA: number, latB: number, lonB: number): number
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(point1: TrackPoint, point2: TrackPoint, point3: TrackPoint | Coordinates): Coordinates {
return projected(point1.getCoordinates(), point2.getCoordinates(), point3 instanceof TrackPoint ? point3.getCoordinates() : point3);
}
function projected(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates): Coordinates {
// Calculates the point on the line defined by p1 and p2
// that is closest to the third point, p3.
// Input lat1,lon1,lat2,lon2,lat3,lon3 in degrees.
const rad = Math.PI / 180;
const lat1 = coord1.lat * rad;
const lat2 = coord2.lat * rad;
const lat3 = coord3.lat * rad;
const lon1 = coord1.lon * rad;
const lon2 = coord2.lon * rad;
const lon3 = coord3.lon * rad;
// 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);
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;
}
// Find the cross-track distance.
let dxt = Math.asin(Math.sin(dis13 / earthRadius) * Math.sin(bear13 - bear12)) * earthRadius;
// 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));
return { lat: lat4 / rad, lon: lon4 / rad };
}
}

View File

@@ -58,8 +58,8 @@ export type TrackType = {
src?: string;
link?: Link;
type?: string;
trkseg: TrackSegmentType[];
extensions?: TrackExtensions;
trkseg: TrackSegmentType[];
};
export type TrackExtensions = {
@@ -89,9 +89,9 @@ export type TrackPointExtensions = {
};
export type TrackPointExtension = {
'gpxtpx:atemp'?: number;
'gpxtpx:hr'?: number;
'gpxtpx:cad'?: number;
'gpxtpx:atemp'?: number;
'gpxtpx:Extensions'?: {
surface?: string;
};

View File

@@ -1,59 +0,0 @@
import * as fs from 'fs';
import { parseGPX, buildGPX } from '../src/io';
describe('GPX operations', () => {
it('Clone', () => {
const path = "test-data/with_tracks_and_segments.gpx";
const data = fs.readFileSync(path, 'utf8');
const original = parseGPX(data);
const cloned = original.clone();
expect(cloned).not.toBe(original);
const originalString = buildGPX(original);
const clonedString = buildGPX(cloned);
expect(clonedString).toBe(originalString);
});
it('Reverse', () => {
const path = "test-data/with_tracks_and_segments.gpx";
const data = fs.readFileSync(path, 'utf8');
const original = parseGPX(data);
let reversed = original.clone();
reversed.reverse();
expect(original.getStartTimestamp().getTime()).toBe(reversed.getStartTimestamp().getTime());
expect(original.getEndTimestamp().getTime()).toBe(reversed.getEndTimestamp().getTime());
expect(reversed.trk.length).toBe(original.trk.length);
for (let i = 0; i < original.trk.length; i++) {
const originalTrack = original.trk[i];
const reversedTrack = reversed.trk[original.trk.length - i - 1];
expect(reversedTrack.trkseg.length).toBe(originalTrack.trkseg.length);
for (let j = 0; j < originalTrack.trkseg.length; j++) {
const originalSegment = originalTrack.trkseg[j];
const reversedSegment = reversedTrack.trkseg[originalTrack.trkseg.length - j - 1];
expect(reversedSegment.trkpt.length).toBe(originalSegment.trkpt.length);
for (let k = 0; k < originalSegment.trkpt.length; k++) {
const originalPoint = originalSegment.trkpt[k];
const reversedPoint = reversedSegment.trkpt[originalSegment.trkpt.length - k - 1];
expect(reversedPoint.attributes.lat).toBe(originalPoint.attributes.lat);
expect(reversedPoint.attributes.lon).toBe(originalPoint.attributes.lon);
expect(reversedPoint.ele).toBe(originalPoint.ele);
expect(reversed.getEndTimestamp().getTime() - reversedPoint.time.getTime()).toBe(originalPoint.time.getTime() - original.getStartTimestamp().getTime());
}
}
}
});
});

View File

@@ -1,364 +0,0 @@
import * as fs from 'fs';
import { parseGPX, buildGPX } from '../src/io';
describe("Parsing", () => {
it("Simple", () => {
const path = "test-data/simple.gpx";
const data = fs.readFileSync(path, 'utf8');
const result = parseGPX(data);
expect(result.attributes.creator).toBe("https://gpx.studio");
expect(result.metadata.name).toBe("simple");
expect(result.metadata.author.name).toBe("gpx.studio");
expect(result.metadata.author.link.attributes.href).toBe("https://gpx.studio");
expect(result.trk.length).toBe(1);
const track = result.trk[0];
expect(track.name).toBe("simple");
expect(track.type).toBe("Cycling");
expect(track.trkseg.length).toBe(1);
const segment = track.trkseg[0];
expect(segment.trkpt.length).toBe(80);
for (let i = 0; i < segment.trkpt.length; i++) {
const point = segment.trkpt[i];
expect(point).toHaveProperty('attributes');
expect(point.attributes).toHaveProperty('lat');
expect(point.attributes).toHaveProperty('lon');
expect(point).toHaveProperty('ele');
}
expect(segment.trkpt[0].attributes.lat).toBe(50.790867);
expect(segment.trkpt[0].attributes.lon).toBe(4.404968);
expect(segment.trkpt[0].ele).toBe(109.0);
});
it("Multiple tracks", () => {
const path = "test-data/with_tracks.gpx";
const data = fs.readFileSync(path, 'utf8');
const result = parseGPX(data);
expect(result.trk.length).toBe(2);
const track_1 = result.trk[0];
expect(track_1.name).toBe("track 1");
expect(track_1.trkseg.length).toBe(1);
expect(track_1.trkseg[0].trkpt.length).toBe(49);
const track_2 = result.trk[1];
expect(track_2.name).toBe("track 2");
expect(track_2.trkseg.length).toBe(1);
expect(track_2.trkseg[0].trkpt.length).toBe(28);
});
it("Multiple segments", () => {
const path = "test-data/with_segments.gpx";
const data = fs.readFileSync(path, 'utf8');
const result = parseGPX(data);
expect(result.trk.length).toBe(1);
const track = result.trk[0];
expect(track.trkseg.length).toBe(2);
expect(track.trkseg[0].trkpt.length).toBe(49);
expect(track.trkseg[1].trkpt.length).toBe(28);
});
it("Waypoint", () => {
const path = "test-data/with_waypoint.gpx";
const data = fs.readFileSync(path, 'utf8');
const result = parseGPX(data);
expect(result.wpt.length).toBe(1);
const waypoint = result.wpt[0];
expect(waypoint.attributes.lat).toBe(50.7836710064975);
expect(waypoint.attributes.lon).toBe(4.410764082658738);
expect(waypoint.ele).toBe(122.0);
expect(waypoint.name).toBe("Waypoint");
expect(waypoint.cmt).toBe("Comment");
expect(waypoint.desc).toBe("Description");
expect(waypoint.sym).toBe("Bike Trail");
});
it("Time", () => {
const path = "test-data/with_time.gpx";
const data = fs.readFileSync(path, 'utf8');
const result = parseGPX(data);
const track = result.trk[0];
const segment = track.trkseg[0];
for (let i = 0; i < segment.trkpt.length; i++) {
expect(segment.trkpt[i].time).toBeInstanceOf(Date);
}
expect(segment.trkpt[0].time).toEqual(new Date("2023-12-31T23:00:00.000Z"));
expect(segment.trkpt[segment.trkpt.length - 1].time).toEqual(new Date("2023-12-31T23:06:40.567Z"));
});
it("Heart rate", () => {
const path = "test-data/with_hr.gpx";
const data = fs.readFileSync(path, 'utf8');
const result = parseGPX(data);
const track = result.trk[0];
const segment = track.trkseg[0];
for (let i = 0; i < segment.trkpt.length; i++) {
expect(segment.trkpt[i]).toHaveProperty('extensions');
expect(segment.trkpt[i].extensions).toHaveProperty('gpxtpx:TrackPointExtension');
expect(segment.trkpt[i].extensions['gpxtpx:TrackPointExtension']).toHaveProperty('gpxtpx:hr');
}
expect(segment.trkpt[0].extensions['gpxtpx:TrackPointExtension']['gpxtpx:hr']).toBe(150);
expect(segment.trkpt[segment.trkpt.length - 1].extensions['gpxtpx:TrackPointExtension']['gpxtpx:hr']).toBe(160);
});
it("Cadence", () => {
const path = "test-data/with_cad.gpx";
const data = fs.readFileSync(path, 'utf8');
const result = parseGPX(data);
const track = result.trk[0];
const segment = track.trkseg[0];
for (let i = 0; i < segment.trkpt.length; i++) {
expect(segment.trkpt[i]).toHaveProperty('extensions');
expect(segment.trkpt[i].extensions).toHaveProperty('gpxtpx:TrackPointExtension');
expect(segment.trkpt[i].extensions['gpxtpx:TrackPointExtension']).toHaveProperty('gpxtpx:cad');
}
expect(segment.trkpt[0].extensions['gpxtpx:TrackPointExtension']['gpxtpx:cad']).toBe(80);
expect(segment.trkpt[segment.trkpt.length - 1].extensions['gpxtpx:TrackPointExtension']['gpxtpx:cad']).toBe(90);
});
it("Temperature", () => {
const path = "test-data/with_temp.gpx";
const data = fs.readFileSync(path, 'utf8');
const result = parseGPX(data);
const track = result.trk[0];
const segment = track.trkseg[0];
for (let i = 0; i < segment.trkpt.length; i++) {
expect(segment.trkpt[i]).toHaveProperty('extensions');
expect(segment.trkpt[i].extensions).toHaveProperty('gpxtpx:TrackPointExtension');
expect(segment.trkpt[i].extensions['gpxtpx:TrackPointExtension']).toHaveProperty('gpxtpx:atemp');
}
expect(segment.trkpt[0].extensions['gpxtpx:TrackPointExtension']['gpxtpx:atemp']).toBe(21);
expect(segment.trkpt[segment.trkpt.length - 1].extensions['gpxtpx:TrackPointExtension']['gpxtpx:atemp']).toBe(22);
});
it("Power 1", () => {
const path = "test-data/with_power_1.gpx";
const data = fs.readFileSync(path, 'utf8');
const result = parseGPX(data);
const track = result.trk[0];
const segment = track.trkseg[0];
for (let i = 0; i < segment.trkpt.length; i++) {
expect(segment.trkpt[i]).toHaveProperty('extensions');
expect(segment.trkpt[i].extensions).toHaveProperty('gpxpx:PowerExtension');
expect(segment.trkpt[i].extensions['gpxpx:PowerExtension']).toHaveProperty('gpxpx:PowerInWatts');
}
expect(segment.trkpt[0].extensions['gpxpx:PowerExtension']['gpxpx:PowerInWatts']).toBe(200);
expect(segment.trkpt[segment.trkpt.length - 1].extensions['gpxpx:PowerExtension']['gpxpx:PowerInWatts']).toBe(210);
});
it("Power 2", () => {
const path = "test-data/with_power_2.gpx";
const data = fs.readFileSync(path, 'utf8');
const result = parseGPX(data);
const track = result.trk[0];
const segment = track.trkseg[0];
for (let i = 0; i < segment.trkpt.length; i++) {
expect(segment.trkpt[i]).toHaveProperty('extensions');
expect(segment.trkpt[i].extensions).toHaveProperty('gpxpx:PowerExtension');
expect(segment.trkpt[i].extensions['gpxpx:PowerExtension']).toHaveProperty('gpxpx:PowerInWatts');
}
expect(segment.trkpt[0].extensions['gpxpx:PowerExtension']['gpxpx:PowerInWatts']).toBe(200);
expect(segment.trkpt[segment.trkpt.length - 1].extensions['gpxpx:PowerExtension']['gpxpx:PowerInWatts']).toBe(210);
});
it("Surface", () => {
const path = "test-data/with_surface.gpx";
const data = fs.readFileSync(path, 'utf8');
const result = parseGPX(data);
const track = result.trk[0];
const segment = track.trkseg[0];
for (let i = 0; i < segment.trkpt.length; i++) {
expect(segment.trkpt[i]).toHaveProperty('extensions');
expect(segment.trkpt[i].extensions).toHaveProperty('gpxtpx:TrackPointExtension');
expect(segment.trkpt[i].extensions['gpxtpx:TrackPointExtension']).toHaveProperty('gpxtpx:Extensions');
expect(segment.trkpt[i].extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions']).toHaveProperty('surface');
}
expect(segment.trkpt[0].extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions'].surface).toBe("asphalt");
expect(segment.trkpt[segment.trkpt.length - 1].extensions['gpxtpx:TrackPointExtension']['gpxtpx:Extensions'].surface).toBe("cobblestone");
});
it("Track style", () => {
const path = "test-data/with_style.gpx";
const data = fs.readFileSync(path, 'utf8');
const result = parseGPX(data);
const track = result.trk[0];
expect(track).toHaveProperty('extensions');
expect(track.extensions).toHaveProperty('gpx_style:line');
expect(track.extensions['gpx_style:line']).toHaveProperty('color');
expect(track.extensions['gpx_style:line']).toHaveProperty('opacity');
expect(track.extensions['gpx_style:line']).toHaveProperty('weight');
expect(track.extensions['gpx_style:line'].color).toBe("#2d3ee9");
expect(track.extensions['gpx_style:line'].opacity).toBe(0.5);
expect(track.extensions['gpx_style:line'].weight).toBe(6);
});
});
describe("Building", () => {
it("Simple", () => {
const path = "test-data/simple.gpx";
const data = fs.readFileSync(path, 'utf8');
const original = parseGPX(data);
const built = buildGPX(original);
const rebuilt = parseGPX(built);
expect(rebuilt).toEqual(original);
});
it("Multiple tracks", () => {
const path = "test-data/with_tracks.gpx";
const data = fs.readFileSync(path, 'utf8');
const original = parseGPX(data);
const built = buildGPX(original);
const rebuilt = parseGPX(built);
expect(rebuilt).toEqual(original);
});
it("Multiple segments", () => {
const path = "test-data/with_segments.gpx";
const data = fs.readFileSync(path, 'utf8');
const original = parseGPX(data);
const built = buildGPX(original);
const rebuilt = parseGPX(built);
expect(rebuilt).toEqual(original);
});
it("Waypoint", () => {
const path = "test-data/with_waypoint.gpx";
const data = fs.readFileSync(path, 'utf8');
const original = parseGPX(data);
const built = buildGPX(original);
const rebuilt = parseGPX(built);
expect(rebuilt).toEqual(original);
});
it("Time", () => {
const path = "test-data/with_time.gpx";
const data = fs.readFileSync(path, 'utf8');
const original = parseGPX(data);
const built = buildGPX(original);
const rebuilt = parseGPX(built);
expect(rebuilt).toEqual(original);
});
it("Heart rate", () => {
const path = "test-data/with_hr.gpx";
const data = fs.readFileSync(path, 'utf8');
const original = parseGPX(data);
const built = buildGPX(original);
const rebuilt = parseGPX(built);
expect(rebuilt).toEqual(original);
});
it("Cadence", () => {
const path = "test-data/with_cad.gpx";
const data = fs.readFileSync(path, 'utf8');
const original = parseGPX(data);
const built = buildGPX(original);
const rebuilt = parseGPX(built);
expect(rebuilt).toEqual(original);
});
it("Temperature", () => {
const path = "test-data/with_temp.gpx";
const data = fs.readFileSync(path, 'utf8');
const original = parseGPX(data);
const built = buildGPX(original);
const rebuilt = parseGPX(built);
expect(rebuilt).toEqual(original);
});
it("Power 1", () => {
const path = "test-data/with_power_1.gpx";
const data = fs.readFileSync(path, 'utf8');
const original = parseGPX(data);
const built = buildGPX(original);
const rebuilt = parseGPX(built);
expect(rebuilt).toEqual(original);
});
it("Power 2", () => {
const path = "test-data/with_power_2.gpx";
const data = fs.readFileSync(path, 'utf8');
const original = parseGPX(data);
const built = buildGPX(original);
const rebuilt = parseGPX(built);
expect(rebuilt).toEqual(original);
});
it("Surface", () => {
const path = "test-data/with_surface.gpx";
const data = fs.readFileSync(path, 'utf8');
const original = parseGPX(data);
const built = buildGPX(original);
const rebuilt = parseGPX(built);
expect(rebuilt).toEqual(original);
});
it("Track style", () => {
const path = "test-data/with_style.gpx";
const data = fs.readFileSync(path, 'utf8');
const original = parseGPX(data);
const built = buildGPX(original);
const rebuilt = parseGPX(built);
expect(rebuilt).toEqual(original);
});
});

View File

@@ -7,7 +7,6 @@
"moduleResolution": "node",
},
"include": [
"src",
"test"
"src"
],
}

View File

@@ -14,15 +14,17 @@
"@types/mapbox__sphericalmercator": "^1.2.3",
"bits-ui": "^0.21.12",
"chart.js": "^4.4.3",
"chartjs-plugin-zoom": "^2.0.1",
"clsx": "^2.1.1",
"dexie": "^4.0.7",
"gpx": "file:../gpx",
"immer": "^10.1.1",
"lucide-static": "^0.408.0",
"lucide-svelte": "^0.395.0",
"lucide-static": "^0.427.0",
"lucide-svelte": "^0.427.0",
"mapbox-gl": "^3.4.0",
"mapillary-js": "^4.1.2",
"mode-watcher": "^0.3.1",
"sanitize-html": "^2.13.0",
"sortablejs": "^1.15.2",
"svelte-i18n": "^4.0.0",
"svelte-sonner": "^0.3.24",
@@ -40,6 +42,7 @@
"@types/mapbox__mapbox-gl-geocoder": "^5.0.0",
"@types/mapbox-gl": "^3.1.0",
"@types/node": "^20.14.6",
"@types/sanitize-html": "^2.11.0",
"@types/sortablejs": "^1.15.8",
"@typescript-eslint/eslint-plugin": "^7.13.1",
"@typescript-eslint/parser": "^7.13.1",
@@ -71,10 +74,7 @@
},
"devDependencies": {
"@types/geojson": "^7946.0.14",
"@types/jest": "^29.5.12",
"@types/node": "^20.14.6",
"jest": "^29.7.0",
"ts-jest": "^29.1.5",
"typescript": "^5.4.5"
}
},
@@ -2042,6 +2042,15 @@
"@types/node": "*"
}
},
"node_modules/@types/sanitize-html": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.11.0.tgz",
"integrity": "sha512-7oxPGNQHXLHE48r/r/qjn7q0hlrs3kL7oZnGj0Wf/h9tj/6ibFyRkNbsDxaBBZ4XUZ0Dx5LGCyDJ04ytSofacQ==",
"dev": true,
"dependencies": {
"htmlparser2": "^8.0.0"
}
},
"node_modules/@types/sortablejs": {
"version": "1.15.8",
"resolved": "https://registry.npmjs.org/@types/sortablejs/-/sortablejs-1.15.8.tgz",
@@ -2678,6 +2687,17 @@
"pnpm": ">=8"
}
},
"node_modules/chartjs-plugin-zoom": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/chartjs-plugin-zoom/-/chartjs-plugin-zoom-2.0.1.tgz",
"integrity": "sha512-ogOmLu6e+Q7E1XWOCOz9YwybMslz9qNfGV2a+qjfmqJYpsw5ZMoRHZBUyW+NGhkpQ5PwwPA/+rikHpBZb7PZuA==",
"dependencies": {
"hammerjs": "^2.0.8"
},
"peerDependencies": {
"chart.js": ">=3.2.0"
}
},
"node_modules/cheap-ruler": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/cheap-ruler/-/cheap-ruler-3.0.2.tgz",
@@ -3063,11 +3083,62 @@
"node": ">=6.0.0"
}
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/dom-walk": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz",
"integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w=="
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
]
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz",
"integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/earcut": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz",
@@ -3097,6 +3168,17 @@
"once": "^1.4.0"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/error": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/error/-/error-4.4.0.tgz",
@@ -3236,7 +3318,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"dev": true,
"engines": {
"node": ">=10"
},
@@ -3889,6 +3970,14 @@
"resolved": "https://registry.npmjs.org/grid-index/-/grid-index-1.1.0.tgz",
"integrity": "sha512-HZRwumpOGUrHyxO5bqKZL0B0GlUpwtCAzZ42sgxUPniu33R1LSFH5yrIcBCHjkctCAh3mtWKcKd9J4vDDdeVHA=="
},
"node_modules/hammerjs": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/hammerjs/-/hammerjs-2.0.8.tgz",
"integrity": "sha512-tSQXBXS/MWQOn/RKckawJ61vvsDpCom87JgxiYdGwHdOa0ht0vzUWDlfioofFCRU0L+6NGDt6XzbgoJvZkMeRQ==",
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/hard-rejection": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz",
@@ -3928,6 +4017,24 @@
"node": ">=10"
}
},
"node_modules/htmlparser2": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.0.1",
"entities": "^4.4.0"
}
},
"node_modules/http-cache-semantics": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz",
@@ -4157,6 +4264,14 @@
"node": ">=0.10.0"
}
},
"node_modules/is-plain-object": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-promise": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz",
@@ -4373,14 +4488,14 @@
}
},
"node_modules/lucide-static": {
"version": "0.408.0",
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-0.408.0.tgz",
"integrity": "sha512-XJioz3vKagiyA6qMDWkYqU1RUS/bMjqio0/TCOItievnV/C4wwgJZGAbk6eVDe6Wv+d0e9NbhS7Y8yMEpGkElQ=="
"version": "0.427.0",
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-0.427.0.tgz",
"integrity": "sha512-lsUNKUv6rZrRyQa5KMHZwxKMP1Am2L9BqQGxSvFhcPvZW86sF0YcLZFQ+V5SrBPr3VhNnebMS4SqloMDucd/YQ=="
},
"node_modules/lucide-svelte": {
"version": "0.395.0",
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.395.0.tgz",
"integrity": "sha512-gl4HIIGUyj3seZf/CQ6vG38oMnNmdO1lcBQaMsB5pFpnL1T13qlfqjMjfTCnsKMFjiIFr3LttE/LFVi/GXmV0A==",
"version": "0.427.0",
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.427.0.tgz",
"integrity": "sha512-l0IJqYVBTxMkJPvZqyIMyVFlbZ18Mjg0n6p7Zo9bSQNvsmeviTyOxYmYdp/G5gXcSHaOGL6YoQW7fmbFklbW/w==",
"peerDependencies": {
"svelte": "^3 || ^4 || ^5.0.0-next.42"
}
@@ -4921,6 +5036,11 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/parse-srcset": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q=="
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -5780,6 +5900,19 @@
"rimraf": "bin.js"
}
},
"node_modules/sanitize-html": {
"version": "2.13.0",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.13.0.tgz",
"integrity": "sha512-Xff91Z+4Mz5QiNSLdLWwjgBDm5b1RU6xBT0+12rapjiaR7SwfRdjw8f+6Rir2MXKLrDicRFHdb51hGOAxmsUIA==",
"dependencies": {
"deepmerge": "^4.2.2",
"escape-string-regexp": "^4.0.0",
"htmlparser2": "^8.0.0",
"is-plain-object": "^5.0.0",
"parse-srcset": "^1.0.2",
"postcss": "^8.3.11"
}
},
"node_modules/semver": {
"version": "7.6.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz",

View File

@@ -23,6 +23,7 @@
"@types/mapbox__mapbox-gl-geocoder": "^5.0.0",
"@types/mapbox-gl": "^3.1.0",
"@types/node": "^20.14.6",
"@types/sanitize-html": "^2.11.0",
"@types/sortablejs": "^1.15.8",
"@typescript-eslint/eslint-plugin": "^7.13.1",
"@typescript-eslint/parser": "^7.13.1",
@@ -52,15 +53,17 @@
"@types/mapbox__sphericalmercator": "^1.2.3",
"bits-ui": "^0.21.12",
"chart.js": "^4.4.3",
"chartjs-plugin-zoom": "^2.0.1",
"clsx": "^2.1.1",
"dexie": "^4.0.7",
"gpx": "file:../gpx",
"immer": "^10.1.1",
"lucide-static": "^0.408.0",
"lucide-svelte": "^0.395.0",
"lucide-static": "^0.427.0",
"lucide-svelte": "^0.427.0",
"mapbox-gl": "^3.4.0",
"mapillary-js": "^4.1.2",
"mode-watcher": "^0.3.1",
"sanitize-html": "^2.13.0",
"sortablejs": "^1.15.2",
"svelte-i18n": "^4.0.0",
"svelte-sonner": "^0.3.24",

View File

@@ -0,0 +1,43 @@
import { base } from '$app/paths';
import { languages } from '$lib/languages';
import { getURLForLanguage } from '$lib/utils';
export async function handle({ event, resolve }) {
let language = event.params.language ?? 'en';
const strings = await import(`./locales/${language}.json`);
let path = event.url.pathname;
let page = event.route.id?.replace('/[[language]]', '').split('/')[1] ?? 'home';
let title = strings.metadata[`${page}_title`];
let description = strings.metadata[`description`];
let head = `<head>
<title>gpx.studio — ${title}</title>
<meta name="description" content="${description}" />
<meta property="og:title" content="gpx.studio — ${title}" />
<meta property="og:description" content="${description}" />
<meta name="twitter:title" content="gpx.studio — ${title}" />
<meta name="twitter:description" content="${description}" />
<meta property="og:image" content="https://gpx.studio${base}/og_logo.png" />
<meta property="og:url" content="https://gpx.studio/" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="gpx.studio" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content="https://gpx.studio${base}/og_logo.png" />
<meta name="twitter:url" content="https://gpx.studio/" />
<meta name="twitter:site" content="@gpxstudio" />
<meta name="twitter:creator" content="@gpxstudio" />
<link rel="alternate" hreflang="x-default" href="https://gpx.studio${getURLForLanguage('en', path)}" />`;
for (let lang of Object.keys(languages)) {
head += ` <link rel="alternate" hreflang="${lang}" href="https://gpx.studio${getURLForLanguage(lang, path)}" />
`;
}
const response = await resolve(event, {
transformPageChunk: ({ html }) => html.replace('<head>', head)
});
return response;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

View File

@@ -1,6 +1,9 @@
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
import { TramFront, Utensils, ShoppingBasket, Droplet, ShowerHead, Fuel, CircleParking, Fence, FerrisWheel, Telescope, Bed, Mountain, Pickaxe, Store, TrainFront, Bus, Ship, Croissant, House, Tent, Wrench } from 'lucide-static';
import { type AnySourceData, type Style } from 'mapbox-gl';
import { TramFront, Utensils, ShoppingBasket, Droplet, ShowerHead, Fuel, CircleParking, Fence, FerrisWheel, Bed, Mountain, Pickaxe, Store, TrainFront, Bus, Ship, Croissant, House, Tent, Wrench, Binoculars } from 'lucide-static';
import { type Style } 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';
import bikerouterGravel from './custom/bikerouter-gravel.json';
export const basemaps: { [key: string]: string | Style; } = {
mapboxOutdoors: 'mapbox://styles/mapbox/outdoors-v12',
@@ -127,7 +130,8 @@ export const basemaps: { [key: string]: string | Style; } = {
source: 'ignBe',
}],
},
ignFrPlan: 'https://data.geopf.fr/annexes/ressources/vectorTiles/styles/PLAN.IGN/classique.json',
ignFrPlan: ignFrPlan,
ignFrTopo: ignFrTopo,
ignFrScan25: {
version: 8,
sources: {
@@ -145,23 +149,7 @@ export const basemaps: { [key: string]: string | Style; } = {
source: 'ignFrScan25',
}],
},
ignFrSatellite: {
version: 8,
sources: {
ignFrSatellite: {
type: 'raster',
tiles: ['https://data.geopf.fr/wmts?SERVICE=WMTS&VERSION=1.0.0&REQUEST=GetTile&STYLE=normal&TILEMATRIXSET=PM&FORMAT=image/jpeg&LAYER=ORTHOIMAGERY.ORTHOPHOTOS&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}'],
tileSize: 256,
maxzoom: 19,
attribution: 'IGN-F/Géoportail'
}
},
layers: [{
id: 'ignFrSatellite',
type: 'raster',
source: 'ignFrSatellite',
}],
},
ignFrSatellite: ignFrSatellite,
ignEs: {
version: 8,
sources: {
@@ -179,23 +167,7 @@ export const basemaps: { [key: string]: string | Style; } = {
source: 'ignEs',
}],
},
ordnanceSurvey: {
version: 8,
sources: {
ordnanceSurvey: {
type: 'raster',
tiles: ['https://api.os.uk/maps/raster/v1/zxy/Outdoor_3857/{z}/{x}/{y}.png?key=piCT8WysfuC3xLSUW7sGLfrAAJoYDvQz'],
tileSize: 256,
maxzoom: 20,
attribution: '&copy; <a href="http://www.ordnancesurvey.co.uk/" target="_blank">Ordnance Survey</a>'
}
},
layers: [{
id: 'ordnanceSurvey',
type: 'raster',
source: 'ordnanceSurvey',
}],
},
ordnanceSurvey: "https://api.os.uk/maps/vector/v1/vts/resources/styles?srs=3857&key=piCT8WysfuC3xLSUW7sGLfrAAJoYDvQz",
norwayTopo: {
version: 8,
sources: {
@@ -216,18 +188,49 @@ export const basemaps: { [key: string]: string | Style; } = {
swedenTopo: {
version: 8,
sources: {
swedenTopo: {
swedenTopoWMTS: {
type: 'raster',
tiles: ['https://api.lantmateriet.se/open/topowebb-ccby/v1/wmts/token/1d54dd14-a28c-38a9-b6f3-b4ebfcc3c204/1.0.0/topowebb/default/3857/{z}/{y}/{x}.png'],
tileSize: 256,
maxzoom: 14,
attribution: '&copy; <a href="https://www.lantmateriet.se" target="_blank">Lantmäteriet</a>'
},
swedenTopoWMS: {
type: 'raster',
tiles: ['https://minkarta.lantmateriet.se/map/topowebb?REQUEST=GetMap&SERVICE=WMS&VERSION=1.1.1&FORMAT=image%2Fpng&STYLES=&TRANSPARENT=false&LAYERS=topowebbkartan&TILED=true&MAP_RESOLUTION=180&WIDTH=512&HEIGHT=512&SRS=EPSG%3A3857&BBOX={bbox-epsg-3857}'],
tileSize: 512,
minzoom: 14,
maxzoom: 20,
attribution: '&copy; <a href="https://www.lantmateriet.se" target="_blank">Lantmäteriet</a>'
}
},
layers: [{
id: 'swedenTopo',
id: 'swedenTopoWMTS',
type: 'raster',
source: 'swedenTopo',
source: 'swedenTopoWMTS',
maxzoom: 14
}, {
id: 'swedenTopoWMS',
type: 'raster',
source: 'swedenTopoWMS',
minzoom: 14
}],
},
swedenSatellite: {
version: 8,
sources: {
swedenSatellite: {
type: 'raster',
tiles: ['https://minkarta.lantmateriet.se/map/ortofoto?REQUEST=GetMap&SERVICE=WMS&VERSION=1.1.1&FORMAT=image%2Fpng&STYLES=&TRANSPARENT=false&LAYERS=Ortofoto_0.5%2COrtofoto_0.4%2COrtofoto_0.25%2COrtofoto_0.16&TILED=true&MAP_RESOLUTION=180&WIDTH=512&HEIGHT=512&SRS=EPSG%3A3857&BBOX={bbox-epsg-3857}'],
tileSize: 512,
maxzoom: 22,
attribution: '&copy; <a href="https://www.lantmateriet.se" target="_blank">Lantmäteriet</a>'
}
},
layers: [{
id: 'swedenSatellite',
type: 'raster',
source: 'swedenSatellite',
}],
},
finlandTopo: {
@@ -283,29 +286,28 @@ export const basemaps: { [key: string]: string | Style; } = {
},
};
export function extendBasemap(basemap: string | Style): string | Style {
if (typeof basemap === 'object') {
basemap["glyphs"] = "mapbox://fonts/mapbox/{fontstack}/{range}.pbf";
basemap["sprite"] = `https://api.mapbox.com/styles/v1/mapbox/outdoors-v12/sprite?access_token=${PUBLIC_MAPBOX_TOKEN}`;
}
return basemap;
}
Object.values(basemaps).forEach(extendBasemap);
export const font: { [key: string]: string; } = {
swisstopoVector: 'Frutiger Neue Condensed Regular',
swisstopoSatellite: 'Frutiger Neue Condensed Regular',
};
export const overlays: { [key: string]: AnySourceData; } = {
export const overlays: { [key: string]: string | Style; } = {
cyclOSMlite: {
version: 8,
sources: {
cyclOSMlite: {
type: 'raster',
tiles: ['https://a.tile-cyclosm.openstreetmap.fr/cyclosm-lite/{z}/{x}/{y}.png', 'https://b.tile-cyclosm.openstreetmap.fr/cyclosm-lite/{z}/{x}/{y}.png', 'https://c.tile-cyclosm.openstreetmap.fr/cyclosm-lite/{z}/{x}/{y}.png'],
tileSize: 256,
maxzoom: 17,
attribution: '&copy; <a href="https://github.com/cyclosm/cyclosm-cartocss-style/releases" title="CyclOSM - Open Bicycle render">CyclOSM</a> &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}
},
layers: [{
id: 'cyclOSMlite',
type: 'raster',
source: 'cyclOSMlite',
}],
},
bikerouterGravel: bikerouterGravel,
swisstopoSlope: {
version: 8,
sources: {
swisstopoSlope: {
type: 'raster',
tiles: ['https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.hangneigung-ueber_30/default/current/3857/{z}/{x}/{y}.png'],
@@ -313,6 +315,16 @@ export const overlays: { [key: string]: AnySourceData; } = {
maxzoom: 17,
attribution: '&copy; <a href="https://www.swisstopo.admin.ch" target="_blank">swisstopo</a>',
},
},
layers: [{
id: 'swisstopoSlope',
type: 'raster',
source: 'swisstopoSlope',
}],
},
swisstopoHiking: {
version: 8,
sources: {
swisstopoHiking: {
type: 'raster',
tiles: ['https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.swisstlm3d-wanderwege/default/current/3857/{z}/{x}/{y}.png'],
@@ -320,59 +332,148 @@ export const overlays: { [key: string]: AnySourceData; } = {
maxzoom: 18,
attribution: '&copy; <a href="https://www.swisstopo.admin.ch" target="_blank">swisstopo</a>'
},
},
layers: [{
id: 'swisstopoHiking',
type: 'raster',
source: 'swisstopoHiking',
}],
},
swisstopoHikingClosures: {
version: 8,
sources: {
swisstopoHikingClosures: {
type: 'raster',
tiles: ['https://wms.geo.admin.ch/?version=1.3.0&service=WMS&request=GetMap&sld_version=1.1.0&layers=ch.astra.wanderland-sperrungen_umleitungen&format=image/png&STYLE=default&bbox={bbox-epsg-3857}&width=256&height=256&crs=EPSG:3857&transparent=true'],
tileSize: 256,
attribution: '&copy; <a href="https://www.swisstopo.admin.ch" target="_blank">swisstopo</a>'
},
},
layers: [{
id: 'swisstopoHikingClosures',
type: 'raster',
source: 'swisstopoHikingClosures',
}],
},
swisstopoCycling: {
version: 8,
sources: {
swisstopoCycling: {
type: 'raster',
tiles: ['https://wmts.geo.admin.ch/1.0.0/ch.astra.veloland/default/current/3857/{z}/{x}/{y}.png'],
tileSize: 256,
maxzoom: 18,
attribution: '&copy; <a href="https://www.swisstopo.admin.ch" target="_blank">swisstopo</a>'
}
},
layers: [{
id: 'swisstopoCycling',
type: 'raster',
source: 'swisstopoCycling',
}],
},
swisstopoCyclingClosures: {
version: 8,
sources: {
swisstopoCyclingClosures: {
type: 'raster',
tiles: ['https://wms.geo.admin.ch/?version=1.3.0&service=WMS&request=GetMap&sld_version=1.1.0&layers=ch.astra.veloland-sperrungen_umleitungen&format=image/png&STYLE=default&bbox={bbox-epsg-3857}&width=256&height=256&crs=EPSG:3857&transparent=true'],
tileSize: 256,
attribution: '&copy; <a href="https://www.swisstopo.admin.ch" target="_blank">swisstopo</a>'
}
},
layers: [{
id: 'swisstopoCyclingClosures',
type: 'raster',
source: 'swisstopoCyclingClosures',
}],
},
swisstopoMountainBike: {
version: 8,
sources: {
swisstopoMountainBike: {
type: 'raster',
tiles: ['https://wmts.geo.admin.ch/1.0.0/ch.astra.mountainbikeland/default/current/3857/{z}/{x}/{y}.png'],
tileSize: 256,
maxzoom: 18,
attribution: '&copy; <a href="https://www.swisstopo.admin.ch" target="_blank">swisstopo</a>'
}
},
layers: [{
id: 'swisstopoMountainBike',
type: 'raster',
source: 'swisstopoMountainBike',
}],
},
swisstopoMountainBikeClosures: {
version: 8,
sources: {
swisstopoMountainBikeClosures: {
type: 'raster',
tiles: ['https://wms.geo.admin.ch/?version=1.3.0&service=WMS&request=GetMap&sld_version=1.1.0&layers=ch.astra.mountainbikeland-sperrungen_umleitungen&format=image/png&STYLE=default&bbox={bbox-epsg-3857}&width=256&height=256&crs=EPSG:3857&transparent=true'],
tileSize: 256,
attribution: '&copy; <a href="https://www.swisstopo.admin.ch" target="_blank">swisstopo</a>'
}
},
layers: [{
id: 'swisstopoMountainBikeClosures',
type: 'raster',
source: 'swisstopoMountainBikeClosures',
}],
},
swisstopoSkiTouring: {
version: 8,
sources: {
swisstopoSkiTouring: {
type: 'raster',
tiles: ['https://wmts.geo.admin.ch/1.0.0/ch.swisstopo-karto.skitouren/default/current/3857/{z}/{x}/{y}.png'],
tileSize: 256,
maxzoom: 17,
attribution: '&copy; <a href="https://www.swisstopo.admin.ch" target="_blank">swisstopo</a>'
}
},
layers: [{
id: 'swisstopoSkiTouring',
type: 'raster',
source: 'swisstopoSkiTouring',
}],
},
ignFrCadastre: {
version: 8,
sources: {
ignFrCadastre: {
type: 'raster',
tiles: ['https://data.geopf.fr/wmts?SERVICE=WMTS&VERSION=1.0.0&REQUEST=GetTile&TILEMATRIXSET=PM&TILEMATRIX={z}&TILECOL={x}&TILEROW={y}&LAYER=CADASTRALPARCELS.PARCELS&FORMAT=image/png&STYLE=normal'],
tileSize: 256,
maxzoom: 20,
attribution: 'IGN-F/Géoportail'
}
},
layers: [{
id: 'ignFrCadastre',
type: 'raster',
source: 'ignFrCadastre',
}],
},
ignSlope: {
version: 8,
sources: {
ignSlope: {
type: 'raster',
tiles: ['https://data.geopf.fr/wmts?SERVICE=WMTS&VERSION=1.0.0&REQUEST=GetTile&TileMatrixSet=PM&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&Layer=GEOGRAPHICALGRIDSYSTEMS.SLOPES.MOUNTAIN&FORMAT=image/png&Style=normal'],
tileSize: 256,
maxzoom: 17,
attribution: 'IGN-F/Géoportail'
}
},
layers: [{
id: 'ignSlope',
type: 'raster',
source: 'ignSlope',
}],
},
ignSkiTouring: {
version: 8,
sources: {
ignSkiTouring: {
type: 'raster',
tiles: ['https://data.geopf.fr/wmts?SERVICE=WMTS&VERSION=1.0.0&REQUEST=GetTile&TileMatrixSet=PM&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&Layer=TRACES.RANDO.HIVERNALE&FORMAT=image/png&Style=normal'],
@@ -380,103 +481,114 @@ export const overlays: { [key: string]: AnySourceData; } = {
maxzoom: 16,
attribution: 'IGN-F/Géoportail'
},
},
layers: [{
id: 'ignSkiTouring',
type: 'raster',
source: 'ignSkiTouring',
}],
},
waymarkedTrailsHiking: {
version: 8,
sources: {
waymarkedTrailsHiking: {
type: 'raster',
tiles: ['https://tile.waymarkedtrails.org/hiking/{z}/{x}/{y}.png'],
tileSize: 256,
maxzoom: 18,
attribution: '&copy; <a href="https://www.waymarkedtrails.org" target="_blank">Waymarked Trails</a>'
}
},
layers: [{
id: 'waymarkedTrailsHiking',
type: 'raster',
source: 'waymarkedTrailsHiking',
}],
},
waymarkedTrailsCycling: {
version: 8,
sources: {
waymarkedTrailsCycling: {
type: 'raster',
tiles: ['https://tile.waymarkedtrails.org/cycling/{z}/{x}/{y}.png'],
tileSize: 256,
maxzoom: 18,
attribution: '&copy; <a href="https://www.waymarkedtrails.org" target="_blank">Waymarked Trails</a>'
}
},
layers: [{
id: 'waymarkedTrailsCycling',
type: 'raster',
source: 'waymarkedTrailsCycling',
}],
},
waymarkedTrailsMTB: {
version: 8,
sources: {
waymarkedTrailsMTB: {
type: 'raster',
tiles: ['https://tile.waymarkedtrails.org/mtb/{z}/{x}/{y}.png'],
tileSize: 256,
maxzoom: 18,
attribution: '&copy; <a href="https://www.waymarkedtrails.org" target="_blank">Waymarked Trails</a>'
}
},
layers: [{
id: 'waymarkedTrailsMTB',
type: 'raster',
source: 'waymarkedTrailsMTB',
}],
},
waymarkedTrailsSkating: {
version: 8,
sources: {
waymarkedTrailsSkating: {
type: 'raster',
tiles: ['https://tile.waymarkedtrails.org/skating/{z}/{x}/{y}.png'],
tileSize: 256,
maxzoom: 18,
attribution: '&copy; <a href="https://www.waymarkedtrails.org" target="_blank">Waymarked Trails</a>'
}
},
layers: [{
id: 'waymarkedTrailsSkating',
type: 'raster',
source: 'waymarkedTrailsSkating',
}],
},
waymarkedTrailsHorseRiding: {
version: 8,
sources: {
waymarkedTrailsHorseRiding: {
type: 'raster',
tiles: ['https://tile.waymarkedtrails.org/riding/{z}/{x}/{y}.png'],
tileSize: 256,
maxzoom: 18,
attribution: '&copy; <a href="https://www.waymarkedtrails.org" target="_blank">Waymarked Trails</a>'
}
},
layers: [{
id: 'waymarkedTrailsHorseRiding',
type: 'raster',
source: 'waymarkedTrailsHorseRiding',
}],
},
waymarkedTrailsWinter: {
version: 8,
sources: {
waymarkedTrailsWinter: {
type: 'raster',
tiles: ['https://tile.waymarkedtrails.org/slopes/{z}/{x}/{y}.png'],
tileSize: 256,
maxzoom: 18,
attribution: '&copy; <a href="https://www.waymarkedtrails.org" target="_blank">Waymarked Trails</a>'
}
},
stravaHeatmapRun: {
layers: [{
id: 'waymarkedTrailsWinter',
type: 'raster',
tiles: [],
tileSize: 1024,
maxzoom: 15,
attribution: '&copy; <a href="https://www.strava.com" target="_blank">Strava</a>'
},
stravaHeatmapTrailRun: {
type: 'raster',
tiles: [],
tileSize: 1024,
maxzoom: 15,
attribution: '&copy; <a href="https://www.strava.com" target="_blank">Strava</a>'
},
stravaHeatmapHike: {
type: 'raster',
tiles: [],
tileSize: 1024,
maxzoom: 15,
attribution: '&copy; <a href="https://www.strava.com" target="_blank">Strava</a>'
},
stravaHeatmapRide: {
type: 'raster',
tiles: [],
tileSize: 1024,
maxzoom: 15,
attribution: '&copy; <a href="https://www.strava.com" target="_blank">Strava</a>'
},
stravaHeatmapGravel: {
type: 'raster',
tiles: [],
tileSize: 1024,
maxzoom: 15,
attribution: '&copy; <a href="https://www.strava.com" target="_blank">Strava</a>'
},
stravaHeatmapMTB: {
type: 'raster',
tiles: [],
tileSize: 1024,
maxzoom: 15,
attribution: '&copy; <a href="https://www.strava.com" target="_blank">Strava</a>'
},
stravaHeatmapWater: {
type: 'raster',
tiles: [],
tileSize: 1024,
maxzoom: 15,
attribution: '&copy; <a href="https://www.strava.com" target="_blank">Strava</a>'
},
stravaHeatmapWinter: {
type: 'raster',
tiles: [],
tileSize: 1024,
maxzoom: 15,
attribution: '&copy; <a href="https://www.strava.com" target="_blank">Strava</a>'
source: 'waymarkedTrailsWinter',
}],
},
};
@@ -511,6 +623,7 @@ export const basemapTree: LayerTreeType = {
},
france: {
ignFrPlan: true,
ignFrTopo: true,
ignFrScan25: true,
ignFrSatellite: true,
},
@@ -526,6 +639,7 @@ export const basemapTree: LayerTreeType = {
},
sweden: {
swedenTopo: true,
swedenSatellite: true,
},
switzerland: {
swisstopoRaster: true,
@@ -546,19 +660,6 @@ export const basemapTree: LayerTreeType = {
export const overlayTree: LayerTreeType = {
overlays: {
world: {
cyclOSM: {
cyclOSMlite: true,
},
/*strava: {
stravaHeatmapRun: true,
stravaHeatmapTrailRun: true,
stravaHeatmapHike: true,
stravaHeatmapRide: true,
stravaHeatmapGravel: true,
stravaHeatmapMTB: true,
stravaHeatmapWater: true,
stravaHeatmapWinter: true,
},*/
waymarked_trails: {
waymarkedTrailsHiking: true,
waymarkedTrailsCycling: true,
@@ -566,7 +667,9 @@ export const overlayTree: LayerTreeType = {
waymarkedTrailsSkating: true,
waymarkedTrailsHorseRiding: true,
waymarkedTrailsWinter: true,
}
},
cyclOSMlite: true,
bikerouterGravel: true,
},
countries: {
france: {
@@ -600,6 +703,7 @@ export const overpassTree: LayerTreeType = {
toilets: true,
"water": true,
shower: true,
shelter: true,
barrier: true
},
tourism: {
@@ -608,6 +712,7 @@ export const overpassTree: LayerTreeType = {
hotel: true,
campsite: true,
hut: true,
picnic: true,
summit: true,
pass: true,
climbing: true,
@@ -638,19 +743,6 @@ export const defaultBasemap = 'mapboxOutdoors';
export const defaultOverlays = {
overlays: {
world: {
cyclOSM: {
cyclOSMlite: false,
},
/*strava: {
stravaHeatmapRun: false,
stravaHeatmapTrailRun: false,
stravaHeatmapHike: false,
stravaHeatmapRide: false,
stravaHeatmapGravel: false,
stravaHeatmapMTB: false,
stravaHeatmapWater: false,
stravaHeatmapWinter: false,
},*/
waymarked_trails: {
waymarkedTrailsHiking: false,
waymarkedTrailsCycling: false,
@@ -658,7 +750,9 @@ export const defaultOverlays = {
waymarkedTrailsSkating: false,
waymarkedTrailsHorseRiding: false,
waymarkedTrailsWinter: false,
}
},
cyclOSMlite: false,
bikerouterGravel: false,
},
countries: {
france: {
@@ -692,6 +786,7 @@ export const defaultOverpassQueries: LayerTreeType = {
toilets: false,
"water": false,
shower: false,
shelter: false,
barrier: false
},
tourism: {
@@ -700,6 +795,7 @@ export const defaultOverpassQueries: LayerTreeType = {
hotel: false,
campsite: false,
hut: false,
picnic: false,
summit: false,
pass: false,
climbing: false
@@ -746,6 +842,7 @@ export const defaultBasemapTree: LayerTreeType = {
},
france: {
ignFrPlan: false,
ignFrTopo: false,
ignFrScan25: false,
ignFrSatellite: false,
},
@@ -761,6 +858,7 @@ export const defaultBasemapTree: LayerTreeType = {
},
sweden: {
swedenTopo: false,
swedenSatellite: false,
},
switzerland: {
swisstopoRaster: false,
@@ -781,19 +879,6 @@ export const defaultBasemapTree: LayerTreeType = {
export const defaultOverlayTree: LayerTreeType = {
overlays: {
world: {
cyclOSM: {
cyclOSMlite: false,
},
/*strava: {
stravaHeatmapRun: true,
stravaHeatmapTrailRun: true,
stravaHeatmapHike: true,
stravaHeatmapRide: true,
stravaHeatmapGravel: true,
stravaHeatmapMTB: true,
stravaHeatmapWater: true,
stravaHeatmapWinter: true,
},*/
waymarked_trails: {
waymarkedTrailsHiking: true,
waymarkedTrailsCycling: true,
@@ -801,7 +886,9 @@ export const defaultOverlayTree: LayerTreeType = {
waymarkedTrailsSkating: false,
waymarkedTrailsHorseRiding: false,
waymarkedTrailsWinter: false,
}
},
cyclOSMlite: false,
bikerouterGravel: false,
},
countries: {
france: {
@@ -835,6 +922,7 @@ export const defaultOverpassTree: LayerTreeType = {
toilets: true,
"water": true,
shower: false,
shelter: false,
barrier: false
},
tourism: {
@@ -843,6 +931,7 @@ export const defaultOverpassTree: LayerTreeType = {
hotel: true,
campsite: true,
hut: true,
picnic: false,
summit: true,
pass: true,
climbing: false
@@ -882,17 +971,19 @@ type OverpassQueryData = {
color: string,
},
tags: Record<string, string | boolean | string[]> | Record<string, string | boolean | string[]>[],
symbol?: string,
};
export const overpassQueryData: Record<string, OverpassQueryData> = {
"bakery": {
bakery: {
icon: {
svg: Croissant,
color: "Coral",
},
tags: {
shop: "bakery"
}
},
symbol: "Convenience Store"
},
"food-store": {
icon: {
@@ -901,7 +992,8 @@ export const overpassQueryData: Record<string, OverpassQueryData> = {
},
tags: {
shop: ["supermarket", "convenience"],
}
},
symbol: "Convenience Store"
},
"eat-and-drink": {
icon: {
@@ -910,16 +1002,18 @@ export const overpassQueryData: Record<string, OverpassQueryData> = {
},
tags: {
amenity: ["restaurant", "fast_food", "cafe", "pub", "bar"]
}
},
"toilets": {
symbol: "Restaurant"
},
toilets: {
icon: {
svg: Droplet,
color: "DeepSkyBlue",
},
tags: {
amenity: "toilets"
}
},
symbol: "Restroom"
},
water: {
icon: {
@@ -931,7 +1025,8 @@ export const overpassQueryData: Record<string, OverpassQueryData> = {
}, {
natural: "spring",
drinking_water: "yes"
}]
}],
symbol: "Drinking Water"
},
shower: {
icon: {
@@ -940,7 +1035,18 @@ export const overpassQueryData: Record<string, OverpassQueryData> = {
},
tags: {
amenity: "shower"
}
},
symbol: "Shower"
},
shelter: {
icon: {
svg: Tent,
color: "#000000",
},
tags: {
amenity: "shelter"
},
symbol: "Shelter"
},
"fuel-station": {
icon: {
@@ -949,7 +1055,8 @@ export const overpassQueryData: Record<string, OverpassQueryData> = {
},
tags: {
amenity: "fuel"
}
},
symbol: "Gas Station"
},
parking: {
icon: {
@@ -958,7 +1065,8 @@ export const overpassQueryData: Record<string, OverpassQueryData> = {
},
tags: {
amenity: "parking"
}
},
symbol: "Parking Area"
},
garage: {
icon: {
@@ -967,7 +1075,8 @@ export const overpassQueryData: Record<string, OverpassQueryData> = {
},
tags: {
shop: ["car_repair", "motorcycle_repair"]
}
},
symbol: "Car Repair"
},
barrier: {
icon: {
@@ -989,12 +1098,13 @@ export const overpassQueryData: Record<string, OverpassQueryData> = {
},
viewpoint: {
icon: {
svg: Telescope,
svg: Binoculars,
color: "Green",
},
tags: {
tourism: "viewpoint"
}
},
symbol: "Scenic Area"
},
hotel: {
icon: {
@@ -1003,7 +1113,8 @@ export const overpassQueryData: Record<string, OverpassQueryData> = {
},
tags: {
tourism: ["hotel", "hostel", "guest_house", "motel"]
}
},
symbol: "Hotel"
},
campsite: {
icon: {
@@ -1012,7 +1123,8 @@ export const overpassQueryData: Record<string, OverpassQueryData> = {
},
tags: {
tourism: "camp_site"
}
},
symbol: "Campground"
},
hut: {
icon: {
@@ -1021,7 +1133,18 @@ export const overpassQueryData: Record<string, OverpassQueryData> = {
},
tags: {
tourism: ["alpine_hut", "wilderness_hut"]
}
},
symbol: "Lodge"
},
picnic: {
icon: {
svg: Utensils,
color: "Green",
},
tags: {
tourism: "picnic_site"
},
symbol: "Picnic Area"
},
summit: {
icon: {
@@ -1030,7 +1153,8 @@ export const overpassQueryData: Record<string, OverpassQueryData> = {
},
tags: {
natural: "peak"
}
},
symbol: "Summit"
},
pass: {
icon: {
@@ -1057,7 +1181,8 @@ export const overpassQueryData: Record<string, OverpassQueryData> = {
},
tags: {
amenity: "bicycle_parking"
}
},
symbol: "Parking Area"
},
"bicycle-rental": {
icon: {
@@ -1084,7 +1209,8 @@ export const overpassQueryData: Record<string, OverpassQueryData> = {
},
tags: {
railway: "station"
}
},
symbol: "Ground Transportation"
},
"tram-stop": {
icon: {
@@ -1094,6 +1220,7 @@ export const overpassQueryData: Record<string, OverpassQueryData> = {
tags: {
railway: "tram_stop"
},
symbol: "Ground Transportation"
},
"bus-stop": {
icon: {
@@ -1103,7 +1230,8 @@ export const overpassQueryData: Record<string, OverpassQueryData> = {
tags: {
"public_transport": ["stop_position", "platform"],
bus: "yes"
}
},
symbol: "Ground Transportation"
},
ferry: {
icon: {
@@ -1112,18 +1240,7 @@ export const overpassQueryData: Record<string, OverpassQueryData> = {
},
tags: {
amenity: "ferry_terminal"
}
},
symbol: "Anchor"
}
};
export const stravaHeatmapServers = ['https://heatmap-external-a.strava.com/tiles-auth', 'https://heatmap-external-b.strava.com/tiles-auth', 'https://heatmap-external-c.strava.com/tiles-auth'];
export const stravaHeatmapActivityIds: { [key: string]: string } = {
stravaHeatmapRun: 'sport_Run',
stravaHeatmapTrailRun: 'sport_TrailRun',
stravaHeatmapHike: 'sport_Hike',
stravaHeatmapRide: 'sport_Ride',
stravaHeatmapGravel: 'sport_GravelRide',
stravaHeatmapMTB: 'sport_MountainBikeRide',
stravaHeatmapWater: 'water',
stravaHeatmapWinter: 'winter',
}

View File

@@ -0,0 +1,60 @@
import { Landmark, Icon, Shell, Bike, Building, Tent, Car, Wrench, ShoppingBasket, Droplet, DoorOpen, Trees, Fuel, Home, Info, TreeDeciduous, CircleParking, Cross, Utensils, Construction, BrickWall, ShowerHead, Mountain, Phone, TrainFront, Bed, Binoculars, TriangleAlert, Anchor } from "lucide-svelte";
import { Landmark as LandmarkSvg, Shell as ShellSvg, Bike as BikeSvg, Building as BuildingSvg, Tent as TentSvg, Car as CarSvg, Wrench as WrenchSvg, ShoppingBasket as ShoppingBasketSvg, Droplet as DropletSvg, DoorOpen as DoorOpenSvg, Trees as TreesSvg, Fuel as FuelSvg, Home as HomeSvg, Info as InfoSvg, TreeDeciduous as TreeDeciduousSvg, CircleParking as CircleParkingSvg, Cross as CrossSvg, Utensils as UtensilsSvg, Construction as ConstructionSvg, BrickWall as BrickWallSvg, ShowerHead as ShowerHeadSvg, Mountain as MountainSvg, Phone as PhoneSvg, TrainFront as TrainFrontSvg, Bed as BedSvg, Binoculars as BinocularsSvg, TriangleAlert as TriangleAlertSvg, Anchor as AnchorSvg } from "lucide-static";
import type { ComponentType } from "svelte";
export type Symbol = {
value: string;
icon?: ComponentType<Icon>;
iconSvg?: string;
};
export const symbols: { [key: string]: Symbol } = {
alert: { value: 'Alert', icon: TriangleAlert, iconSvg: TriangleAlertSvg },
anchor: { value: 'Anchor', icon: Anchor, iconSvg: AnchorSvg },
bank: { value: 'Bank', icon: Landmark, iconSvg: LandmarkSvg },
beach: { value: 'Beach', icon: Shell, iconSvg: ShellSvg },
bike_trail: { value: 'Bike Trail', icon: Bike, iconSvg: BikeSvg },
binoculars: { value: 'Binoculars', icon: Binoculars, iconSvg: BinocularsSvg },
bridge: { value: 'Bridge' },
building: { value: 'Building', icon: Building, iconSvg: BuildingSvg },
campground: { value: 'Campground', icon: Tent, iconSvg: TentSvg },
car: { value: 'Car', icon: Car, iconSvg: CarSvg },
car_repair: { value: 'Car Repair', icon: Wrench, iconSvg: WrenchSvg },
convenience_store: { value: 'Convenience Store', icon: ShoppingBasket, iconSvg: ShoppingBasketSvg },
crossing: { value: 'Crossing' },
department_store: { value: 'Department Store', icon: ShoppingBasket, iconSvg: ShoppingBasketSvg },
drinking_water: { value: 'Drinking Water', icon: Droplet, iconSvg: DropletSvg },
exit: { value: 'Exit', icon: DoorOpen, iconSvg: DoorOpenSvg },
lodge: { value: 'Lodge', icon: Home, iconSvg: HomeSvg },
lodging: { value: 'Lodging', icon: Bed, iconSvg: BedSvg },
forest: { value: 'Forest', icon: Trees, iconSvg: TreesSvg },
gas_station: { value: 'Gas Station', icon: Fuel, iconSvg: FuelSvg },
ground_transportation: { value: 'Ground Transportation', icon: TrainFront, iconSvg: TrainFrontSvg },
hotel: { value: 'Hotel', icon: Bed, iconSvg: BedSvg },
house: { value: 'House', icon: Home, iconSvg: HomeSvg },
information: { value: 'Information', icon: Info, iconSvg: InfoSvg },
park: { value: 'Park', icon: TreeDeciduous, iconSvg: TreeDeciduousSvg },
parking_area: { value: 'Parking Area', icon: CircleParking, iconSvg: CircleParkingSvg },
pharmacy: { value: 'Pharmacy', icon: Cross, iconSvg: CrossSvg },
picnic_area: { value: 'Picnic Area', icon: Utensils, iconSvg: UtensilsSvg },
restaurant: { value: 'Restaurant', icon: Utensils, iconSvg: UtensilsSvg },
restricted_area: { value: 'Restricted Area', icon: Construction, iconSvg: ConstructionSvg },
restroom: { value: 'Restroom' },
road: { value: 'Road', icon: BrickWall, iconSvg: BrickWallSvg },
scenic_area: { value: 'Scenic Area', icon: Binoculars, iconSvg: BinocularsSvg },
shelter: { value: 'Shelter', icon: Tent, iconSvg: TentSvg },
shopping_center: { value: 'Shopping Center', icon: ShoppingBasket },
shower: { value: 'Shower', icon: ShowerHead, iconSvg: ShowerHeadSvg },
summit: { value: 'Summit', icon: Mountain, iconSvg: MountainSvg },
telephone: { value: 'Telephone', icon: Phone, iconSvg: PhoneSvg },
tunnel: { value: 'Tunnel' },
water_source: { value: 'Water Source', icon: Droplet, iconSvg: DropletSvg },
};
export function getSymbolKey(value: string | undefined): string | undefined {
if (value === undefined) {
return undefined;
} else {
return Object.keys(symbols).find(key => symbols[key].value === value);
}
}

View File

@@ -40,6 +40,7 @@
import { DateFormatter } from '@internationalized/date';
import type { GPXStatistics } from 'gpx';
import { settings } from '$lib/db';
import { mode } from 'mode-watcher';
export let gpxStatistics: Writable<GPXStatistics>;
export let slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>;
@@ -72,6 +73,7 @@
let marker: mapboxgl.Marker | null = null;
let dragging = false;
let panning = false;
let options = {
animation: false,
@@ -102,7 +104,8 @@
line: {
pointRadius: 0,
tension: 0.4,
borderWidth: 2
borderWidth: 2,
cubicInterpolationMode: 'monotone'
}
},
interaction: {
@@ -118,7 +121,7 @@
enabled: true
},
tooltip: {
enabled: () => !dragging,
enabled: () => !dragging && !panning,
callbacks: {
title: function () {
return '';
@@ -176,6 +179,48 @@
return labels;
}
}
},
zoom: {
pan: {
enabled: true,
mode: 'x',
modifierKey: 'shift',
onPanStart: function () {
// hide tooltip
panning = true;
$slicedGPXStatistics = undefined;
},
onPanComplete: function () {
panning = false;
}
},
zoom: {
wheel: {
enabled: true
},
mode: 'x',
onZoomStart: function ({ chart, event }: { chart: Chart; event: any }) {
if (
event.deltaY < 0 &&
Math.abs(
chart.getInitialScaleBounds().x.max / chart.options.plugins.zoom.limits.x.minRange -
chart.getZoomLevel()
) < 0.01
) {
// Disable wheel pan if zoomed in to the max, and zooming in
return false;
}
$slicedGPXStatistics = undefined;
}
},
limits: {
x: {
min: 'original',
max: 'original',
minRange: 1
}
}
}
},
stacked: false,
@@ -248,7 +293,9 @@
}
};
onMount(() => {
onMount(async () => {
Chart.register((await import('chartjs-plugin-zoom')).default); // dynamic import to avoid SSR and 'window is not defined' error
chart = new Chart(canvas, {
type: 'line',
data: {
@@ -312,6 +359,10 @@
let dragStarted = false;
function onMouseDown(evt) {
if (evt.shiftKey) {
// Panning interaction
return;
}
dragStarted = true;
canvas.style.cursor = 'col-resize';
startIndex = getIndex(evt);
@@ -525,7 +576,8 @@
// Draw selection rectangle
let selectionContext = overlay.getContext('2d');
if (selectionContext) {
selectionContext.globalAlpha = 0.1;
selectionContext.fillStyle = $mode === 'dark' ? 'white' : 'black';
selectionContext.globalAlpha = $mode === 'dark' ? 0.2 : 0.1;
selectionContext.clearRect(0, 0, overlay.width, overlay.height);
let startPixel = chart.scales.x.getPixelForValue(
@@ -550,7 +602,7 @@
}
}
$: $slicedGPXStatistics, updateOverlay();
$: $slicedGPXStatistics, $mode, updateOverlay();
onDestroy(() => {
if (chart) {

View File

@@ -1,24 +1,75 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Label } from '$lib/components/ui/label';
import { Checkbox } from '$lib/components/ui/checkbox';
import { Separator } from '$lib/components/ui/separator';
import { Dialog } from 'bits-ui';
import {
currentTool,
exportAllFiles,
exportSelectedFiles,
ExportState,
exportState
exportState,
gpxStatistics
} from '$lib/stores';
import { fileObservers } from '$lib/db';
import { Download } from 'lucide-svelte';
import {
Download,
Zap,
BrickWall,
HeartPulse,
Orbit,
Thermometer,
SquareActivity
} from 'lucide-svelte';
import { _ } from 'svelte-i18n';
import { selection } from './file-list/Selection';
import { get } from 'svelte/store';
import { GPXStatistics } from 'gpx';
import { ListRootItem } from './file-list/FileList';
let open = false;
let exportOptions: Record<string, boolean> = {
time: true,
surface: true,
hr: true,
cad: true,
atemp: true,
power: true
};
let hide: Record<string, boolean> = {
time: false,
surface: false,
hr: false,
cad: false,
atemp: false,
power: false
};
$: if ($exportState !== ExportState.NONE) {
open = true;
$currentTool = null;
let statistics = $gpxStatistics;
if ($exportState === ExportState.ALL) {
statistics = Array.from($fileObservers.values())
.map((file) => get(file)?.statistics)
.reduce((acc, cur) => {
if (cur !== undefined) {
acc.mergeWith(cur.getStatisticsFor(new ListRootItem()));
}
return acc;
}, new GPXStatistics());
}
hide.time = statistics.global.time.total === 0;
hide.hr = statistics.global.hr.count === 0;
hide.cad = statistics.global.cad.count === 0;
hide.atemp = statistics.global.atemp.count === 0;
hide.power = statistics.global.power.count === 0;
}
$: exclude = Object.keys(exportOptions).filter((key) => !exportOptions[key]);
</script>
<Dialog.Root
@@ -32,9 +83,11 @@
<Dialog.Trigger class="hidden" />
<Dialog.Portal>
<Dialog.Content
class="fixed left-[50%] top-[50%] z-50 w-fit max-w-full translate-x-[-50%] translate-y-[-50%] flex flex-col items-center gap-2 border bg-background p-3 shadow-lg rounded-md"
class="fixed left-[50%] top-[50%] z-50 w-fit max-w-full translate-x-[-50%] translate-y-[-50%] flex flex-col items-center gap-3 border bg-background p-3 shadow-lg rounded-md"
>
<div
class="w-full flex flex-row items-center justify-center gap-4 border rounded-md p-2 bg-accent"
>
<div class="flex flex-row items-center gap-4 border rounded-md p-2">
<span>⚠️</span>
<span class="max-w-96 text-sm">
{$_('menu.support_message')}
@@ -50,9 +103,9 @@
class="grow"
on:click={() => {
if ($exportState === ExportState.SELECTION) {
exportSelectedFiles();
exportSelectedFiles(exclude);
} else if ($exportState === ExportState.ALL) {
exportAllFiles();
exportAllFiles(exclude);
}
open = false;
$exportState = ExportState.NONE;
@@ -66,6 +119,63 @@
{/if}
</Button>
</div>
<div class="w-full max-w-xl flex flex-col items-center gap-2">
<div class="w-full flex flex-row items-center gap-3">
<div class="grow">
<Separator />
</div>
<Label class="shrink-0">
{$_('menu.export_options')}
</Label>
<div class="grow">
<Separator />
</div>
</div>
<div class="flex flex-row flex-wrap justify-center gap-x-6 gap-y-2">
<div class="flex flex-row items-center gap-1.5 {hide.time ? 'hidden' : ''}">
<Checkbox id="export-time" bind:checked={exportOptions.time} />
<Label for="export-time" class="flex flex-row items-center gap-1">
<Zap size="16" />
{$_('quantities.time')}
</Label>
</div>
<div class="flex flex-row items-center gap-1.5">
<Checkbox id="export-surface" bind:checked={exportOptions.surface} />
<Label for="export-surface" class="flex flex-row items-center gap-1">
<BrickWall size="16" />
{$_('quantities.surface')}
</Label>
</div>
<div class="flex flex-row items-center gap-1.5 {hide.hr ? 'hidden' : ''}">
<Checkbox id="export-heartrate" bind:checked={exportOptions.hr} />
<Label for="export-heartrate" class="flex flex-row items-center gap-1">
<HeartPulse size="16" />
{$_('quantities.heartrate')}
</Label>
</div>
<div class="flex flex-row items-center gap-1.5 {hide.cad ? 'hidden' : ''}">
<Checkbox id="export-cadence" bind:checked={exportOptions.cad} />
<Label for="export-cadence" class="flex flex-row items-center gap-1">
<Orbit size="16" />
{$_('quantities.cadence')}
</Label>
</div>
<div class="flex flex-row items-center gap-1.5 {hide.atemp ? 'hidden' : ''}">
<Checkbox id="export-temperature" bind:checked={exportOptions.atemp} />
<Label for="export-temperature" class="flex flex-row items-center gap-1">
<Thermometer size="16" />
{$_('quantities.temperature')}
</Label>
</div>
<div class="flex flex-row items-center gap-1.5 {hide.power ? 'hidden' : ''}">
<Checkbox id="export-power" bind:checked={exportOptions.power} />
<Label for="export-power" class="flex flex-row items-center gap-1">
<SquareActivity size="16" />
{$_('quantities.power')}
</Label>
</div>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>

View File

@@ -1,60 +0,0 @@
<script lang="ts">
import { base } from '$app/paths';
import { page } from '$app/stores';
import { languages } from '$lib/languages';
import { _, isLoading } from 'svelte-i18n';
$: location = $page.route.id?.split('/')[2] ?? 'home';
</script>
<svelte:head>
{#if $isLoading}
<title>gpx.studio — the online GPX file editor</title>
<meta
name="description"
content="View, edit and create GPX files online with advanced route planning capabilities and file processing tools, beautiful maps and detailed data visualizations."
/>
<meta property="og:title" content="gpx.studio — the online GPX file editor" />
<meta
property="og:description"
content="View, edit and create GPX files online with advanced route planning capabilities and file processing tools, beautiful maps and detailed data visualizations."
/>
<meta name="twitter:title" content="gpx.studio — the online GPX file editor" />
<meta
name="twitter:description"
content="View, edit and create GPX files online with advanced route planning capabilities and file processing tools, beautiful maps and detailed data visualizations."
/>
{:else}
<title>gpx.studio — {$_(`metadata.${location}_title`)}</title>
<meta name="description" content={$_('metadata.description')} />
<meta property="og:title" content="gpx.studio — {$_(`metadata.${location}_title`)}" />
<meta property="og:description" content={$_('metadata.description')} />
<meta name="twitter:title" content="gpx.studio — {$_(`metadata.${location}_title`)}" />
<meta name="twitter:description" content={$_('metadata.description')} />
{/if}
<meta property="og:image" content="https://gpx.studio/og_logo.png" />
<meta property="og:url" content="https://gpx.studio/" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="gpx.studio" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content="https://gpx.studio/og_logo.png" />
<meta name="twitter:url" content="https://gpx.studio/" />
<meta name="twitter:site" content="@gpxstudio" />
<meta name="twitter:creator" content="@gpxstudio" />
<link
rel="alternate"
hreflang="x-default"
href="https://gpx.studio{base}/{location === 'home' ? '' : location}"
/>
{#each Object.keys(languages) as lang}
<link
rel="alternate"
hreflang={lang}
href="https://gpx.studio{base}/{lang === 'en' ? '' : lang + '/'}{location === 'home'
? ''
: location}"
/>
{/each}
</svelte:head>

View File

@@ -1,11 +1,22 @@
<script>
<script lang="ts">
import { CircleHelp } from 'lucide-svelte';
import { _ } from 'svelte-i18n';
export let link: string | undefined = undefined;
</script>
<div
class="{$$props.class ||
''} text-sm bg-muted font-light rounded border flex flex-row items-center p-2"
>
<div class="text-sm bg-muted rounded border flex flex-row items-center p-2 {$$props.class || ''}">
<CircleHelp size="16" class="w-4 mr-2 shrink-0 grow-0" />
<div>
<slot />
{#if link}
<a
href={link}
target="_blank"
class="text-sm text-blue-500 dark:text-blue-300 hover:underline"
>
{$_('menu.more')}
</a>
{/if}
</div>
</div>

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { page } from '$app/stores';
import * as Select from '$lib/components/ui/select';
import { languages } from '$lib/languages';
import { getURLForLanguage } from '$lib/utils';
@@ -25,18 +26,26 @@
</Select.Trigger>
<Select.Content>
{#each Object.entries(languages) as [lang, label]}
<a href={getURLForLanguage(lang)}>
{#if $page.url.pathname.includes('404')}
<a href={getURLForLanguage(lang, '/')}>
<Select.Item value={lang}>{label}</Select.Item>
</a>
{:else}
<a href={getURLForLanguage(lang, $page.url.pathname)}>
<Select.Item value={lang}>{label}</Select.Item>
</a>
{/if}
{/each}
</Select.Content>
</Select.Root>
<!-- hidden links for svelte crawling -->
<div class="hidden">
{#if !$page.url.pathname.includes('404')}
{#each Object.entries(languages) as [lang, label]}
<a href={getURLForLanguage(lang)}>
<a href={getURLForLanguage(lang, $page.url.pathname)}>
{label}
</a>
{/each}
{/if}
</div>

View File

@@ -10,9 +10,9 @@
import { Button } from '$lib/components/ui/button';
import { map } from '$lib/stores';
import { settings } from '$lib/db';
import { locale, _ } from 'svelte-i18n';
import { get } from 'svelte/store';
import { _ } from 'svelte-i18n';
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
import { page } from '$app/stores';
export let accessToken = PUBLIC_MAPBOX_TOKEN;
export let geolocate = true;
@@ -41,12 +41,51 @@
return;
}
let language = $page.params.language;
if (language === 'zh') {
language = 'zh-Hans';
} else if (language?.includes('-')) {
language = language.split('-')[0];
} else if (language === '' || language === undefined) {
language = 'en';
}
let newMap = new mapboxgl.Map({
container: 'map',
style: { version: 8, sources: {}, layers: [] },
style: {
version: 8,
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=${PUBLIC_MAPBOX_TOKEN}`
}
},
{
id: 'basemap',
url: ''
},
{
id: 'overlays',
url: '',
data: {
version: 8,
sources: {},
layers: []
}
}
]
},
zoom: 0,
hash: hash,
language: get(locale),
language,
attributionControl: false,
logoPosition: 'bottom-right',
boxZoom: false
@@ -62,7 +101,11 @@
})
);
newMap.addControl(new mapboxgl.NavigationControl());
newMap.addControl(
new mapboxgl.NavigationControl({
visualizePitch: true
})
);
if (geocoder) {
newMap.addControl(
@@ -71,7 +114,7 @@
mapboxgl: mapboxgl,
collapsed: true,
flyTo: fitBoundsOptions,
language: get(locale)
language
})
);
}
@@ -121,14 +164,6 @@
});
}
});
// add dummy layer to place the overlay layers below
newMap.addLayer({
id: 'overlays',
type: 'background',
paint: {
'background-color': 'rgba(0, 0, 0, 0)'
}
});
});
});
@@ -150,7 +185,9 @@
<div {...$$restProps}>
<div id="map" class="h-full {webgl2Supported ? '' : 'hidden'}"></div>
<div
class="flex flex-col items-center justify-center gap-3 h-full {webgl2Supported ? 'hidden' : ''}"
class="flex flex-col items-center justify-center gap-3 h-full {webgl2Supported
? 'hidden'
: ''}"
>
<p>{$_('webgl2_required')}</p>
<Button href="https://get.webgl.org/webgl2/" target="_blank">

View File

@@ -22,7 +22,6 @@
Sun,
Moon,
Layers3,
MountainSnow,
GalleryVertical,
Languages,
Settings,
@@ -41,7 +40,9 @@
FolderOpen,
FileStack,
FileX,
BookOpenText
BookOpenText,
ChartArea,
Maximize
} from 'lucide-svelte';
import {
@@ -54,7 +55,8 @@
editMetadata,
editStyle,
exportState,
ExportState
ExportState,
centerMapOnSelection
} from '$lib/stores';
import {
copied,
@@ -247,6 +249,17 @@
{$_('menu.select_all')}
<Shortcut key="A" ctrl={true} />
</Menubar.Item>
<Menubar.Item
on:click={() => {
if ($selection.size > 0) {
centerMapOnSelection();
}
}}
>
<Maximize size="16" class="mr-1" />
{$_('menu.center')}
<Shortcut key="⏎" ctrl={true} />
</Menubar.Item>
{#if $verticalFileView}
<Menubar.Separator />
<Menubar.Item on:click={copySelection} disabled={$selection.size === 0}>
@@ -286,7 +299,7 @@
</Menubar.Trigger>
<Menubar.Content class="border-none">
<Menubar.CheckboxItem bind:checked={$elevationProfile}>
<MountainSnow size="16" class="mr-1" />
<ChartArea size="16" class="mr-1" />
{$_('menu.elevation_profile')}
<Shortcut key="P" ctrl={true} />
</Menubar.CheckboxItem>
@@ -333,6 +346,7 @@
<Menubar.RadioGroup bind:value={$distanceUnits}>
<Menubar.RadioItem value="metric">{$_('menu.metric')}</Menubar.RadioItem>
<Menubar.RadioItem value="imperial">{$_('menu.imperial')}</Menubar.RadioItem>
<Menubar.RadioItem value="nautical">{$_('menu.nautical')}</Menubar.RadioItem>
</Menubar.RadioGroup>
</Menubar.SubContent>
</Menubar.Sub>
@@ -367,7 +381,7 @@
<Menubar.SubContent>
<Menubar.RadioGroup bind:value={$locale}>
{#each Object.entries(languages) as [lang, label]}
<a href={getURLForLanguage(lang)}>
<a href={getURLForLanguage(lang, '/app')}>
<Menubar.RadioItem value={lang}>{label}</Menubar.RadioItem>
</a>
{/each}
@@ -499,12 +513,14 @@
dbUtils.undo();
}
} else if ((e.key === 'Backspace' || e.key === 'Delete') && (e.metaKey || e.ctrlKey)) {
if (!targetInput) {
if (e.shiftKey) {
dbUtils.deleteAllFiles();
} else {
dbUtils.deleteSelection();
}
e.preventDefault();
}
} else if (e.key === 'a' && (e.metaKey || e.ctrlKey)) {
if (!targetInput) {
selectAll();
@@ -533,6 +549,10 @@
dbUtils.setHiddenToSelection(true);
}
e.preventDefault();
} else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
if ($selection.size > 0) {
centerMapOnSelection();
}
} else if (e.key === 'F1') {
switchBasemaps();
e.preventDefault();

View File

@@ -8,14 +8,19 @@
export let click: boolean = false;
let isMac = false;
let isSafari = false;
onMount(() => {
isMac = navigator.userAgent.toUpperCase().indexOf('MAC') >= 0;
isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
});
</script>
<span class="ml-auto pl-2 text-xs tracking-widest text-muted-foreground"
>{shift ? '⇧' : ''}{ctrl ? (isMac ? '⌘' : $_('menu.ctrl') + '+') : ''}{key}{click
? $_('menu.click')
: ''}</span
<div
class="ml-auto pl-2 text-xs tracking-widest text-muted-foreground flex flex-row gap-0 items-baseline"
>
<span>{shift ? '⇧' : ''}</span>
<span>{ctrl ? (isMac && !isSafari ? '⌘' : $_('menu.ctrl') + '+') : ''}</span>
<span class={key === '+' ? 'font-medium text-sm/4' : ''}>{key}</span>
<span>{click ? $_('menu.click') : ''}</span>
</div>

View File

@@ -1,29 +0,0 @@
<script lang="ts">
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { settings } from '$lib/db';
const { showWelcomeMessage } = settings;
</script>
<AlertDialog.Root
open={$showWelcomeMessage === true}
closeOnEscape={false}
closeOnOutsideClick={false}
onOpenChange={() => ($showWelcomeMessage = false)}
>
<AlertDialog.Trigger class="hidden"></AlertDialog.Trigger>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>
Welcome to the new version of <b>gpx.studio</b>!
</AlertDialog.Title>
<AlertDialog.Description class="space-y-1">
<p>The website is still under development and may contain bugs.</p>
<p>Please report any issues you find by email or on GitHub.</p>
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Action>Let's go!</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>

View File

@@ -2,9 +2,12 @@
import { settings } from '$lib/db';
import {
celsiusToFahrenheit,
distancePerHourToSecondsPerDistance,
kilometersToMiles,
metersToFeet,
getConvertedDistance,
getConvertedElevation,
getConvertedVelocity,
getDistanceUnits,
getElevationUnits,
getVelocityUnits,
secondsToHHMMSS
} from '$lib/units';
@@ -20,31 +23,18 @@
<span class={$$props.class}>
{#if type === 'distance'}
{#if $distanceUnits === 'metric'}
{value.toFixed(decimals ?? 2)} {showUnits ? $_('units.kilometers') : ''}
{:else}
{kilometersToMiles(value).toFixed(decimals ?? 2)} {showUnits ? $_('units.miles') : ''}
{/if}
{getConvertedDistance(value, $distanceUnits).toFixed(decimals ?? 2)}
{showUnits ? getDistanceUnits($distanceUnits) : ''}
{:else if type === 'elevation'}
{#if $distanceUnits === 'metric'}
{value.toFixed(decimals ?? 0)} {showUnits ? $_('units.meters') : ''}
{:else}
{metersToFeet(value).toFixed(decimals ?? 0)} {showUnits ? $_('units.feet') : ''}
{/if}
{getConvertedElevation(value, $distanceUnits).toFixed(decimals ?? 0)}
{showUnits ? getElevationUnits($distanceUnits) : ''}
{:else if type === 'speed'}
{#if $distanceUnits === 'metric'}
{#if $velocityUnits === 'speed'}
{value.toFixed(decimals ?? 2)} {showUnits ? $_('units.kilometers_per_hour') : ''}
{getConvertedVelocity(value, $velocityUnits, $distanceUnits).toFixed(decimals ?? 2)}
{showUnits ? getVelocityUnits($velocityUnits, $distanceUnits) : ''}
{:else}
{secondsToHHMMSS(distancePerHourToSecondsPerDistance(value))}
{showUnits ? $_('units.minutes_per_kilometer') : ''}
{/if}
{:else if $velocityUnits === 'speed'}
{kilometersToMiles(value).toFixed(decimals ?? 2)}
{showUnits ? $_('units.miles_per_hour') : ''}
{:else}
{secondsToHHMMSS(distancePerHourToSecondsPerDistance(kilometersToMiles(value)))}
{showUnits ? $_('units.minutes_per_mile') : ''}
{secondsToHHMMSS(getConvertedVelocity(value, $velocityUnits, $distanceUnits))}
{showUnits ? getVelocityUnits($velocityUnits, $distanceUnits) : ''}
{/if}
{:else if type === 'temperature'}
{#if $temperatureUnits === 'celsius'}

View File

@@ -1,4 +1,7 @@
<script lang="ts">
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import { _, locale } from 'svelte-i18n';
export let path: string;
@@ -10,14 +13,18 @@
const modules = import.meta.glob('/src/lib/docs/**/*.mdx');
function loadModule(path: string) {
modules[path]().then((mod) => {
modules[path]?.().then((mod) => {
module = mod.default;
metadata = mod.metadata;
});
}
$: if ($locale) {
if (modules.hasOwnProperty(`/src/lib/docs/${$locale}/${path}`)) {
loadModule(`/src/lib/docs/${$locale}/${path}`);
} else if (browser) {
goto(`${base}/404`);
}
}
</script>
@@ -25,7 +32,7 @@
{#if titleOnly}
{metadata.title}
{:else}
<div class="markdown space-y-3">
<div class="markdown flex flex-col gap-3">
<svelte:component this={module} />
</div>
{/if}
@@ -40,14 +47,13 @@
@apply text-foreground;
@apply text-3xl;
@apply font-semibold;
@apply mb-6;
@apply mb-3 pt-6;
}
:global(.markdown h2) {
@apply text-foreground;
@apply text-2xl;
@apply font-semibold;
@apply mb-3;
@apply pt-3;
}
@@ -58,7 +64,7 @@
@apply pt-1.5;
}
:global(.markdown p > button) {
:global(.markdown p > button, .markdown li > button) {
@apply border;
@apply rounded-md;
@apply px-1;

View File

@@ -9,6 +9,7 @@ export const guides: Record<string, string[]> = {
'map-controls': [],
'gpx': [],
'integration': [],
'faq': [],
};
export const guideIcons: Record<string, string | ComponentType<Icon>> = {
@@ -31,6 +32,7 @@ export const guideIcons: Record<string, string | ComponentType<Icon>> = {
"map-controls": "🗺",
"gpx": "💾",
"integration": "{ 👩‍💻 }",
"faq": "🔮",
};
export function getPreviousGuide(currentGuide: string): string | undefined {
@@ -51,12 +53,16 @@ export function getPreviousGuide(currentGuide: string): string | undefined {
return `${previousGuide}/${guides[previousGuide][guides[previousGuide].length - 1]}`;
}
} else {
if (guides.hasOwnProperty(subguides[0])) {
let subguideIndex = guides[subguides[0]].indexOf(subguides[1]);
if (subguideIndex > 0) {
return `${subguides[0]}/${guides[subguides[0]][subguideIndex - 1]}`;
} else {
return subguides[0];
}
} else {
return undefined;
}
}
}
@@ -64,6 +70,7 @@ export function getNextGuide(currentGuide: string): string | undefined {
let subguides = currentGuide.split('/');
if (subguides.length === 1) {
if (guides.hasOwnProperty(currentGuide)) {
if (guides[currentGuide].length === 0) {
let keys = Object.keys(guides);
let index = keys.indexOf(currentGuide);
@@ -72,6 +79,10 @@ export function getNextGuide(currentGuide: string): string | undefined {
return `${currentGuide}/${guides[currentGuide][0]}`;
}
} else {
return undefined;
}
} else {
if (guides.hasOwnProperty(subguides[0])) {
let subguideIndex = guides[subguides[0]].indexOf(subguides[1]);
if (subguideIndex < guides[subguides[0]].length - 1) {
return `${subguides[0]}/${guides[subguides[0]][subguideIndex + 1]}`;
@@ -80,5 +91,8 @@ export function getNextGuide(currentGuide: string): string | undefined {
let index = keys.indexOf(subguides[0]);
return keys[index + 1];
}
} else {
return undefined;
}
}
}

View File

@@ -6,7 +6,6 @@
import Map from '$lib/components/Map.svelte';
import LayerControl from '$lib/components/layer-control/LayerControl.svelte';
import OpenIn from '$lib/components/embedding/OpenIn.svelte';
import {
gpxStatistics,
slicedGPXStatistics,
@@ -22,6 +21,7 @@
import { selection } from '$lib/components/file-list/Selection';
import { ListFileItem } from '$lib/components/file-list/FileList';
import { allowedEmbeddingBasemaps, type EmbeddingOptions } from './Embedding';
import { mode, setMode } from 'mode-watcher';
$embedding = true;
@@ -44,7 +44,8 @@
directionMarkers: false,
distanceUnits: 'metric',
velocityUnits: 'speed',
temperatureUnits: 'celsius'
temperatureUnits: 'celsius',
theme: 'system'
};
function applyOptions() {
@@ -160,6 +161,10 @@
if (options.temperatureUnits !== $temperatureUnits) {
$temperatureUnits = options.temperatureUnits;
}
if (options.theme !== $mode) {
setMode(options.theme);
}
}
onMount(() => {
@@ -168,6 +173,7 @@
prevSettings.distanceUnits = $distanceUnits;
prevSettings.velocityUnits = $velocityUnits;
prevSettings.temperatureUnits = $temperatureUnits;
prevSettings.theme = $mode ?? 'system';
});
$: if (options) {
@@ -199,6 +205,10 @@
$temperatureUnits = prevSettings.temperatureUnits;
}
if ($mode !== prevSettings.theme) {
setMode(prevSettings.theme);
}
$selection.clear();
$fileObservers.clear();
$fileOrder = $fileOrder.filter((id) => !id.includes('embed'));

View File

@@ -1,4 +1,5 @@
import { basemaps } from "$lib/assets/layers";
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
import { basemaps } from '$lib/assets/layers';
export type EmbeddingOptions = {
token: string;
@@ -6,20 +7,21 @@ export type EmbeddingOptions = {
basemap: string;
elevation: {
show: boolean;
height: number,
controls: boolean,
fill: 'slope' | 'surface' | undefined,
speed: boolean,
hr: boolean,
cad: boolean,
temp: boolean,
power: boolean,
},
distanceMarkers: boolean,
directionMarkers: boolean,
distanceUnits: 'metric' | 'imperial',
velocityUnits: 'speed' | 'pace',
temperatureUnits: 'celsius' | 'fahrenheit',
height: number;
controls: boolean;
fill: 'slope' | 'surface' | undefined;
speed: boolean;
hr: boolean;
cad: boolean;
temp: boolean;
power: boolean;
};
distanceMarkers: boolean;
directionMarkers: boolean;
distanceUnits: 'metric' | 'imperial' | 'nautical';
velocityUnits: 'speed' | 'pace';
temperatureUnits: 'celsius' | 'fahrenheit';
theme: 'system' | 'light' | 'dark';
};
export const defaultEmbeddingOptions = {
@@ -35,23 +37,46 @@ export const defaultEmbeddingOptions = {
hr: false,
cad: false,
temp: false,
power: false,
power: false
},
distanceMarkers: false,
directionMarkers: false,
distanceUnits: 'metric',
velocityUnits: 'speed',
temperatureUnits: 'celsius',
theme: 'system'
};
export function getDefaultEmbeddingOptions(): EmbeddingOptions {
return JSON.parse(JSON.stringify(defaultEmbeddingOptions));
}
export function getCleanedEmbeddingOptions(options: any, defaultOptions: any = defaultEmbeddingOptions): any {
export function getMergedEmbeddingOptions(
options: any,
defaultOptions: any = defaultEmbeddingOptions
): EmbeddingOptions {
const mergedOptions = JSON.parse(JSON.stringify(defaultOptions));
for (const key in options) {
if (typeof options[key] === 'object' && options[key] !== null && !Array.isArray(options[key])) {
mergedOptions[key] = getMergedEmbeddingOptions(options[key], defaultOptions[key]);
} else {
mergedOptions[key] = options[key];
}
}
return mergedOptions;
}
export function getCleanedEmbeddingOptions(
options: any,
defaultOptions: any = defaultEmbeddingOptions
): any {
const cleanedOptions = JSON.parse(JSON.stringify(options));
for (const key in cleanedOptions) {
if (typeof cleanedOptions[key] === 'object' && cleanedOptions[key] !== null && !Array.isArray(cleanedOptions[key])) {
if (
typeof cleanedOptions[key] === 'object' &&
cleanedOptions[key] !== null &&
!Array.isArray(cleanedOptions[key])
) {
cleanedOptions[key] = getCleanedEmbeddingOptions(cleanedOptions[key], defaultOptions[key]);
if (Object.keys(cleanedOptions[key]).length === 0) {
delete cleanedOptions[key];
@@ -63,4 +88,54 @@ export function getCleanedEmbeddingOptions(options: any, defaultOptions: any = d
return cleanedOptions;
}
export const allowedEmbeddingBasemaps = Object.keys(basemaps).filter(basemap => !['ordnanceSurvey'].includes(basemap));
export const allowedEmbeddingBasemaps = Object.keys(basemaps).filter(
(basemap) => !['ordnanceSurvey'].includes(basemap)
);
export function getURLForGoogleDriveFile(fileId: string): string {
return `https://www.googleapis.com/drive/v3/files/${fileId}?alt=media&key=AIzaSyA2ZadQob_hXiT2VaYIkAyafPvz_4ZMssk`;
}
export function convertOldEmbeddingOptions(options: URLSearchParams): any {
let newOptions: any = {
token: PUBLIC_MAPBOX_TOKEN,
files: []
};
if (options.has('state')) {
let state = JSON.parse(options.get('state')!);
if (state.ids) {
newOptions.files.push(...state.ids.map(getURLForGoogleDriveFile));
}
if (state.urls) {
newOptions.files.push(...state.urls);
}
}
if (options.has('source')) {
let basemap = options.get('source')!;
if (basemap === 'satellite') {
newOptions.basemap = 'mapboxSatellite';
} else if (basemap === 'otm') {
newOptions.basemap = 'openTopoMap';
} else if (basemap === 'ohm') {
newOptions.basemap = 'openHikingMap';
}
}
if (options.has('imperial')) {
newOptions.distanceUnits = 'imperial';
}
if (options.has('running')) {
newOptions.velocityUnits = 'pace';
}
if (options.has('distance')) {
newOptions.distanceMarkers = true;
}
if (options.has('direction')) {
newOptions.directionMarkers = true;
}
if (options.has('slope')) {
newOptions.elevation = {
fill: 'slope'
};
}
return newOptions;
}

View File

@@ -19,7 +19,8 @@
import {
allowedEmbeddingBasemaps,
getCleanedEmbeddingOptions,
getDefaultEmbeddingOptions
getDefaultEmbeddingOptions,
getURLForGoogleDriveFile
} from './Embedding';
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
import Embedding from './Embedding.svelte';
@@ -34,9 +35,13 @@
];
let files = options.files[0];
$: if (files) {
let driveIds = '';
$: if (files || driveIds) {
let urls = files.split(',');
urls = urls.filter((url) => url.length > 0);
let ids = driveIds.split(',');
ids = ids.filter((id) => id.length > 0);
urls.push(...ids.map(getURLForGoogleDriveFile));
if (JSON.stringify(urls) !== JSON.stringify(options.files)) {
options.files = urls;
}
@@ -94,6 +99,8 @@
<Input id="token" type="text" class="h-8" bind:value={options.token} />
<Label for="file_urls">{$_('embedding.file_urls')}</Label>
<Input id="file_urls" type="text" class="h-8" bind:value={files} />
<Label for="drive_ids">{$_('embedding.drive_ids')}</Label>
<Input id="drive_ids" type="text" class="h-8" bind:value={driveIds} />
<Label for="basemap">{$_('embedding.basemap')}</Label>
<Select.Root
selected={{ value: options.basemap, label: $_(`layers.label.${options.basemap}`) }}
@@ -120,7 +127,11 @@
<div class="grid grid-cols-2 gap-x-6 gap-y-3 rounded-md border p-3 mt-1">
<Label class="flex flex-row items-center gap-2">
{$_('embedding.height')}
<Input type="number" bind:value={options.elevation.height} class="h-8 w-20" />
<Input
type="number"
bind:value={options.elevation.height}
class="h-8 w-20"
/>
</Label>
<div class="flex flex-row items-center gap-2">
<span class="shrink-0">
@@ -142,7 +153,8 @@
</Select.Trigger>
<Select.Content>
<Select.Item value="slope">{$_('quantities.slope')}</Select.Item>
<Select.Item value="surface">{$_('quantities.surface')}</Select.Item>
<Select.Item value="surface">{$_('quantities.surface')}</Select.Item
>
<Select.Item value="none">{$_('embedding.none')}</Select.Item>
</Select.Content>
</Select.Root>
@@ -214,6 +226,10 @@
<RadioGroup.Item value="imperial" id="imperial" />
<Label for="imperial">{$_('menu.imperial')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="nautical" id="nautical" />
<Label for="nautical">{$_('menu.nautical')}</Label>
</div>
</RadioGroup.Root>
</Label>
<Label class="flex flex-col items-start gap-2">
@@ -243,6 +259,23 @@
</RadioGroup.Root>
</Label>
</div>
<Label class="flex flex-col items-start gap-2">
{$_('menu.mode')}
<RadioGroup.Root bind:value={options.theme} class="flex flex-row">
<div class="flex items-center space-x-2">
<RadioGroup.Item value="system" id="system" />
<Label for="system">{$_('menu.system')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="light" id="light" />
<Label for="light">{$_('menu.light')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="dark" id="dark" />
<Label for="dark">{$_('menu.dark')}</Label>
</div>
</RadioGroup.Root>
</Label>
<div class="flex flex-col gap-3 p-3 border rounded-md">
<div class="flex flex-row items-center gap-2">
<Checkbox id="manual-camera" bind:checked={manualCamera} />
@@ -286,7 +319,8 @@
<Label>
{$_('embedding.code')}
</Label>
<pre class="bg-primary text-primary-foreground p-3 rounded-md whitespace-normal break-all">
<pre
class="bg-primary text-primary-foreground p-3 rounded-md whitespace-normal break-all">
<code class="language-html">
{`<iframe src="https://gpx.studio${base}/embed?options=${encodeURIComponent(JSON.stringify(getCleanedEmbeddingOptions(options)))}${hash}" width="100%" height="600px" frameborder="0" style="outline: none;"/>`}
</code>

View File

@@ -15,6 +15,7 @@
EyeOff,
ClipboardCopy,
ClipboardPaste,
Maximize,
Scissors,
FileStack,
FileX
@@ -39,7 +40,15 @@
} from './Selection';
import { getContext } from 'svelte';
import { get } from 'svelte/store';
import { allHidden, editMetadata, editStyle, embedding, gpxLayers, map } from '$lib/stores';
import {
allHidden,
editMetadata,
editStyle,
embedding,
centerMapOnSelection,
gpxLayers,
map
} from '$lib/stores';
import {
GPXTreeElement,
Track,
@@ -275,9 +284,13 @@
{$_('menu.select_all')}
<Shortcut key="A" ctrl={true} />
</ContextMenu.Item>
<ContextMenu.Separator />
{/if}
{#if orientation === 'vertical'}
<ContextMenu.Item on:click={centerMapOnSelection}>
<Maximize size="16" class="mr-1" />
{$_('menu.center')}
<Shortcut key="⏎" ctrl={true} />
</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item on:click={dbUtils.duplicateSelection}>
<Copy size="16" class="mr-1" />
{$_('menu.duplicate')}
@@ -306,7 +319,6 @@
</ContextMenu.Item>
{/if}
<ContextMenu.Separator />
{/if}
<ContextMenu.Item on:click={dbUtils.deleteSelection}>
{#if item instanceof ListFileItem}
<FileX size="16" class="mr-1" />

View File

@@ -1,10 +1,9 @@
import { font } from "$lib/assets/layers";
import { settings } from "$lib/db";
import { gpxStatistics } from "$lib/stores";
import { get } from "svelte/store";
const { distanceMarkers, distanceUnits, currentBasemap } = settings;
const { distanceMarkers, distanceUnits } = settings;
export class DistanceMarkers {
map: mapboxgl.Map;
@@ -17,7 +16,7 @@ export class DistanceMarkers {
this.unsubscribes.push(gpxStatistics.subscribe(this.updateBinded));
this.unsubscribes.push(distanceMarkers.subscribe(this.updateBinded));
this.unsubscribes.push(distanceUnits.subscribe(this.updateBinded));
this.map.on('style.load', this.updateBinded);
this.map.on('style.import.load', this.updateBinded);
}
update() {
@@ -40,7 +39,7 @@ export class DistanceMarkers {
layout: {
'text-field': ['get', 'distance'],
'text-size': 14,
'text-font': [font[get(currentBasemap)] ?? 'Open Sans Bold'],
'text-font': ['Open Sans Bold'],
'text-padding': 20,
},
paint: {

View File

@@ -6,10 +6,10 @@ import { currentPopupWaypoint, deleteWaypoint, waypointPopup } from "./WaypointP
import { addSelectItem, selectItem, selection } from "$lib/components/file-list/Selection";
import { ListTrackSegmentItem, ListWaypointItem, ListWaypointsItem, ListTrackItem, ListFileItem, ListRootItem } from "$lib/components/file-list/FileList";
import type { Waypoint } from "gpx";
import { getElevation, resetCursor, setCursor, setGrabbingCursor, setPointerCursor } from "$lib/utils";
import { font } from "$lib/assets/layers";
import { getElevation, resetCursor, setGrabbingCursor, setPointerCursor, setScissorsCursor } from "$lib/utils";
import { selectedWaypoint } from "$lib/components/toolbar/tools/Waypoint.svelte";
import { MapPin } from "lucide-static";
import { MapPin, Square } from "lucide-static";
import { getSymbolKey, symbols } from "$lib/assets/symbols";
const colors = [
'#ff0000',
@@ -43,7 +43,29 @@ function decrementColor(color: string) {
}
}
const { directionMarkers, verticalFileView, currentBasemap, defaultOpacity, defaultWeight } = settings;
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">
${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"', '')
.replace('path', `path fill="#3fb1ce" stroke="SteelBlue" stroke-width="1"`)
.replace('circle', `circle fill="${symbolSvg ? 'none' : 'white'}" stroke="${symbolSvg ? 'none' : 'white'}" stroke-width="2"`)}
${symbolSvg?.replace('width="24"', 'width="10"')
.replace('height="24"', 'height="10"')
.replace('stroke="currentColor"', 'stroke="white"')
.replace('stroke-width="2"', 'stroke-width="2.5" x="7" y="5"') ?? ''}
</svg>`;
}
const { directionMarkers, verticalFileView, defaultOpacity, defaultWeight } = settings;
export class GPXLayer {
map: mapboxgl.Map;
@@ -89,7 +111,7 @@ export class GPXLayer {
}));
this.draggable = get(currentTool) === Tool.WAYPOINT;
this.map.on('style.load', this.updateBinded);
this.map.on('style.import.load', this.updateBinded);
}
update() {
@@ -98,9 +120,9 @@ export class GPXLayer {
return;
}
if (file._data.style && file._data.style.color && this.layerColor !== file._data.style.color) {
if (file._data.style && file._data.style.color && this.layerColor !== `#${file._data.style.color}`) {
decrementColor(this.layerColor);
this.layerColor = file._data.style.color;
this.layerColor = `#${file._data.style.color}`;
}
try {
@@ -147,7 +169,7 @@ export class GPXLayer {
'text-keep-upright': false,
'text-max-angle': 361,
'text-allow-overlap': true,
'text-font': [font[get(currentBasemap)] ?? 'Open Sans Bold'],
'text-font': ['Open Sans Bold'],
'symbol-placement': 'line',
'symbol-spacing': 20,
},
@@ -184,19 +206,15 @@ export class GPXLayer {
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().querySelector('circle')?.setAttribute('fill', this.layerColor);
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 = MapPin
.replace('width="24"', '')
.replace('height="24"', '')
.replace('stroke="currentColor"', '')
.replace('path', `path fill="#3fb1ce" stroke="SteelBlue" stroke-width="1"`)
.replace('circle', `circle fill="${this.layerColor}" stroke="white" stroke-width="2.5"`);
element.innerHTML = getMarkerForSymbol(symbolKey, this.layerColor);
let marker = new mapboxgl.Marker({
draggable: this.draggable,
element,
@@ -275,7 +293,7 @@ export class GPXLayer {
updateMap(map: mapboxgl.Map) {
this.map = map;
this.map.on('style.load', this.updateBinded);
this.map.on('style.import.load', this.updateBinded);
this.update();
}
@@ -284,7 +302,7 @@ export class GPXLayer {
this.map.off('click', this.fileId, this.layerOnClickBinded);
this.map.off('mouseenter', this.fileId, this.layerOnMouseEnterBinded);
this.map.off('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
this.map.off('style.load', this.updateBinded);
this.map.off('style.import.load', this.updateBinded);
if (this.map.getLayer(this.fileId + '-direction')) {
this.map.removeLayer(this.fileId + '-direction');
@@ -320,7 +338,7 @@ export class GPXLayer {
let segmentIndex = e.features[0].properties.segmentIndex;
if (get(currentTool) === Tool.SCISSORS && get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex))) {
setCursor(`url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" version="1.1"><path d="M 3.200 3.200 C 0.441 5.959, 2.384 9.516, 7 10.154 C 10.466 10.634, 10.187 13.359, 6.607 13.990 C 2.934 14.637, 1.078 17.314, 2.612 19.750 C 4.899 23.380, 10 21.935, 10 17.657 C 10 16.445, 12.405 13.128, 15.693 9.805 C 18.824 6.641, 21.066 3.732, 20.674 3.341 C 20.283 2.950, 18.212 4.340, 16.072 6.430 C 12.019 10.388, 10 10.458, 10 6.641 C 10 2.602, 5.882 0.518, 3.200 3.200 M 4.446 5.087 C 3.416 6.755, 5.733 8.667, 7.113 7.287 C 8.267 6.133, 7.545 4, 6 4 C 5.515 4, 4.816 4.489, 4.446 5.087 M 14 14.813 C 14 16.187, 19.935 21.398, 20.667 20.667 C 21.045 20.289, 20.065 18.634, 18.490 16.990 C 15.661 14.036, 14 13.231, 14 14.813 M 4.446 17.087 C 3.416 18.755, 5.733 20.667, 7.113 19.287 C 8.267 18.133, 7.545 16, 6 16 C 5.515 16, 4.816 16.489, 4.446 17.087" stroke="black" stroke-width="1.2" fill="white" fill-rule="evenodd"/></svg>') 12 12, auto`);
setScissorsCursor();
} else {
setPointerCursor();
}

View File

@@ -4,10 +4,12 @@
import Shortcut from '$lib/components/Shortcut.svelte';
import { waypointPopup, currentPopupWaypoint, deleteWaypoint } from './WaypointPopup';
import WithUnits from '$lib/components/WithUnits.svelte';
import { Dot, Trash2 } from 'lucide-svelte';
import { Dot, ExternalLink, Trash2 } from 'lucide-svelte';
import { onMount } from 'svelte';
import { Tool, currentTool } from '$lib/stores';
import { getSymbolKey, symbols } from '$lib/assets/symbols';
import { _ } from 'svelte-i18n';
import sanitizeHtml from 'sanitize-html';
let popupElement: HTMLDivElement;
@@ -15,16 +17,55 @@
waypointPopup.setDOMContent(popupElement);
popupElement.classList.remove('hidden');
});
$: symbolKey = $currentPopupWaypoint ? getSymbolKey($currentPopupWaypoint[0].sym) : undefined;
function sanitize(text: string | undefined): string {
if (text === undefined) {
return '';
}
return sanitizeHtml(text, {
allowedTags: ['a', 'br', 'img'],
allowedAttributes: {
a: ['href', 'target'],
img: ['src']
}
}).trim();
}
</script>
<div bind:this={popupElement} class="hidden">
{#if $currentPopupWaypoint}
<Card.Root class="border-none shadow-md text-base max-w-80 p-2">
<Card.Header class="p-0">
<Card.Title class="text-md">{$currentPopupWaypoint[0].name}</Card.Title>
<Card.Title class="text-md">
{#if $currentPopupWaypoint[0].link && $currentPopupWaypoint[0].link.attributes && $currentPopupWaypoint[0].link.attributes.href}
<a href={$currentPopupWaypoint[0].link.attributes.href} target="_blank">
{$currentPopupWaypoint[0].name ?? $currentPopupWaypoint[0].link.attributes.href}
<ExternalLink size="12" class="inline-block mb-1.5" />
</a>
{:else}
{$currentPopupWaypoint[0].name ?? $_('gpx.waypoint')}
{/if}
</Card.Title>
</Card.Header>
<Card.Content class="flex flex-col p-0 text-sm">
<div class="flex flex-row items-center text-muted-foreground">
<div class="flex flex-row items-center text-muted-foreground text-xs whitespace-nowrap">
{#if symbolKey}
<span>
{#if symbols[symbolKey].icon}
<svelte:component
this={symbols[symbolKey].icon}
size="12"
class="inline-block mb-0.5"
/>
{:else}
<span class="w-4 inline-block" />
{/if}
{$_(`gpx.symbol.${symbolKey}`)}
</span>
<Dot size="16" />
{/if}
{$currentPopupWaypoint[0].getLatitude().toFixed(6)}&deg; {$currentPopupWaypoint[0]
.getLongitude()
.toFixed(6)}&deg;
@@ -34,10 +75,10 @@
{/if}
</div>
{#if $currentPopupWaypoint[0].desc}
<span class="whitespace-pre-wrap">{$currentPopupWaypoint[0].desc}</span>
<span class="whitespace-pre-wrap">{@html sanitize($currentPopupWaypoint[0].desc)}</span>
{/if}
{#if $currentPopupWaypoint[0].cmt && $currentPopupWaypoint[0].cmt !== $currentPopupWaypoint[0].desc}
<span class="whitespace-pre-wrap">{$currentPopupWaypoint[0].cmt}</span>
<span class="whitespace-pre-wrap">{@html sanitize($currentPopupWaypoint[0].cmt)}</span>
{/if}
{#if $currentTool === Tool.WAYPOINT}
<Button
@@ -55,3 +96,15 @@
</Card.Root>
{/if}
</div>
<style lang="postcss">
div :global(a) {
@apply text-blue-500 dark:text-blue-300;
@apply hover:underline;
}
div :global(img) {
@apply my-0;
@apply rounded-md;
}
</style>

View File

@@ -3,12 +3,27 @@
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Button } from '$lib/components/ui/button';
import { Separator } from '$lib/components/ui/separator';
import * as RadioGroup from '$lib/components/ui/radio-group';
import { CirclePlus, CircleX, Minus, Pencil, Plus, Save, Trash2 } from 'lucide-svelte';
import {
CirclePlus,
CircleX,
Minus,
Pencil,
Plus,
Save,
Trash2,
Move,
Map,
Layers2
} from 'lucide-svelte';
import { _ } from 'svelte-i18n';
import { settings } from '$lib/db';
import { defaultBasemap, extendBasemap, type CustomLayer } from '$lib/assets/layers';
import { defaultBasemap, type CustomLayer } from '$lib/assets/layers';
import { map } from '$lib/stores';
import { onDestroy, onMount } from 'svelte';
import Sortable from 'sortablejs/Sortable';
import { customBasemapUpdate } from './utils';
const {
customLayers,
@@ -17,7 +32,9 @@
currentBasemap,
previousBasemap,
currentOverlays,
previousOverlays
previousOverlays,
customBasemapOrder,
customOverlayOrder
} = settings;
let name: string = '';
@@ -26,19 +43,68 @@
let layerType: 'basemap' | 'overlay' = 'basemap';
let resourceType: 'raster' | 'vector' = 'raster';
let basemapContainer: HTMLElement;
let overlayContainer: HTMLElement;
let basemapSortable: Sortable;
let overlaySortable: Sortable;
onMount(() => {
if ($customBasemapOrder.length === 0) {
$customBasemapOrder = Object.keys($customLayers).filter(
(id) => $customLayers[id].layerType === 'basemap'
);
}
if ($customOverlayOrder.length === 0) {
$customOverlayOrder = Object.keys($customLayers).filter(
(id) => $customLayers[id].layerType === 'overlay'
);
}
basemapSortable = Sortable.create(basemapContainer, {
onSort: (e) => {
$customBasemapOrder = basemapSortable.toArray();
$selectedBasemapTree.basemaps['custom'] = $customBasemapOrder.reduce((acc, id) => {
acc[id] = true;
return acc;
}, {});
}
});
overlaySortable = Sortable.create(overlayContainer, {
onSort: (e) => {
$customOverlayOrder = overlaySortable.toArray();
$selectedOverlayTree.overlays['custom'] = $customOverlayOrder.reduce((acc, id) => {
acc[id] = true;
return acc;
}, {});
}
});
basemapSortable.sort($customBasemapOrder);
overlaySortable.sort($customOverlayOrder);
});
onDestroy(() => {
basemapSortable.destroy();
overlaySortable.destroy();
});
$: if (tileUrls[0].length > 0) {
if (
tileUrls[0].includes('.json') ||
(tileUrls[0].includes('api.mapbox.com/styles') && !tileUrls[0].includes('tiles'))
) {
resourceType = 'vector';
layerType = 'basemap';
} else {
resourceType = 'raster';
}
}
function createLayer() {
if (selectedLayerId && $customLayers[selectedLayerId].layerType !== layerType) {
deleteLayer(selectedLayerId);
}
if (typeof maxZoom === 'string') {
maxZoom = parseInt(maxZoom);
}
@@ -47,7 +113,7 @@
let layer: CustomLayer = {
id: layerId,
name: name,
tileUrls: tileUrls,
tileUrls: tileUrls.map((url) => decodeURI(url.trim())),
maxZoom: maxZoom,
layerType: layerType,
resourceType: resourceType,
@@ -55,15 +121,15 @@
};
if (resourceType === 'vector') {
layer.value = tileUrls[0];
layer.value = layer.tileUrls[0];
} else {
if (layerType === 'basemap') {
layer.value = extendBasemap({
layer.value = {
version: 8,
sources: {
[layerId]: {
type: 'raster',
tiles: tileUrls,
tiles: layer.tileUrls,
tileSize: 256,
maxzoom: maxZoom
}
},
@@ -74,15 +140,8 @@
source: layerId
}
]
});
} else {
layer.value = {
type: 'raster',
tiles: tileUrls,
maxzoom: maxZoom
};
}
}
$customLayers[layerId] = layer;
addLayer(layerId);
selectedLayerId = undefined;
@@ -106,6 +165,16 @@
$tree.basemaps['custom'][layerId] = true;
return $tree;
});
if ($currentBasemap === layerId) {
$customBasemapUpdate++;
} else {
$currentBasemap = layerId;
}
if (!$customBasemapOrder.includes(layerId)) {
$customBasemapOrder = [...$customBasemapOrder, layerId];
}
} else {
selectedOverlayTree.update(($tree) => {
if (!$tree.overlays.hasOwnProperty('custom')) {
@@ -114,6 +183,27 @@
$tree.overlays['custom'][layerId] = true;
return $tree;
});
if (
$currentOverlays.overlays['custom'] &&
$currentOverlays.overlays['custom'][layerId] &&
$map
) {
try {
$map.removeImport(layerId);
} catch (e) {
// No reliable way to check if the map is ready to remove sources and layers
}
}
if (!$currentOverlays.overlays.hasOwnProperty('custom')) {
$currentOverlays.overlays['custom'] = {};
}
$currentOverlays.overlays['custom'][layerId] = true;
if (!$customOverlayOrder.includes(layerId)) {
$customOverlayOrder = [...$customOverlayOrder, layerId];
}
}
}
@@ -139,26 +229,42 @@
layerId
);
if (Object.keys($selectedBasemapTree.basemaps['custom']).length === 0) {
$selectedBasemapTree.basemaps = tryDeleteLayer($selectedBasemapTree.basemaps, 'custom');
$selectedBasemapTree.basemaps = tryDeleteLayer(
$selectedBasemapTree.basemaps,
'custom'
);
}
$customBasemapOrder = $customBasemapOrder.filter((id) => id !== layerId);
} else {
$currentOverlays = tryDeleteLayer($currentOverlays, layerId);
$previousOverlays = tryDeleteLayer($previousOverlays, layerId);
$currentOverlays.overlays['custom'][layerId] = false;
if ($previousOverlays.overlays['custom']) {
$previousOverlays.overlays['custom'] = tryDeleteLayer(
$previousOverlays.overlays['custom'],
layerId
);
}
$selectedOverlayTree.overlays['custom'] = tryDeleteLayer(
$selectedOverlayTree.overlays['custom'],
layerId
);
if (Object.keys($selectedOverlayTree.overlays['custom']).length === 0) {
$selectedOverlayTree.overlays = tryDeleteLayer($selectedOverlayTree.overlays, 'custom');
$selectedOverlayTree.overlays = tryDeleteLayer(
$selectedOverlayTree.overlays,
'custom'
);
}
$customOverlayOrder = $customOverlayOrder.filter((id) => id !== layerId);
if ($map) {
if ($map.getLayer(layerId)) {
$map.removeLayer(layerId);
}
if ($map.getSource(layerId)) {
$map.removeSource(layerId);
if (
$currentOverlays.overlays['custom'] &&
$currentOverlays.overlays['custom'][layerId] &&
$map
) {
try {
$map.removeImport(layerId);
} catch (e) {
// No reliable way to check if the map is ready to remove sources and layers
}
}
}
@@ -187,23 +293,61 @@
$: selectedLayerId, setDataFromSelectedLayer();
</script>
{#if Object.keys($customLayers).length > 0}
<div class="flex flex-col gap-1 mb-3">
{#each Object.entries($customLayers) as [id, layer] (id)}
<div class="flex flex-row items-center gap-2">
<span class="grow">{layer.name}</span>
<Button variant="outline" on:click={() => (selectedLayerId = id)} class="p-1 h-8">
<div class="flex flex-col">
{#if $customBasemapOrder.length > 0}
<div class="flex flex-row items-center gap-1 font-semibold mb-2">
<Map size="16" />
{$_('layers.label.basemaps')}
<div class="grow">
<Separator />
</div>
</div>
{/if}
<div
bind:this={basemapContainer}
class="ml-1.5 flex flex-col gap-1 {$customBasemapOrder.length > 0 ? 'mb-2' : ''}"
>
{#each $customBasemapOrder as id (id)}
<div class="flex flex-row items-center gap-2" data-id={id}>
<Move size="12" />
<span class="grow">{$customLayers[id].name}</span>
<Button variant="outline" on:click={() => (selectedLayerId = id)} class="p-1 h-7">
<Pencil size="16" />
</Button>
<Button variant="outline" on:click={() => deleteLayer(id)} class="p-1 h-8">
<Button variant="outline" on:click={() => deleteLayer(id)} class="p-1 h-7">
<Trash2 size="16" />
</Button>
</div>
{/each}
</div>
{#if $customOverlayOrder.length > 0}
<div class="flex flex-row items-center gap-1 font-semibold mb-2">
<Layers2 size="16" />
{$_('layers.label.overlays')}
<div class="grow">
<Separator />
</div>
</div>
{/if}
<div
bind:this={overlayContainer}
class="ml-1.5 flex flex-col gap-1 {$customOverlayOrder.length > 0 ? 'mb-2' : ''}"
>
{#each $customOverlayOrder as id (id)}
<div class="flex flex-row items-center gap-2" data-id={id}>
<Move size="12" />
<span class="grow">{$customLayers[id].name}</span>
<Button variant="outline" on:click={() => (selectedLayerId = id)} class="p-1 h-7">
<Pencil size="16" />
</Button>
<Button variant="outline" on:click={() => deleteLayer(id)} class="p-1 h-7">
<Trash2 size="16" />
</Button>
</div>
{/each}
</div>
{/if}
<Card.Root>
<Card.Root>
<Card.Header class="p-3">
<Card.Title class="text-base">
{#if selectedLayerId}
@@ -228,7 +372,8 @@
/>
{#if tileUrls.length > 1}
<Button
on:click={() => (tileUrls = tileUrls.filter((_, index) => index !== i))}
on:click={() =>
(tileUrls = tileUrls.filter((_, index) => index !== i))}
variant="outline"
class="p-1 h-8"
>
@@ -248,7 +393,14 @@
{/each}
{#if resourceType === 'raster'}
<Label for="maxZoom">{$_('layers.custom_layers.max_zoom')}</Label>
<Input type="number" bind:value={maxZoom} id="maxZoom" min={0} max={22} class="h-8" />
<Input
type="number"
bind:value={maxZoom}
id="maxZoom"
min={0}
max={22}
class="h-8"
/>
{/if}
<Label>{$_('layers.custom_layers.layer_type')}</Label>
<RadioGroup.Root bind:value={layerType} class="flex flex-row">
@@ -257,7 +409,7 @@
<Label for="basemap">{$_('layers.custom_layers.basemap')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="overlay" id="overlay" disabled={resourceType === 'vector'} />
<RadioGroup.Item value="overlay" id="overlay" />
<Label for="overlay">{$_('layers.custom_layers.overlay')}</Label>
</div>
</RadioGroup.Root>
@@ -279,4 +431,5 @@
{/if}
</fieldset>
</Card.Content>
</Card.Root>
</Card.Root>
</div>

View File

@@ -11,7 +11,7 @@
import { settings } from '$lib/db';
import { map } from '$lib/stores';
import { get, writable } from 'svelte/store';
import { getLayers } from './utils';
import { customBasemapUpdate, getLayers } from './utils';
import { OverpassLayer } from './OverpassLayer';
import OverpassPopup from './OverpassPopup.svelte';
@@ -30,33 +30,73 @@
opacities
} = settings;
$: if ($map) {
// Set style depending on the current basemap
function setStyle() {
if ($map) {
let basemap = basemaps.hasOwnProperty($currentBasemap)
? basemaps[$currentBasemap]
: $customLayers[$currentBasemap]?.value ?? basemaps[defaultBasemap];
$map.setStyle(basemap, {
diff: false
$map.removeImport('basemap');
if (typeof basemap === 'string') {
$map.addImport({ id: 'basemap', url: basemap }, 'overlays');
} else {
$map.addImport(
{
id: 'basemap',
data: basemap
},
'overlays'
);
}
}
}
$: if ($map && ($currentBasemap || $customBasemapUpdate)) {
setStyle();
}
function addOverlay(id: string) {
try {
let overlay = $customLayers.hasOwnProperty(id) ? $customLayers[id].value : overlays[id];
if (typeof overlay === 'string') {
$map.addImport({ id, url: overlay });
} else {
$map.addImport({
id,
data: overlay
});
}
} catch (e) {
// No reliable way to check if the map is ready to add sources and layers
}
}
function updateOverlays() {
if ($map && $currentOverlays) {
let overlayLayers = getLayers($currentOverlays);
try {
let activeOverlays = $map
.getStyle()
.imports.filter((i) => i.id !== 'basemap' && i.id !== 'overlays');
let toRemove = activeOverlays.filter((i) => !overlayLayers[i.id]);
toRemove.forEach((i) => {
$map.removeImport(i.id);
});
let toAdd = Object.entries(overlayLayers)
.filter(
([id, selected]) => selected && !activeOverlays.some((j) => j.id === id)
)
.map(([id]) => id);
toAdd.forEach((id) => {
addOverlay(id);
});
} catch (e) {
// No reliable way to check if the map is ready to add sources and layers
}
}
}
$: if ($map && $currentOverlays) {
// Add or remove overlay layers depending on the current overlays
let overlayLayers = getLayers($currentOverlays);
Object.keys(overlayLayers).forEach((id) => {
if (overlayLayers[id]) {
if (!addOverlayLayer.hasOwnProperty(id)) {
addOverlayLayer[id] = addOverlayLayerForId(id);
}
if (!$map.getLayer(id)) {
addOverlayLayer[id]();
$map.on('style.load', addOverlayLayer[id]);
}
} else if ($map.getLayer(id)) {
$map.removeLayer(id);
$map.off('style.load', addOverlayLayer[id]);
}
});
updateOverlays();
}
$: if ($map) {
@@ -65,6 +105,7 @@
}
overpassLayer = new OverpassLayer($map);
overpassLayer.add();
$map.on('style.import.load', updateOverlays);
}
let selectedBasemap = writable(get(currentBasemap));
@@ -80,35 +121,15 @@
selectedBasemap.set(value);
});
let addOverlayLayer: { [key: string]: () => void } = {};
function addOverlayLayerForId(id: string) {
return () => {
function removeOverlayLayer(id: string) {
if ($map) {
try {
let overlay = $customLayers.hasOwnProperty(id) ? $customLayers[id].value : overlays[id];
if (!$map.getSource(id)) {
$map.addSource(id, overlay);
}
$map.addLayer(
{
id,
type: overlay.type === 'raster' ? 'raster' : 'line',
source: id,
paint: {
...(id in $opacities
? overlay.type === 'raster'
? { 'raster-opacity': $opacities[id] }
: { 'line-opacity': $opacities[id] }
: {})
}
},
'overlays'
);
} catch (e) {
// No reliable way to check if the map is ready to add sources and layers
if (overlay.layers) {
$map.removeImport(id);
} else {
$map.removeLayer(id);
}
}
};
}
let open = false;

View File

@@ -14,16 +14,14 @@
import { settings } from '$lib/db';
import { _ } from 'svelte-i18n';
import { writable, get } from 'svelte/store';
import { map, setStravaHeatmapURLs } from '$lib/stores';
import { browser } from '$app/environment';
import { writable } from 'svelte/store';
import { map } from '$lib/stores';
import CustomLayers from './CustomLayers.svelte';
const {
selectedBasemapTree,
selectedOverlayTree,
selectedOverpassTree,
stravaHeatmapColor,
currentOverlays,
customLayers,
opacities
@@ -51,63 +49,6 @@
$: if ($selectedOverlay) {
setOpacityFromSelection();
}
const heatmapColors = [
{ value: '', label: '' },
{ value: 'blue', label: $_('layers.color.blue') },
{ value: 'bluered', label: $_('layers.color.bluered') },
{ value: 'gray', label: $_('layers.color.gray') },
{ value: 'hot', label: $_('layers.color.hot') },
{ value: 'orange', label: $_('layers.color.orange') },
{ value: 'purple', label: $_('layers.color.purple') }
];
let selectedHeatmapColor = writable(heatmapColors[0]);
$: if ($selectedHeatmapColor !== heatmapColors[0]) {
stravaHeatmapColor.set($selectedHeatmapColor.value);
// remove and add the heatmap layers
let m = get(map);
if (m) {
let currentStravaLayers = [];
if (overlayTree.overlays.world.strava) {
for (let layer of Object.keys(overlayTree.overlays.world.strava)) {
if (m.getLayer(layer)) {
m.removeLayer(layer);
currentStravaLayers.push(layer);
}
if (m.getSource(layer)) {
m.removeSource(layer);
}
}
}
if (currentStravaLayers.length > 0) {
currentOverlays.update(($currentOverlays) => {
for (let layer of currentStravaLayers) {
$currentOverlays.overlays.world.strava[layer] = false;
}
return $currentOverlays;
});
currentOverlays.update(($currentOverlays) => {
for (let layer of currentStravaLayers) {
$currentOverlays.overlays.world.strava[layer] = true;
}
return $currentOverlays;
});
}
}
}
$: if ($stravaHeatmapColor && browser) {
setStravaHeatmapURLs();
if ($stravaHeatmapColor !== get(selectedHeatmapColor).value) {
let toSelect = heatmapColors.find(({ value }) => value === $stravaHeatmapColor);
if (toSelect) {
selectedHeatmapColor.set(toSelect);
}
}
}
</script>
<Sheet.Root bind:open>
@@ -209,26 +150,6 @@
</ScrollArea>
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="heatmap-color" class="hidden">
<Accordion.Trigger>{$_('layers.heatmap')}</Accordion.Trigger>
<Accordion.Content class="overflow-visible">
<div class="flex flex-row items-center justify-between gap-6">
<Label>
{$_('menu.style.color')}
</Label>
<Select.Root bind:selected={$selectedHeatmapColor}>
<Select.Trigger class="h-8 mr-1">
<Select.Value />
</Select.Trigger>
<Select.Content>
{#each heatmapColors as { value, label }}
<Select.Item {value}>{label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
</Accordion.Content>
</Accordion.Item>
</Accordion.Root>
</ScrollArea>
</Sheet.Header>

View File

@@ -32,6 +32,7 @@ export class OverpassLayer {
overpassUrl = 'https://overpass.private.coffee/api/interpreter';
minZoom = 12;
queryZoom = 12;
expirationTime = 7 * 24 * 3600 * 1000;
map: mapboxgl.Map;
currentQueries: Set<string> = new Set();
@@ -49,7 +50,7 @@ export class OverpassLayer {
add() {
this.map.on('moveend', this.queryIfNeededBinded);
this.map.on('style.load', this.updateBinded);
this.map.on('style.import.load', this.updateBinded);
this.unsubscribes.push(data.subscribe(this.updateBinded));
this.unsubscribes.push(currentOverpassQueries.subscribe(() => {
this.updateBinded();
@@ -107,9 +108,10 @@ export class OverpassLayer {
remove() {
this.map.off('moveend', this.queryIfNeededBinded);
this.map.off('style.load', this.updateBinded);
this.map.off('style.import.load', this.updateBinded);
this.unsubscribes.forEach((unsubscribe) => unsubscribe());
try {
if (this.map.getLayer('overpass')) {
this.map.removeLayer('overpass');
}
@@ -117,10 +119,16 @@ export class OverpassLayer {
if (this.map.getSource('overpass')) {
this.map.removeSource('overpass');
}
} catch (e) {
// No reliable way to check if the map is ready to remove sources and layers
}
}
onHover(e: any) {
overpassPopupPOI.set(e.features[0].properties);
overpassPopupPOI.set({
...e.features[0].properties,
sym: overpassQueryData[e.features[0].properties.query].symbol ?? ''
});
overpassPopup.setLngLat(e.features[0].geometry.coordinates);
overpassPopup.addTo(this.map);
this.map.on('mousemove', this.maybeHidePopupBinded);
@@ -147,6 +155,7 @@ export class OverpassLayer {
}
let tileLimits = mercator.xyz(bbox, this.queryZoom);
let time = Date.now();
for (let x = tileLimits.minX; x <= tileLimits.maxX; x++) {
for (let y = tileLimits.minY; y <= tileLimits.maxY; y++) {
@@ -154,8 +163,8 @@ export class OverpassLayer {
continue;
}
db.overpassquerytiles.where('[x+y]').equals([x, y]).toArray().then((querytiles) => {
let missingQueries = queries.filter((query) => !querytiles.some((querytile) => querytile.query === query));
db.overpasstiles.where('[x+y]').equals([x, y]).toArray().then((querytiles) => {
let missingQueries = queries.filter((query) => !querytiles.some((querytile) => querytile.query === query && time - querytile.time < this.expirationTime));
if (missingQueries.length > 0) {
this.queryTile(x, y, missingQueries);
}
@@ -185,7 +194,8 @@ export class OverpassLayer {
}
storeOverpassData(x: number, y: number, queries: string[], data: any) {
let queryTiles = queries.map((query) => ({ x, y, query }));
let time = Date.now();
let queryTiles = queries.map((query) => ({ x, y, query, time }));
let pois: { query: string, id: number, poi: GeoJSON.Feature }[] = [];
if (data.elements === undefined) {
@@ -218,8 +228,8 @@ export class OverpassLayer {
}
}
db.transaction('rw', db.overpassquerytiles, db.overpassdata, async () => {
await db.overpassquerytiles.bulkPut(queryTiles);
db.transaction('rw', db.overpasstiles, db.overpassdata, async () => {
await db.overpasstiles.bulkPut(queryTiles);
await db.overpassdata.bulkPut(pois);
});

View File

@@ -7,7 +7,6 @@
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { dbUtils } from '$lib/db';
import { get } from 'svelte/store';
let popupElement: HTMLDivElement;
@@ -89,7 +88,8 @@
},
name: name,
desc: desc,
cmt: desc
cmt: desc,
sym: $overpassPopupPOI.sym
});
}}
>

View File

@@ -1,4 +1,5 @@
import type { LayerTreeType } from "$lib/assets/layers";
import { writable } from "svelte/store";
export function anySelectedLayer(node: LayerTreeType) {
return Object.keys(node).find((id) => {
@@ -37,3 +38,5 @@ export function isSelected(node: LayerTreeType, id: string) {
return false;
});
}
export const customBasemapUpdate = writable(0);

View File

@@ -4,7 +4,7 @@
import { flyAndScale } from '$lib/utils';
import * as Card from '$lib/components/ui/card';
import Routing from '$lib/components/toolbar/tools/routing/Routing.svelte';
import Scissors from '$lib/components/toolbar/tools/Scissors.svelte';
import Scissors from '$lib/components/toolbar/tools/scissors/Scissors.svelte';
import Waypoint from '$lib/components/toolbar/tools/Waypoint.svelte';
import Time from '$lib/components/toolbar/tools/Time.svelte';
import Merge from '$lib/components/toolbar/tools/Merge.svelte';

View File

@@ -178,7 +178,7 @@
<Trash2 size="16" class="mr-1" />
{$_('toolbar.clean.button')}
</Button>
<Help>
<Help link="./help/toolbar/clean">
{#if validSelection}
{$_('toolbar.clean.help')}
{:else}

View File

@@ -42,7 +42,7 @@
<Ungroup size="16" class="mr-1" />
{$_('toolbar.extract.button')}
</Button>
<Help>
<Help link="./help/toolbar/extract">
{#if validSelection}
{$_('toolbar.extract.help')}
{:else}

View File

@@ -74,7 +74,7 @@
<Group size="16" class="mr-1" />
{$_('toolbar.merge.merge_selection')}
</Button>
<Help>
<Help link="./help/toolbar/merge">
{#if mergeType === MergeType.TRACES && canMergeTraces}
{$_('toolbar.merge.help_merge_traces')}
{:else if mergeType === MergeType.TRACES && !canMergeTraces}

View File

@@ -164,7 +164,7 @@
{$_('toolbar.reduce.button')}
</Button>
<Help>
<Help link="./help/toolbar/minify">
{#if validSelection}
{$_('toolbar.reduce.help')}
{:else}

View File

@@ -10,7 +10,8 @@
import {
distancePerHourToSecondsPerDistance,
getConvertedVelocity,
kilometersToMiles
milesToKilometers,
nauticalMilesToKilometers
} from '$lib/units';
import { CalendarDate, type DateValue } from '@internationalized/date';
import { CalendarClock, CirclePlay, CircleStop, CircleX, Timer, Zap } from 'lucide-svelte';
@@ -32,6 +33,7 @@
let endTime: string | undefined = undefined;
let movingTime: number | undefined = undefined;
let speed: number | undefined = undefined;
let artificial = false;
function toCalendarDate(date: Date): CalendarDate {
return new CalendarDate(date.getFullYear(), date.getMonth() + 1, date.getDate());
@@ -128,7 +130,9 @@
speedValue = distancePerHourToSecondsPerDistance(speed);
}
if ($distanceUnits === 'imperial') {
speedValue = kilometersToMiles(speedValue);
speedValue = milesToKilometers(speedValue);
} else if ($distanceUnits === 'nautical') {
speedValue = nauticalMilesToKilometers(speedValue);
}
return speedValue;
}
@@ -152,7 +156,11 @@
if (movingTime === undefined) {
return;
}
setSpeed($gpxStatistics.global.distance.moving / (movingTime / 3600));
let distance =
$gpxStatistics.global.distance.moving > 0
? $gpxStatistics.global.distance.moving
: $gpxStatistics.global.distance.total;
setSpeed(distance / (movingTime / 3600));
updateEnd();
}
@@ -186,8 +194,10 @@
<span class="text-sm shrink-0">
{#if $distanceUnits === 'imperial'}
{$_('units.miles_per_hour')}
{:else}
{:else if $distanceUnits === 'metric'}
{$_('units.kilometers_per_hour')}
{:else if $distanceUnits === 'nautical'}
{$_('units.knots')}
{/if}
</span>
{:else}
@@ -195,13 +205,15 @@
bind:value={speed}
showHours={false}
disabled={!canUpdate}
on:change={updateDataFromSpeed}
onChange={updateDataFromSpeed}
/>
<span class="text-sm shrink-0">
{#if $distanceUnits === 'imperial'}
{$_('units.minutes_per_mile')}
{:else}
{:else if $distanceUnits === 'metric'}
{$_('units.minutes_per_kilometer')}
{:else if $distanceUnits === 'nautical'}
{$_('units.minutes_per_nautical_mile')}
{/if}
</span>
{/if}
@@ -215,7 +227,7 @@
<TimePicker
bind:value={movingTime}
disabled={!canUpdate}
on:change={updateDataFromTotalTime}
onChange={updateDataFromTotalTime}
/>
</div>
</div>
@@ -235,13 +247,13 @@
updateEnd();
}}
/>
<Input
<input
type="time"
step={1}
disabled={!canUpdate}
bind:value={startTime}
class="w-fit"
on:input={updateEnd}
on:change={updateEnd}
/>
</div>
<Label class="flex flex-row">
@@ -260,7 +272,7 @@
updateStart();
}}
/>
<Input
<input
type="time"
step={1}
disabled={!canUpdate}
@@ -270,8 +282,8 @@
/>
</div>
{#if $gpxStatistics.global.time.moving === 0 || $gpxStatistics.global.time.moving === undefined}
<div class="mt-0.5 flex flex-row gap-1 items-center hidden">
<Checkbox id="artificial-time" disabled={!canUpdate} />
<div class="mt-0.5 flex flex-row gap-1 items-center">
<Checkbox id="artificial-time" bind:checked={artificial} disabled={!canUpdate} />
<Label for="artificial-time">
{$_('toolbar.time.artificial')}
</Label>
@@ -285,7 +297,11 @@
class="grow"
on:click={() => {
let effectiveSpeed = getSpeed();
if (startDate === undefined || startTime === undefined || effectiveSpeed === undefined) {
if (
startDate === undefined ||
startTime === undefined ||
effectiveSpeed === undefined
) {
return;
}
@@ -305,15 +321,42 @@
let fileId = item.getFileId();
dbUtils.applyToFile(fileId, (file) => {
if (item instanceof ListFileItem) {
file.changeTimestamps(getDate(startDate, startTime), effectiveSpeed, ratio);
if (artificial) {
file.createArtificialTimestamps(
getDate(startDate, startTime),
movingTime
);
} else {
file.changeTimestamps(
getDate(startDate, startTime),
effectiveSpeed,
ratio
);
}
} else if (item instanceof ListTrackItem) {
if (artificial) {
file.createArtificialTimestamps(
getDate(startDate, startTime),
movingTime,
item.getTrackIndex()
);
} else {
file.changeTimestamps(
getDate(startDate, startTime),
effectiveSpeed,
ratio,
item.getTrackIndex()
);
}
} else if (item instanceof ListTrackSegmentItem) {
if (artificial) {
file.createArtificialTimestamps(
getDate(startDate, startTime),
movingTime,
item.getTrackIndex(),
item.getSegmentIndex()
);
} else {
file.changeTimestamps(
getDate(startDate, startTime),
effectiveSpeed,
@@ -322,6 +365,7 @@
item.getSegmentIndex()
);
}
}
});
}}
>
@@ -332,7 +376,7 @@
<CircleX size="16" />
</Button>
</div>
<Help>
<Help link="./help/toolbar/time">
{#if canUpdate}
{$_('toolbar.time.help')}
{:else}
@@ -340,3 +384,13 @@
{/if}
</Help>
</div>
<style lang="postcss">
div :global(input[type='time']) {
/*
Style copy-pasted from shadcn-svelte Input.
Needed to use native time input to avoid a bug with 2-level bind:value.
*/
@apply flex h-10 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50;
}
</style>

View File

@@ -9,9 +9,10 @@
import { Textarea } from '$lib/components/ui/textarea';
import { Label } from '$lib/components/ui/label/index.js';
import { Button } from '$lib/components/ui/button';
import * as Select from '$lib/components/ui/select';
import { selection } from '$lib/components/file-list/Selection';
import { Waypoint } from 'gpx';
import { _ } from 'svelte-i18n';
import { _, locale } from 'svelte-i18n';
import { ListWaypointItem } from '$lib/components/file-list/FileList';
import { dbUtils, fileObservers, getFile, settings, type GPXFileWithStatistics } from '$lib/db';
import { get } from 'svelte/store';
@@ -20,12 +21,19 @@
import { map } from '$lib/stores';
import { resetCursor, setCrosshairCursor } from '$lib/utils';
import { CirclePlus, CircleX, Save } from 'lucide-svelte';
import { getSymbolKey, symbols } from '$lib/assets/symbols';
let name: string;
let description: string;
let link: string;
let longitude: number;
let latitude: number;
let selectedSymbol = {
value: '',
label: ''
};
const { verticalFileView } = settings;
$: canCreate = $selection.size > 0;
@@ -60,6 +68,20 @@
) {
description += '\n\n' + $selectedWaypoint[0].cmt;
}
link = $selectedWaypoint[0].link?.attributes?.href ?? '';
let symbol = $selectedWaypoint[0].sym ?? '';
let symbolKey = getSymbolKey(symbol);
if (symbolKey) {
selectedSymbol = {
value: symbol,
label: $_(`gpx.symbol.${symbolKey}`)
};
} else {
selectedSymbol = {
value: symbol,
label: ''
};
}
longitude = parseFloat($selectedWaypoint[0].getLongitude().toFixed(6));
latitude = parseFloat($selectedWaypoint[0].getLatitude().toFixed(6));
} else {
@@ -74,6 +96,11 @@
function resetWaypointData() {
name = '';
description = '';
link = '';
selectedSymbol = {
value: '',
label: ''
};
longitude = 0;
latitude = 0;
}
@@ -109,9 +136,11 @@
lat: latitude,
lon: longitude
},
name,
desc: description,
cmt: description
name: name.length > 0 ? name : undefined,
desc: description.length > 0 ? description : undefined,
cmt: description.length > 0 ? description : undefined,
link: link.length > 0 ? { attributes: { href: link } } : undefined,
sym: selectedSymbol.value.length > 0 ? selectedSymbol.value : undefined
},
$selectedWaypoint
? new ListWaypointItem($selectedWaypoint[1], $selectedWaypoint[0]._data.index)
@@ -127,6 +156,10 @@
longitude = e.lngLat.lng.toFixed(6);
}
$: sortedSymbols = Object.entries(symbols).sort((a, b) => {
return $_(`gpx.symbol.${a[0]}`).localeCompare($_(`gpx.symbol.${b[0]}`), $locale ?? 'en');
});
onMount(() => {
let m = get(map);
m?.on('click', setCoordinates);
@@ -151,6 +184,32 @@
<Input bind:value={name} id="name" class="font-semibold h-8" />
<Label for="description">{$_('menu.metadata.description')}</Label>
<Textarea bind:value={description} id="description" />
<Label for="symbol">{$_('toolbar.waypoint.icon')}</Label>
<Select.Root bind:selected={selectedSymbol}>
<Select.Trigger id="symbol" class="w-full h-8">
<Select.Value />
</Select.Trigger>
<Select.Content class="max-h-60 overflow-y-scroll">
{#each sortedSymbols as [key, symbol]}
<Select.Item value={symbol.value}>
<span>
{#if symbol.icon}
<svelte:component
this={symbol.icon}
size="14"
class="inline-block align-sub mr-0.5"
/>
{:else}
<span class="w-4 inline-block" />
{/if}
{$_(`gpx.symbol.${key}`)}
</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<Label for="link">{$_('toolbar.waypoint.link')}</Label>
<Input bind:value={link} id="link" class="h-8" />
<div class="flex flex-row gap-2">
<div>
<Label for="latitude">{$_('toolbar.waypoint.latitude')}</Label>
@@ -204,7 +263,7 @@
<CircleX size="16" />
</Button>
</div>
<Help>
<Help link="./help/toolbar/poi">
{#if $selectedWaypoint || canCreate}
{$_('toolbar.waypoint.help')}
{:else}

View File

@@ -236,11 +236,11 @@
</Tooltip>
</div>
<div class="w-full flex flex-row gap-2 items-end justify-between">
<Help>
<Help link="./help/toolbar/routing">
{#if !validSelection}
<div>{$_('toolbar.routing.help_no_file')}</div>
{$_('toolbar.routing.help_no_file')}
{:else}
<div>{$_('toolbar.routing.help')}</div>
{$_('toolbar.routing.help')}
{/if}
</Help>
<Button

View File

@@ -11,6 +11,7 @@ const { routing, routingProfile, privateRoads } = settings;
export const brouterProfiles: { [key: string]: string } = {
bike: 'Trekking-dry',
racing_bike: 'fastbike',
gravel_bike: 'gravel',
mountain_bike: 'MTB',
foot: 'Hiking-Alpine-SAC6',
motorcycle: 'Car-FastEco',
@@ -23,7 +24,7 @@ export const routingProfileSelectItem = writable({
});
derived([routingProfile, locale, isLoading], ([profile, l, i]) => [profile, l, i]).subscribe(([profile, l, i]) => {
if (!i && profile !== '' && profile !== get(routingProfileSelectItem).value && l !== null) {
if (!i && profile !== '' && (profile !== get(routingProfileSelectItem).value || get(_)(`toolbar.routing.activities.${profile}`) !== get(routingProfileSelectItem).label) && l !== null) {
routingProfileSelectItem.update((item) => {
item.value = profile;
item.label = get(_)(`toolbar.routing.activities.${profile}`);

View File

@@ -1,4 +1,4 @@
import { distance, type Coordinates, TrackPoint, TrackSegment, Track } from "gpx";
import { distance, type Coordinates, TrackPoint, TrackSegment, Track, projectedPoint } from "gpx";
import { get, writable, type Readable } from "svelte/store";
import mapboxgl from "mapbox-gl";
import { route } from "./Routing";
@@ -10,10 +10,14 @@ import { dbUtils, type GPXFileWithStatistics } from "$lib/db";
import { getOrderedSelection, selection } from "$lib/components/file-list/Selection";
import { ListFileItem, ListTrackItem, ListTrackSegmentItem } from "$lib/components/file-list/FileList";
import { currentTool, streetViewEnabled, Tool } from "$lib/stores";
import { resetCursor, setGrabbingCursor } from "$lib/utils";
import { getClosestLinePoint, getElevation, resetCursor, setGrabbingCursor } from "$lib/utils";
export const canChangeStart = writable(false);
function stopPropagation(e: any) {
e.stopPropagation();
}
export class RoutingControls {
active: boolean = false;
map: mapboxgl.Map;
@@ -24,6 +28,7 @@ export class RoutingControls {
popup: mapboxgl.Popup;
popupElement: HTMLElement;
temporaryAnchor: AnchorWithMarker;
lastDragEvent = 0;
fileUnsubscribe: () => void = () => { };
unsubscribes: Function[] = [];
@@ -76,10 +81,10 @@ export class RoutingControls {
add() {
this.active = true;
this.map.on('zoom', this.toggleAnchorsForZoomLevelAndBoundsBinded);
this.map.on('move', this.toggleAnchorsForZoomLevelAndBoundsBinded);
this.map.on('click', this.appendAnchorBinded);
this.map.on('mousemove', this.fileId, this.showTemporaryAnchorBinded);
this.map.on('click', this.fileId, stopPropagation);
this.fileUnsubscribe = this.file.subscribe(this.updateControls.bind(this));
}
@@ -123,10 +128,10 @@ export class RoutingControls {
for (let anchor of this.anchors) {
anchor.marker.remove();
}
this.map.off('zoom', this.toggleAnchorsForZoomLevelAndBoundsBinded);
this.map.off('move', this.toggleAnchorsForZoomLevelAndBoundsBinded);
this.map.off('click', this.appendAnchorBinded);
this.map.off('mousemove', this.fileId, this.showTemporaryAnchorBinded);
this.map.off('click', this.fileId, stopPropagation);
this.map.off('mousemove', this.updateTemporaryAnchorBinded);
this.temporaryAnchor.marker.remove();
@@ -139,7 +144,7 @@ export class RoutingControls {
createAnchor(point: TrackPoint, segment: TrackSegment, trackIndex: number, segmentIndex: number): AnchorWithMarker {
let element = document.createElement('div');
element.className = `h-3 w-3 rounded-full bg-white border-2 border-black cursor-pointer`;
element.className = `h-5 w-5 xs:h-4 xs:w-4 md:h-3 md:w-3 rounded-full bg-white border-2 border-black cursor-pointer`;
let marker = new mapboxgl.Marker({
draggable: true,
@@ -156,27 +161,37 @@ export class RoutingControls {
inZoom: false
};
let lastDragEvent = 0;
marker.on('dragstart', (e) => {
lastDragEvent = Date.now();
this.lastDragEvent = Date.now();
setGrabbingCursor();
element.classList.remove('cursor-pointer');
element.classList.add('cursor-grabbing');
});
marker.on('dragend', (e) => {
lastDragEvent = Date.now();
this.lastDragEvent = Date.now();
resetCursor();
element.classList.remove('cursor-grabbing');
element.classList.add('cursor-pointer');
this.moveAnchor(anchor);
});
marker.getElement().addEventListener('click', (e) => {
let handleAnchorClick = this.handleClickForAnchor(anchor, marker);
marker.getElement().addEventListener('click', handleAnchorClick);
marker.getElement().addEventListener('contextmenu', handleAnchorClick);
return anchor;
}
handleClickForAnchor(anchor: Anchor, marker: mapboxgl.Marker) {
return (e: any) => {
e.preventDefault();
e.stopPropagation();
if (marker === this.temporaryAnchor.marker) {
if (Date.now() - this.lastDragEvent < 100) { // Prevent click event during drag
return;
}
if (Date.now() - lastDragEvent < 100) { // Prevent click event during drag
if (marker === this.temporaryAnchor.marker) {
this.turnIntoPermanentAnchor();
return;
}
@@ -207,22 +222,21 @@ export class RoutingControls {
this.popupElement.removeEventListener('delete', deleteThisAnchor);
this.popupElement.removeEventListener('change-start', startLoopAtThisAnchor);
});
});
return anchor;
};
}
toggleAnchorsForZoomLevelAndBounds() { // Show markers only if they are in the current zoom level and bounds
this.shownAnchors.splice(0, this.shownAnchors.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 center = this.map.getCenter();
let bottomLeft = this.map.unproject([0, this.map.getCanvas().height]);
let topRight = this.map.unproject([this.map.getCanvas().width, 0]);
let diagonal = bottomLeft.distanceTo(topRight);
let zoom = this.map.getZoom();
this.anchors.forEach((anchor) => {
anchor.inZoom = anchor.point._data.zoom <= zoom;
if (anchor.inZoom && bounds.contains(anchor.marker.getLngLat())) {
if (anchor.inZoom && center.distanceTo(anchor.marker.getLngLat()) < diagonal) {
anchor.marker.addTo(this.map);
this.shownAnchors.push(anchor);
} else {
@@ -232,6 +246,10 @@ export class RoutingControls {
}
showTemporaryAnchor(e: any) {
if (this.temporaryAnchor.marker.getElement().classList.contains('cursor-grabbing')) { // Do not not change the source point if it is already being dragged
return;
}
if (get(streetViewEnabled)) {
return;
}
@@ -318,28 +336,83 @@ export class RoutingControls {
let file = get(this.file)?.file;
// Find the point closest to the temporary anchor
let minDistance = Number.MAX_VALUE;
let minDetails: any = { distance: Number.MAX_VALUE };
let minAnchor = this.temporaryAnchor as Anchor;
file?.forEachSegment((segment, trackIndex, segmentIndex) => {
if (get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex))) {
for (let point of segment.trkpt) {
let dist = distance(point.getCoordinates(), this.temporaryAnchor.point.getCoordinates());
if (dist < minDistance) {
minDistance = dist;
let details: any = {};
let closest = getClosestLinePoint(segment.trkpt, this.temporaryAnchor.point, details);
if (details.distance < minDetails.distance) {
minDetails = details;
minAnchor = {
point,
point: closest,
segment,
trackIndex,
segmentIndex,
segmentIndex
};
}
}
}
});
if (minAnchor.point._data.anchor) {
minAnchor.point = minAnchor.point.clone();
if (minDetails.before) {
minAnchor.point._data.index = minAnchor.point._data.index + 0.5;
} else {
minAnchor.point._data.index = minAnchor.point._data.index - 0.5;
}
}
return minAnchor;
}
turnIntoPermanentAnchor() {
let file = get(this.file)?.file;
// Find the point closest to the temporary anchor
let minDetails: any = { distance: Number.MAX_VALUE };
let minInfo = {
point: this.temporaryAnchor.point,
trackIndex: -1,
segmentIndex: -1,
trkptIndex: -1
};
file?.forEachSegment((segment, trackIndex, segmentIndex) => {
if (get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex))) {
let details: any = {};
getClosestLinePoint(segment.trkpt, this.temporaryAnchor.point, details);
if (details.distance < minDetails.distance) {
minDetails = details;
let before = details.before ? details.index : details.index - 1;
let projectedPt = projectedPoint(segment.trkpt[before], segment.trkpt[before + 1], this.temporaryAnchor.point);
let ratio = distance(segment.trkpt[before], projectedPt) / distance(segment.trkpt[before], segment.trkpt[before + 1]);
let point = segment.trkpt[before].clone();
point.setCoordinates(projectedPt);
point.ele = (1 - ratio) * (segment.trkpt[before].ele ?? 0) + ratio * (segment.trkpt[before + 1].ele ?? 0);
point.time = (segment.trkpt[before].time && segment.trkpt[before + 1].time) ? new Date((1 - ratio) * segment.trkpt[before].time.getTime() + ratio * segment.trkpt[before + 1].time.getTime()) : undefined;
point._data = {
anchor: true,
zoom: 0
};
minInfo = {
point,
trackIndex,
segmentIndex,
trkptIndex: before + 1
};
}
}
});
if (minInfo.trackIndex !== -1) {
dbUtils.applyToFile(this.fileId, (file) => file.replaceTrackPoints(minInfo.trackIndex, minInfo.segmentIndex, minInfo.trkptIndex, minInfo.trkptIndex - 1, [minInfo.point]));
}
}
getDeleteAnchor(anchor: Anchor) {
return () => this.deleteAnchor(anchor);
}
@@ -503,6 +576,9 @@ export class RoutingControls {
if (anchors[0].point._data.index === 0) { // First anchor is the first point of the segment
anchors[0].point = response[0]; // replace the first anchor
anchors[0].point._data.index = 0;
} else if (anchors[0].point._data.index === segment.trkpt.length - 1 && distance(anchors[0].point.getCoordinates(), response[0].getCoordinates()) < 1) { // First anchor is the last point of the segment, and the new point is close enough
anchors[0].point = response[0]; // replace the first anchor
anchors[0].point._data.index = segment.trkpt.length - 1;
} else {
anchors[0].point = anchors[0].point.clone(); // Clone the anchor to assign new properties
response.splice(0, 0, anchors[0].point); // Insert it in the response to keep it
@@ -519,16 +595,7 @@ export class RoutingControls {
for (let i = 1; i < anchors.length - 1; i++) {
// Find the closest point to the intermediate anchor
// and transfer the marker to that point
let minDistance = Number.MAX_VALUE;
let minIndex = 0;
for (let j = 1; j < response.length - 1; j++) {
let dist = distance(response[j].getCoordinates(), targetCoordinates[i]);
if (dist < minDistance) {
minDistance = dist;
minIndex = j;
}
}
anchors[i].point = response[minIndex];
anchors[i].point = getClosestLinePoint(response.slice(1, - 1), targetCoordinates[i]);
}
anchors.forEach((anchor) => {
@@ -553,6 +620,10 @@ export class RoutingControls {
let remainingTime = stats.global.time.moving - (stats.local.time.moving[anchors[anchors.length - 1].point._data.index] - stats.local.time.moving[anchors[0].point._data.index]);
let replacingTime = newTime - remainingTime;
if (replacingTime <= 0) { // Fallback to simple time difference
replacingTime = stats.local.time.total[anchors[anchors.length - 1].point._data.index] - stats.local.time.total[anchors[0].point._data.index];
}
speed = replacingDistance / replacingTime * 3600;
if (startTime === undefined) { // Replacing the first point

View File

@@ -10,7 +10,7 @@ export function getZoomLevelForDistance(latitude: number, distance?: number): nu
const rad = Math.PI / 180;
const lat = latitude * rad;
return Math.min(20, Math.max(0, Math.floor(Math.log2((earthRadius * Math.cos(lat)) / distance))));
return Math.min(22, Math.max(0, Math.log2((earthRadius * Math.cos(lat)) / distance)));
}
export function updateAnchorPoints(file: GPXFile) {
@@ -34,7 +34,7 @@ export function updateAnchorPoints(file: GPXFile) {
function computeAnchorPoints(segment: TrackSegment) {
let points = segment.trkpt;
let anchors = ramerDouglasPeucker(points);
let anchors = ramerDouglasPeucker(points, 1);
anchors.forEach((anchor) => {
let point = anchor.point;
point._data.anchor = true;

View File

@@ -15,21 +15,34 @@
import { Slider } from '$lib/components/ui/slider';
import * as Select from '$lib/components/ui/select';
import { Separator } from '$lib/components/ui/separator';
import { gpxStatistics, slicedGPXStatistics, splitAs } from '$lib/stores';
import { gpxStatistics, map, slicedGPXStatistics, splitAs } from '$lib/stores';
import { get } from 'svelte/store';
import { _ } from 'svelte-i18n';
import { onDestroy, tick } from 'svelte';
import { Crop } from 'lucide-svelte';
import { dbUtils } from '$lib/db';
import { SplitControls } from './SplitControls';
let splitControls: SplitControls | undefined = undefined;
let canCrop = false;
$: if ($map) {
if (splitControls) {
splitControls.destroy();
}
splitControls = new SplitControls($map);
}
$: validSelection =
$selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']) &&
$gpxStatistics.local.points.length > 0;
let maxSliderValue = 100;
let sliderValues = [0, 100];
let maxSliderValue = 1;
let sliderValues = [0, 1];
$: canCrop = sliderValues[0] != 0 || sliderValues[1] != maxSliderValue;
function updateCanCrop() {
canCrop = sliderValues[0] != 0 || sliderValues[1] != maxSliderValue;
}
function updateSlicedGPXStatistics() {
if (validSelection && canCrop) {
@@ -53,7 +66,7 @@
if (validSelection && $gpxStatistics.local.points.length > 0) {
maxSliderValue = $gpxStatistics.local.points.length - 1;
} else {
maxSliderValue = 100;
maxSliderValue = 1;
}
await tick();
sliderValues = [0, maxSliderValue];
@@ -64,6 +77,7 @@
}
$: if (sliderValues) {
updateCanCrop();
updateSlicedGPXStatistics();
}
@@ -72,6 +86,7 @@
($slicedGPXStatistics[1] !== sliderValues[0] || $slicedGPXStatistics[2] !== sliderValues[1])
) {
updateSliderValues();
updateCanCrop();
}
const splitTypes = [
@@ -86,6 +101,10 @@
onDestroy(() => {
$slicedGPXStatistics = undefined;
if (splitControls) {
splitControls.destroy();
splitControls = undefined;
}
});
</script>
@@ -116,7 +135,7 @@
</Select.Content>
</Select.Root>
</Label>
<Help>
<Help link="./help/toolbar/scissors">
{#if validSelection}
{$_('toolbar.scissors.help')}
{:else}

View File

@@ -0,0 +1,163 @@
import { TrackPoint, TrackSegment } from "gpx";
import { get } from "svelte/store";
import mapboxgl from "mapbox-gl";
import { dbUtils, getFile } from "$lib/db";
import { applyToOrderedSelectedItemsFromFile, selection } from "$lib/components/file-list/Selection";
import { ListTrackSegmentItem } from "$lib/components/file-list/FileList";
import { currentTool, gpxStatistics, Tool } from "$lib/stores";
import { _ } from "svelte-i18n";
import { Scissors } from "lucide-static";
export class SplitControls {
active: boolean = false;
map: mapboxgl.Map;
controls: ControlWithMarker[] = [];
shownControls: ControlWithMarker[] = [];
unsubscribes: Function[] = [];
toggleControlsForZoomLevelAndBoundsBinded: () => void = this.toggleControlsForZoomLevelAndBounds.bind(this);
constructor(map: mapboxgl.Map) {
this.map = map;
this.unsubscribes.push(selection.subscribe(this.addIfNeeded.bind(this)));
this.unsubscribes.push(gpxStatistics.subscribe(this.addIfNeeded.bind(this)));
this.unsubscribes.push(currentTool.subscribe(this.addIfNeeded.bind(this)));
}
addIfNeeded() {
let scissors = get(currentTool) === Tool.SCISSORS;
if (!scissors) {
if (this.active) {
this.remove();
}
return;
}
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() { // Update the markers when the files change
let controlIndex = 0;
applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
let file = getFile(fileId);
if (file) {
file.forEachSegment((segment, trackIndex, segmentIndex) => {
if (get(selection).hasAnyParent(new ListTrackSegmentItem(fileId, trackIndex, segmentIndex))) {
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) {
if (controlIndex < this.controls.length) {
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++;
}
}
}
});
}
}, false);
while (controlIndex < this.controls.length) { // Remove the extra controls
this.controls.pop()?.marker.remove();
}
this.toggleControlsForZoomLevelAndBounds();
}
remove() {
this.active = false;
for (let control of this.controls) {
control.marker.remove();
}
this.map.off('zoom', this.toggleControlsForZoomLevelAndBoundsBinded);
this.map.off('move', this.toggleControlsForZoomLevelAndBoundsBinded);
}
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();
}
});
}
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"');
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();
dbUtils.split(fileId, trackIndex, segmentIndex, point.getCoordinates(), point._data.index);
});
return control;
}
destroy() {
this.remove();
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;
};

View File

@@ -4,13 +4,14 @@
export let showHours = true;
export let value: number | undefined = undefined;
export let disabled: boolean = false;
export let onChange = () => {};
let hours: string | number = '--';
let minutes: string | number = '--';
let seconds: string | number = '--';
function maybeParseInt(value: string | number): number {
if (value === '--') {
if (value === '--' || value === '') {
return 0;
}
return typeof value === 'string' ? parseInt(value) : value;
@@ -84,12 +85,12 @@
} else {
hours = 0;
}
onChange();
}}
on:keypress={onKeyPress}
on:focusin={() => {
countKeyPress = 0;
}}
on:change
/>
<span class="text-sm">:</span>
{/if}
@@ -110,12 +111,12 @@
minutes = 0;
}
minutes = minutes.toString().padStart(showHours ? 2 : 1, '0');
onChange();
}}
on:keypress={onKeyPress}
on:focusin={() => {
countKeyPress = 0;
}}
on:change
/>
<span class="text-sm">:</span>
<TimeComponentInput
@@ -135,12 +136,12 @@
seconds = 0;
}
seconds = seconds.toString().padStart(2, '0');
onChange();
}}
on:keypress={onKeyPress}
on:focusin={() => {
countKeyPress = 0;
}}
on:change
/>
</div>

View File

@@ -2,13 +2,14 @@ import Dexie, { liveQuery } from 'dexie';
import { GPXFile, GPXStatistics, Track, TrackSegment, Waypoint, TrackPoint, type Coordinates, distance, type LineStyleExtension, type WaypointType } from 'gpx';
import { enableMapSet, enablePatches, applyPatches, type Patch, type WritableDraft, freeze, produceWithPatches } from 'immer';
import { writable, get, derived, type Readable, type Writable } from 'svelte/store';
import { Tool, currentTool, gpxStatistics, initTargetMapBounds, map, splitAs, updateAllHidden, updateTargetMapBounds } from './stores';
import { gpxStatistics, initTargetMapBounds, map, splitAs, updateAllHidden, updateTargetMapBounds } from './stores';
import { defaultBasemap, defaultBasemapTree, defaultOverlayTree, defaultOverlays, type CustomLayer, defaultOpacities, defaultOverpassQueries, defaultOverpassTree } from './assets/layers';
import { applyToOrderedItemsFromFile, applyToOrderedSelectedItemsFromFile, selection } from '$lib/components/file-list/Selection';
import { ListFileItem, ListItem, ListTrackItem, ListLevel, ListTrackSegmentItem, ListWaypointItem, ListRootItem } from '$lib/components/file-list/FileList';
import { updateAnchorPoints } from '$lib/components/toolbar/tools/routing/Simplify';
import { SplitType } from '$lib/components/toolbar/tools/Scissors.svelte';
import { getElevation, getPreciseElevations } from '$lib/utils';
import { SplitType } from '$lib/components/toolbar/tools/scissors/Scissors.svelte';
import { getClosestLinePoint, getElevation, getPreciseElevations } from '$lib/utils';
import { browser } from '$app/environment';
import type mapboxgl from 'mapbox-gl';
enableMapSet();
@@ -20,7 +21,7 @@ class Database extends Dexie {
files!: Dexie.Table<GPXFile, string>;
patches!: Dexie.Table<{ patch: Patch[], inversePatch: Patch[], index: number }, number>;
settings!: Dexie.Table<any, string>;
overpassquerytiles!: Dexie.Table<{ query: string, x: number, y: number }, [string, number, number]>;
overpasstiles!: Dexie.Table<{ query: string, x: number, y: number, time: number }, [string, number, number]>;
overpassdata!: Dexie.Table<{ query: string, id: number, poi: GeoJSON.Feature }, [string, number]>;
constructor() {
@@ -32,7 +33,7 @@ class Database extends Dexie {
files: '',
patches: ',patch',
settings: '',
overpassquerytiles: '[query+x+y],[x+y]',
overpasstiles: '[query+x+y],[x+y]',
overpassdata: '[query+id]',
});
}
@@ -41,14 +42,22 @@ class Database extends Dexie {
export const db = new Database();
// Wrap Dexie live queries in a Svelte store to avoid triggering the query for every subscriber, and updates to the store are pushed to the DB
export function bidirectionalDexieStore<K, V>(table: Dexie.Table<V, K>, key: K, initial: V, initialize: boolean = true): Writable<V> {
let store = writable(initialize ? initial : undefined);
export function bidirectionalDexieStore<K, V>(table: Dexie.Table<V, K>, key: K, initial: V, initialize: boolean = true): Writable<V | undefined> {
let first = true;
let store = writable<V | undefined>(initialize ? initial : undefined);
liveQuery(() => table.get(key)).subscribe(value => {
if (value === undefined && !initialize) {
if (value === undefined) {
if (first) {
if (!initialize) {
store.set(initial);
} else if (value !== undefined) {
}
} else {
store.set(value);
}
} else {
store.set(value);
}
first = false;
});
return {
subscribe: store.subscribe,
@@ -71,7 +80,7 @@ export function dexieSettingStore<T>(key: string, initial: T, initialize: boolea
}
export const settings = {
distanceUnits: dexieSettingStore<'metric' | 'imperial'>('distanceUnits', 'metric'),
distanceUnits: dexieSettingStore<'metric' | 'imperial' | 'nautical'>('distanceUnits', 'metric'),
velocityUnits: dexieSettingStore<'speed' | 'pace'>('velocityUnits', 'speed'),
temperatureUnits: dexieSettingStore<'celsius' | 'fahrenheit'>('temperatureUnits', 'celsius'),
elevationProfile: dexieSettingStore('elevationProfile', true),
@@ -92,16 +101,16 @@ export const settings = {
selectedOverpassTree: dexieSettingStore('selectedOverpassTree', defaultOverpassTree),
opacities: dexieSettingStore('opacities', defaultOpacities),
customLayers: dexieSettingStore<Record<string, CustomLayer>>('customLayers', {}),
customBasemapOrder: dexieSettingStore<string[]>('customBasemapOrder', []),
customOverlayOrder: dexieSettingStore<string[]>('customOverlayOrder', []),
directionMarkers: dexieSettingStore('directionMarkers', false),
distanceMarkers: dexieSettingStore('distanceMarkers', false),
stravaHeatmapColor: dexieSettingStore('stravaHeatmapColor', 'bluered'),
streetViewSource: dexieSettingStore('streetViewSource', 'mapillary'),
fileOrder: dexieSettingStore<string[]>('fileOrder', []),
defaultOpacity: dexieSettingStore('defaultOpacity', 0.7),
defaultWeight: dexieSettingStore('defaultWeight', 5),
defaultWeight: dexieSettingStore('defaultWeight', (browser && window.innerWidth < 600) ? 8 : 5),
bottomPanelSize: dexieSettingStore('bottomPanelSize', 170),
rightPanelSize: dexieSettingStore('rightPanelSize', 240),
showWelcomeMessage: dexieSettingStore('showWelcomeMessage', true, false),
};
// Wrap Dexie live queries in a Svelte store to avoid triggering the query for every subscriber
@@ -279,7 +288,14 @@ const fileState: Map<string, GPXFile> = new Map(); // Used to generate patches
// Observe the file ids in the database, and maintain a map of file observers for the corresponding files
export function observeFilesFromDatabase() {
let initialize = true;
liveQuery(() => db.fileids.toArray()).subscribe(dbFileIds => {
if (initialize) {
if (dbFileIds.length > 0) {
initTargetMapBounds(dbFileIds.length);
}
initialize = false;
}
// Find new files to observe
let newFiles = dbFileIds.filter(id => !get(fileObservers).has(id)).sort((a, b) => parseInt(a.split('-')[1]) - parseInt(b.split('-')[1]));
// Find deleted files to stop observing
@@ -288,9 +304,6 @@ export function observeFilesFromDatabase() {
// Update the store
if (newFiles.length > 0 || deletedFiles.length > 0) {
fileObservers.update($files => {
if (newFiles.length > 0 && get(currentTool) !== Tool.SCISSORS) { // Reset the target map bounds when new files are added
initTargetMapBounds(newFiles.length);
}
newFiles.forEach(id => {
$files.set(id, dexieGPXFileStore(id));
});
@@ -789,22 +802,20 @@ export const dbUtils = {
});
});
},
split(fileId: string, trackIndex: number, segmentIndex: number, coordinates: Coordinates) {
split(fileId: string, trackIndex: number, segmentIndex: number, coordinates: Coordinates, trkptIndex?: number) {
let splitType = get(splitAs);
return applyGlobal((draft) => {
let file = getFile(fileId);
if (file) {
let segment = file.trk[trackIndex].trkseg[segmentIndex];
// Find the point closest to split
let minDistance = Number.MAX_VALUE;
let minIndex = 0;
for (let i = 0; i < segment.trkpt.length; i++) {
let dist = distance(segment.trkpt[i].getCoordinates(), coordinates);
if (dist < minDistance) {
minDistance = dist;
minIndex = i;
}
if (trkptIndex === undefined) {
// Find the point closest to split
let closest = getClosestLinePoint(segment.trkpt, coordinates);
minIndex = closest._data.index;
} else {
minIndex = trkptIndex;
}
let absoluteIndex = minIndex;
@@ -907,6 +918,8 @@ export const dbUtils = {
wpt.name = waypoint.name;
wpt.desc = waypoint.desc;
wpt.cmt = waypoint.cmt;
wpt.sym = waypoint.sym;
wpt.link = waypoint.link;
wpt.setCoordinates(waypoint.attributes);
wpt.ele = ele;
});

View File

@@ -0,0 +1,40 @@
---
title: FAQ
---
<script>
import DocsNote from '$lib/components/docs/DocsNote.svelte';
</script>
# { title }
### Do I need to donate to use the website?
No.
The website is free to use and always will be (as long as it is financially sustainable).
However, donations are appreciated and help keep the website running.
### Why is this route chosen over that one? _Or_ how can I add something to the map?
**gpx.studio** uses data from <a href="https://www.openstreetmap.org/" target="_blank">OpenStreetMap</a>, which is an open and collaborative world map.
This means you can contribute to the map by adding or editing data on OpenStreetMap.
If you have never contributed to OpenStreetMap before, here is how you can suggest changes:
1. Go to the location where you want to add or edit data on the <a href="https://www.openstreetmap.org/" target="_blank">map</a>.
2. Use the <button>Query features</button> tool on the right to inspect the existing data.
3. Right-click on the location and select <button>Add a note here</button>.
4. Explain what is incorrect or missing in the note and click <button>Add note</button> to submit it.
Someone more experienced with OpenStreetMap will then review your note and make the necessary changes.
<DocsNote>
More information on how to contribute to OpenStreetMap can be found <a href="https://wiki.openstreetmap.org/wiki/How_to_contribute" target="_blank">here</a>.
</DocsNote>
### Why is the elevation profile for my GPX file empty?
If the elevation profile for your GPX file is empty, it means that the GPX file does not contain elevation data.
You can add elevation data to your GPX file by using <a href="https://www.gpsvisualizer.com/elevation" target="_blank">GPS Visualizer</a>.

View File

@@ -0,0 +1,82 @@
---
title: Files and statistics
---
<script>
import { TriangleRight, BrickWall, Zap, HeartPulse, Orbit, Thermometer, SquareActivity } from 'lucide-svelte';
import DocsNote from '$lib/components/docs/DocsNote.svelte';
</script>
# { title }
## File list
Once you have [opened](./menu/file) files, they will be shown as tabs in the file list located at the bottom of the map.
You can reorder them by dragging and dropping the tabs.
And when many files are open, you can scroll through the list of tabs to navigate between them.
<DocsNote>
When using a mouse, you need to hold <kbd>Shift</kbd> to scroll horizontally.
</DocsNote>
### File selection
By clicking on a tab, you can switch between the files to inspect their statistics, and apply [edit actions](./menu/edit) and [tools](./toolbar/) to them.
By holding the <kbd>Ctrl/Cmd</kbd> key, you can add files to the selection or remove them, and by holding <kbd>Shift</kbd>, you can select a range of files.
Most of the [edit actions](./menu/edit) and [tools](./toolbar/) can be applied to multiple files at once.
<DocsNote>
You can also navigate through the files using the arrow keys on your keyboard, and use <kbd>Shift</kbd> to add files to the selection.
</DocsNote>
### Edit actions
By right-clicking on a file tab, you can access the same actions as in the [edit menu](./menu/edit).
### Vertical layout
As mentioned in the [view options section](./menu/view), you can switch between a horizontal and a vertical layout for the file list.
The vertical file list is useful when you have many files open, or files with multiple [tracks, segments, or points of interest](../gpx).
Indeed, this layout allows you to inspect the content of the files through collapsible sections.
You can also apply [edit actions](./menu/edit) and [tools](./toolbar/) to internal file items.
Furthermore, you can drag and drop the inner items to reorder them, or move them in the hierarchy or even to another file.
<DocsNote>
The size of the file list can be adjusted by dragging the separator between the map and the file list.
</DocsNote>
## Elevation profile and statistics
At the bottom of the interface, you can find the elevation profile and statistics for the current selection.
<DocsNote>
The size of the elevation profile can be adjusted by dragging the separator between the map and the elevation profile.
</DocsNote>
### Interactive statistics
When hovering over the elevation profile, a tooltip will show statistics at the cursor position.
To get the statistics for a specific section of the elevation profile, you can drag a selection rectangle on the profile.
Click on the profile to reset the selection.
You can also use the mouse wheel to zoom in and out on the elevation profile, and move left and right by dragging the profile while holding the <kbd>Shift</kbd> key.
### Additional data
Using the buttons on the right of the elevation profile, you can optionally color the elevation profile by:
- **slope** <TriangleRight size="16" class="inline-block" style="margin-bottom: 2px" /> information computed from the elevation data, or
- **surface** <BrickWall size="16" class="inline-block" style="margin-bottom: 2px" /> 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> tags.
This is only available for files created with **gpx.studio**.
If your selection includes it, you can also visualize: **speed** <Zap size="16" class="inline-block" style="margin-bottom: 2px" />, **heart rate** <HeartPulse size="16" class="inline-block" style="margin-bottom: 2px" />, **cadence** <Orbit size="16" class="inline-block" style="margin-bottom: 2px" />, **temperature** <Thermometer size="16" class="inline-block" style="margin-bottom: 2px" />, and **power** <SquareActivity size="16" class="inline-block" style="margin-bottom: 2px" /> data on the elevation profile.

View File

@@ -0,0 +1,38 @@
---
title: Getting started
---
<script lang="ts">
import interfaceScreenshot from '$lib/assets/img/docs/getting-started/interface.png?enhanced';
import DocsImage from '$lib/components/docs/DocsImage.svelte';
</script>
# { title }
Welcome to the official guide for **gpx.studio**!
This guide will walk you through all the components and tools of the interface, helping you become a proficient user of the application.
<DocsImage src={interfaceScreenshot} alt="The gpx.studio interface." />
As shown in the screenshot above, the interface is divided into four main sections organized around the map.
Before we dive into the details of each section, let's have a quick overview of the interface.
## Menu
At the top of the interface, you will find the [main menu](./menu).
This is where you can access common actions such as opening, closing, and exporting files, undoing and redoing actions, and adjusting the application settings.
## Files and statistics
At the bottom of the interface, you will find the list of files currently open in the application.
You can click on a file to select it and display its statistics below the list.
In the [dedicated section](./files-and-stats), we will explain how to select multiple files and switch to a vertical layout for advanced file management.
## Toolbar
On the left side of the interface, you will find the [toolbar](./toolbar), which contains all the tools you can use to edit your files.
## Map controls
Finally, on the right side of the interface, you will find the [map controls](./map-controls).
These controls allow you to navigate the map, zoom in and out, and switch between different map styles.

View File

@@ -0,0 +1,34 @@
---
title: GPX file format
---
<script>
import { Waypoints, MapPin } from 'lucide-svelte';
</script>
# { title }
The <a href="https://www.topografix.com/gpx.asp" target="_blank">GPX file format</a> is an open standard for exchanging GPS data between applications and GPS devices.
It essentially consists of a series of GPS points encoding one or multiple GPS traces, and, optionally, some points of interest.
GPX files may also contain metadata, of which the **name** and **description** fields are the most useful for users.
### <Waypoints size="16" class="inline-block" style="margin-bottom: 2px" /> Tracks, segments, and GPS points
As mentioned above, a GPX file can contain multiple GPS traces.
These are organized in a hierarchical structure, with tracks at the top level.
- A **track** is made of a sequence of disconnected segments.
Furthermore, it can contain metadata such as a **name**, a **description**, and **appearance properties**.
- A **segment** is a sequence of GPS points that form a continuous path.
- A **GPS point** is a location with a latitude, a longitude, and optionally a timestamp and an altitude.
Some devices also store additional information such as heart rate, cadence, temperature, and power.
In most cases, GPX files contain a single track with a single segment.
However, the hierarchy described above allows for more advanced use cases, such as planning multi-day trips with several variants for each day.
### <MapPin size="16" class="inline-block" style="margin-bottom: 2px" /> Points of interest
**Points of interest** (technically called _waypoints_) represent locations of interest to show either on a GPS device or on a digital map.
In addition to its coordinates, a point of interest can have a **name** and a **description**.

View File

@@ -0,0 +1,13 @@
<script>
import { HeartHandshake } from 'lucide-svelte';
</script>
## <HeartHandshake size="18" class="mr-1 inline-block align-baseline" /> Ajuda a mantenir aquesta pàgina web gratuïta (i sense anuncis)
Cada cop que afegeixes o mous un punt GPS, els nostres servidors calculen la millor ruta possible.
També utilitzen la API de <a href="https://mapbox.com" target="_blank">Mapbox</a> per ensenyar mapes bonics, donar informació sobre l'altitud i permetre la cerca de llocs d'interès.
Desafortunadament, això és car.
Si gaudeixes aquesta eina i la trobes valuosa, si us plau, considera fer una petita donació per ajudar a mantenir la pàgina web gratuïta i sense anuncis.
Moltíssimes gràcies pel teu suport! ❤️

View File

@@ -0,0 +1,5 @@
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**.
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.

View File

@@ -0,0 +1,12 @@
<script>
import { Languages } from 'lucide-svelte';
</script>
## <Languages size="18" class="mr-1 inline-block align-baseline" />Traducció
Aquesta pàgina web ha estat traduïda per voluntaris utilitzant una plataforma de traducció col·laborativa.
Tu també pots contribuir-hi afegint o millorant les traduccions al nostre <a href="https://crowdin.com/project/gpxstudio" target="_blank">projecte de Crowdin</a>.
Si vols començar a traduir ara mateix a una nova llengua, si us plau <a href="#contact">posa't en contacte amb nosaltres</a>.
Qualsevol ajuda és molt apreciada!

View File

@@ -0,0 +1,27 @@
---
title: Integration
---
<script>
import DocsNote from '$lib/components/docs/DocsNote.svelte';
import EmbeddingPlayground from '$lib/components/embedding/EmbeddingPlayground.svelte';
</script>
# { title }
You can use **gpx.studio** to create maps showing your GPX files and embed them in your website.
All you need is:
1. A <a href="https://account.mapbox.com/auth/signup" target="_blank">Mapbox access token</a> to load the map, and
2. GPX files hosted on your server or on Google Drive, or accessible via a public URL.
You can then play with the configurator below to customize your map and generate the corresponding HTML code.
<DocsNote type="warning">
You will need to set up <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS" target="_blank">Cross-Origin Resource Sharing (CORS)</a> headers on your server to allow <b>gpx.studio</b> to load your GPX files.
</DocsNote>
<EmbeddingPlayground />

View File

@@ -0,0 +1,67 @@
---
title: Map controls
---
<script>
import { Plus, Minus, Diff, Compass, Search, LocateFixed, PersonStanding, Layers3 } from 'lucide-svelte';
import DocsNote from '$lib/components/docs/DocsNote.svelte';
import DocsLayers from '$lib/components/docs/DocsLayers.svelte';
</script>
# { title }
### <Diff size="16" class="inline-block" style="margin-bottom: 2px" /> Map navigation
The controls at the top allow you to zoom in <Plus size="16" class="inline-block" style="margin-bottom: 2px" /> and out <Minus size="16" class="inline-block" style="margin-bottom: 2px" />, and to change the orientation and tilt of the map <Compass size="16" class="inline-block" style="margin-bottom: 2px" />.
<DocsNote>
To control the orientation and tilt of the map, you can also drag the map while holding <kbd>Ctrl</kbd>.
</DocsNote>
### <Search size="16" class="inline-block" style="margin-bottom: 2px" /> Search bar
You can use the search bar to look for an address and navigate to it on the map.
### <LocateFixed size="16" class="inline-block" style="margin-bottom: 2px" /> Locate button
The locate button centers the map on your current location.
<DocsNote>
This only works if you have allowed your browser and <b>gpx.studio</b> to access your location.
</DocsNote>
### <PersonStanding size="16" class="inline-block" style="margin-bottom: 2px" /> Street view
This button can be used to enable street view mode on the map.
Depending on the street view source chosen in the [settings](./menu/settings), street view imagery can be accessed differently.
- <a href="https://www.mapillary.com/" target="_blank">Mapillary</a>: the street view coverage will appear as green lines on the map. When zoomed in enough, green dots will show the exact locations where street view imagery is available. Hovering over a green dot will show the street view image at that location.
- <a href="https://www.google.com/streetview/" target="_blank">Google Street View</a>: click on the map to open a new tab with the street view imagery at that location.
### <Layers3 size="16" class="inline-block" style="margin-bottom: 2px" /> Map layers
The map layers button allows you to switch between different basemaps, and toggle map overlays and categories of points of interest.
- **Basemaps** are background maps that present the main geographic features of the world.
Depending on their purpose, basemaps have different styles and levels of detail.
Only one basemap can be displayed at a time.
- **Overlays** are additional layers that can be displayed on top of the basemap to provide complementary information.
- **Points of interest** can be added to the map to show different categories of places, such as shops, restaurants, or accommodations.
<div class="flex flex-col items-center">
<DocsLayers />
<span class="text-sm text-center mt-2">
Hover over the map to show the <a href="https://hiking.waymarkedtrails.org" target="_blank">Waymarked Trails hiking</a> overlay on top of the <a href="https://www.mapbox.com/maps/outdoors" target="_blank">Mapbox Outdoors</a> basemap.
</span>
</div>
A large collection of global and local basemaps and overlays is available in **gpx.studio**, as well as a selection of point-of-interest categories.
They can be enabled in the [map layer settings dialog](./menu/settings).
In these settings, you can also manage the opacity of the overlays.
For advanced users, it is possible to add custom basemaps and overlays by providing <a href="https://en.wikipedia.org/wiki/Web_Map_Tile_Service" target="_blank">WMTS</a>, <a href="https://en.wikipedia.org/wiki/Web_Map_Service" target="_blank">WMS</a>, or <a href="https://docs.mapbox.com/help/glossary/style/" target="_blank">Mapbox style JSON</a> URLs.

View File

@@ -0,0 +1,17 @@
---
title: Menu
---
<script lang="ts">
import DocsNote from '$lib/components/docs/DocsNote.svelte';
</script>
# { title }
The main menu, located at the top of the interface, provides access to actions, options, and settings divided into several categories, explained separately in the following sections.
<DocsNote>
Most of the menu actions can also be performed using the keyboard shortcuts displayed in the menu.
</DocsNote>

View File

@@ -0,0 +1,74 @@
---
title: Edit actions
---
<script lang="ts">
import { Undo2, Redo2, Info, PaintBucket, EyeOff, FileStack, ClipboardCopy, Scissors, ClipboardPaste, Trash2, Maximize } from 'lucide-svelte';
import DocsNote from '$lib/components/docs/DocsNote.svelte';
</script>
# { title }
Unlike the file actions, the edit actions can potentially modify the content of the currently selected files.
Moreover, when the vertical 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.
### <Undo2 size="16" class="inline-block" style="margin-bottom: 2px" /><Redo2 size="16" class="inline-block" style="margin-bottom: 2px" /> Undo and redo
Using these buttons, you can undo or redo the last actions you performed.
This applies to all actions of the interface but not to view options, application settings, or map navigation.
### <Info size="16" class="inline-block" style="margin-bottom: 2px" /> Info...
Open the information dialog of the currently selected file item, where you can see and edit its name and description.
### <PaintBucket size="16" class="inline-block" style="margin-bottom: 2px" /> Appearance...
Open the appearance dialog, where you can change the color, opacity, and width of the selected file items on the map.
### <EyeOff size="16" class="inline-block" style="margin-bottom: 2px" /> Hide/unhide
Toggle the visibility of the selected file items on the map.
### <FileStack size="16" class="inline-block" style="margin-bottom: 2px" /> Select all
Add all file items in the current hierarchy level to the selection.
### <Maximize size="16" class="inline-block" style="margin-bottom: 2px" /> Center
Center the map on the selected file items.
### <ClipboardCopy size="16" class="inline-block" style="margin-bottom: 2px" /> Copy
Copy the selected file items to the clipboard.
<DocsNote>
This action is only available when the vertical layout of the files list is enabled.
</DocsNote>
### <Scissors size="16" class="inline-block" style="margin-bottom: 2px" /> Cut
Cut the selected file items to the clipboard.
<DocsNote>
This action is only available when the vertical layout of the files list is enabled.
</DocsNote>
### <ClipboardPaste size="16" class="inline-block" style="margin-bottom: 2px" /> Paste
Paste the file items from the clipboard to the current hierarchy level if they are compatible with it.
<DocsNote>
This action is only available when the vertical layout of the files list is enabled.
</DocsNote>
### <Trash2 size="16" class="inline-block" style="margin-bottom: 2px" /> Delete
Delete the selected file items.

View File

@@ -0,0 +1,52 @@
---
title: File actions
---
<script lang="ts">
import { Plus, FolderOpen, Copy, FileX, Download } from 'lucide-svelte';
import DocsNote from '$lib/components/docs/DocsNote.svelte';
</script>
# { title }
The file actions menu contains a set of pretty self-explanatory file operations.
### <Plus size="16" class="inline-block" style="margin-bottom: 2px" /> New
Create a new empty file.
### <FolderOpen size="16" class="inline-block" style="margin-bottom: 2px" /> Open...
Open files from your computer.
<DocsNote>
You can also drag and drop files directly from your file system into the window.
</DocsNote>
### <Copy size="16" class="inline-block" style="margin-bottom: 2px" /> Duplicate
Create a copy of the currently selected files.
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Close
Close the currently selected files.
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Close all
Close all files.
### <Download size="16" class="inline-block" style="margin-bottom: 2px" /> Export...
Open the export dialog to save the currently selected files to your computer.
### <Download size="16" class="inline-block" style="margin-bottom: 2px" /> Export all...
Open the export dialog to save all files to your computer.
<DocsNote type="warning">
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>

View File

@@ -0,0 +1,50 @@
---
title: Settings
---
<script lang="ts">
import { Ruler, Zap, Thermometer, Languages, Sun, PersonStanding, Layers3 } from 'lucide-svelte';
import DocsNote from '$lib/components/docs/DocsNote.svelte';
</script>
# { title }
### <Ruler size="16" class="inline-block" style="margin-bottom: 2px" /> Distance units
Change the units used to display distances in the interface.
### <Zap size="16" class="inline-block" style="margin-bottom: 2px" /> Velocity units
Change the units used to display velocities in the interface.
You can choose between distance per hour or minutes per distance, which can be more suitable for running activities.
### <Thermometer size="16" class="inline-block" style="margin-bottom: 2px" /> Temperature units
Change the units used to display temperatures in the interface.
### <Languages size="16" class="inline-block" style="margin-bottom: 2px" /> Language
Change the language used in the interface.
<DocsNote>
Tu també pots contribuir-hi afegint o millorant les traduccions al nostre <a href="https://crowdin.com/project/gpxstudio" target="_blank">projecte de Crowdin</a>.
Si vols començar a traduir ara mateix a una nova llengua, si us plau <a href="#contact">posa't en contacte amb nosaltres</a>.
Qualsevol ajuda és molt apreciada!
</DocsNote>
### <Sun size="16" class="inline-block" style="margin-bottom: 2px" /> Theme
Change the theme used in the interface.
### <PersonStanding size="16" class="inline-block" style="margin-bottom: 2px" /> Street view source
Change the source used for the [street view control](../map-controls).
The default one is <a href="https://www.mapillary.com" target="_blank">Mapillary</a>, but you can also use <a href="https://www.google.com/streetview/" target="_blank">Google Street View</a>.
Learn more about how to use the street view control in the [map controls section](../map-controls).
### <Layers3 size="16" class="inline-block" style="margin-bottom: 2px" /> Map layers...
Open a dialog where you can enable or disable map layers, add custom ones, change the opacity of overlays, and more.
More information about map layers can be found in the [map controls section](../map-controls).

View File

@@ -0,0 +1,48 @@
---
title: View options
---
<script lang="ts">
import { ChartArea, GalleryVertical, Map, Layers2, Coins, Milestone, Box } from 'lucide-svelte';
import DocsNote from '$lib/components/docs/DocsNote.svelte';
</script>
# { title }
This menu provides options to rearrange the interface and the map view.
### <ChartArea size="16" class="inline-block" style="margin-bottom: 2px" /> Elevation profile
Hide the elevation profile to make room for the map, or show it to inspect the current selection.
### <GalleryVertical size="16" class="inline-block" style="margin-bottom: 2px" /> Vertical file list
Switch between a vertical and a horizontal layout for the file list.
The [vertical file list](../files-and-stats) is useful when you have many files open, or files with multiple [tracks, segments, or points of interest](../gpx).
### <Map size="16" class="inline-block" style="margin-bottom: 2px" /> Switch to previous basemap
Change the basemap to the one previously selected through the [map layer control](../map-controls).
### <Layers2 size="16" class="inline-block" style="margin-bottom: 2px" /> Toggle overlays
Toggle the visibility of the map overlays selected through the [map layer control](../map-controls).
### <Coins size="16" class="inline-block" style="margin-bottom: 2px" /> Distance markers
Toggle the visibility of distance markers on the map.
They are displayed for the current selection, like the [elevation profile](../files-and-stats).
### <Milestone size="16" class="inline-block" style="margin-bottom: 2px" /> Direction arrows
Toggle the visibility of direction arrows on the map.
### <Box size="16" class="inline-block" style="margin-bottom: 2px" /> Toggle 3D
Enter or exit the 3D map view.
<DocsNote>
To control the orientation and tilt of the map, you can also drag the map while holding <kbd>Ctrl</kbd>.
</DocsNote>

View File

@@ -0,0 +1,32 @@
---
title: Toolbar
---
<script lang="ts">
import Toolbar from '$lib/components/toolbar/Toolbar.svelte';
import { currentTool, Tool } from '$lib/stores';
import { onMount, onDestroy } from 'svelte';
onMount(() => {
currentTool.set(Tool.ROUTING);
});
onDestroy(() => {
currentTool.set(null);
});
</script>
# { title }
The toolbar is located on the left side of the map and is the heart of the application, as it provides access to the main features of **gpx.studio**.
Each tool is represented by an icon and can be activated by clicking on it.
<div class="flex flex-row justify-center text-foreground">
<div>
<Toolbar class="border rounded-md shadow-lg" />
</div>
</div>
As with [edit actions](./menu/edit), most tools can be applied to multiple files at once and to [inner tracks and segments](./gpx).
The next sections describe each tool in detail.

View File

@@ -0,0 +1,18 @@
---
title: Clean
---
<script>
import { SquareDashedMousePointer } from 'lucide-svelte';
import Clean from '$lib/components/toolbar/tools/Clean.svelte';
</script>
# <SquareDashedMousePointer size="24" class="inline-block" style="margin-bottom: 5px" /> { title }
When the clean tool is selected, dragging the map will create a rectangular selection.
Depending on the options selected in the dialog shown below, clicking the delete button will remove GPS points and/or [points of interest](../gpx) located either inside or outside the selection.
<div class="flex flex-row justify-center">
<Clean class="text-foreground p-3 border rounded-md shadow-lg" />
</div>

View File

@@ -0,0 +1,26 @@
---
title: Extract
---
<script>
import { Ungroup } from 'lucide-svelte';
import Extract from '$lib/components/toolbar/tools/Extract.svelte';
import DocsNote from '$lib/components/docs/DocsNote.svelte';
</script>
# <Ungroup size="24" class="inline-block" style="margin-bottom: 5px" /> { title }
This tool allows you to extract [tracks (or segments)](../gpx) from files (or tracks) containing multiple of them.
<div class="flex flex-row justify-center">
<Extract class="text-foreground p-3 border rounded-md shadow-lg" />
</div>
Applying the tool to a file containing multiple tracks will create a new file for each of the tracks it contains.
Similarly, applying the tool to a track containing multiple segments will create (in the same file) a new track for each of the segments it contains.
<DocsNote>
When extracting the tracks from a file containing <a href="../gpx">points of interest</a>, the tool will automatically assign each point of interest to the track it is closest to.
</DocsNote>

View File

@@ -0,0 +1,20 @@
---
title: Merge
---
<script>
import { Group } from 'lucide-svelte';
import Merge from '$lib/components/toolbar/tools/Merge.svelte';
</script>
# <Group size="24" class="inline-block" style="margin-bottom: 5px" /> { title }
To use this tool, you need to [select](../files-and-stats) multiple files, [tracks, or segments](../gpx).
- If your goal is to create a single continuous trace from your selection, use the **Connect the traces** option and validate.
- The second option can be used to create or manage files with multiple [tracks or segments](../gpx).
Merging files (or tracks) will result in a single file (or track) containing all tracks (or segments) from the selection.
<div class="flex flex-row justify-center">
<Merge class="text-foreground p-3 border rounded-md shadow-lg" />
</div>

View File

@@ -0,0 +1,26 @@
---
title: Minify
---
<script>
import { Filter } from 'lucide-svelte';
import Reduce from '$lib/components/toolbar/tools/Reduce.svelte';
import DocsNote from '$lib/components/docs/DocsNote.svelte';
</script>
# <Filter size="24" class="inline-block" style="margin-bottom: 5px" /> { title }
This tool can be used to reduce the number of GPS points in a trace, which can be useful for decreasing its size.
You can adjust the tolerance of the simplification algorithm using the slider, and see the number of points that will be kept, as well as the simplified trace on the map.
<div class="flex flex-row justify-center">
<Reduce class="text-foreground p-3 border rounded-md shadow-lg" />
</div>
<DocsNote>
The tolerance value represents the maximum distance allowed between the original trace and the simplified trace.
You can read more about the algorithm used <a href="https://en.wikipedia.org/wiki/Ramer%E2%80%93Douglas%E2%80%93Peucker_algorithm" target="_blank">here</a>.
</DocsNote>

View File

@@ -0,0 +1,27 @@
---
title: Points of interest
---
<script>
import { MapPin } from 'lucide-svelte';
import Waypoint from '$lib/components/toolbar/tools/Waypoint.svelte';
</script>
# <MapPin size="24" class="inline-block" style="margin-bottom: 5px" /> { title }
[Points of interest](../gpx) can be added to GPX files to mark locations of interest on the map and display them on your GPS device.
### Creating a point of interest
To create a point of interest, fill in the form shown below.
You can choose the location of the point of interest either by clicking on the map or by entering the coordinates manually.
Validate the form when you are done.
<div class="flex flex-row justify-center">
<Waypoint class="text-foreground p-3 border rounded-md shadow-lg" />
</div>
### Editing a point of interest
The form above can also be used to edit an existing point of interest after selecting it on the map.
If you only need to move the point of interest, you can drag it to the desired location.

View File

@@ -0,0 +1,85 @@
---
title: Route planning and editing
---
<script>
import { Pencil, Route, Bike, TriangleAlert, ArrowRightLeft, Home, Repeat, Trash2, CirclePlay, SquareArrowUpLeft } from 'lucide-svelte';
import DocsNote from '$lib/components/docs/DocsNote.svelte';
import Routing from '$lib/components/toolbar/tools/routing/Routing.svelte';
import routingScreenshot from '$lib/assets/img/docs/tools/routing.png?enhanced';
import DocsImage from '$lib/components/docs/DocsImage.svelte';
</script>
# <Pencil size="24" class="inline-block" style="margin-bottom: 5px" /> { title }
The route planning and editing tool allows you to create and edit routes by placing or moving anchor points on the map.
## Settings
As shown below, the tool dialog contains a few settings to control the routing behavior.
You can minimize the dialog to save space by clicking on <button><SquareArrowUpLeft size="16" class="inline-block" style="margin-bottom: 2px" /></button>.
<div class="flex flex-row justify-center">
<Routing minimizable={false} class="text-foreground p-3 border rounded-md shadow-lg" />
</div>
### <Route size="16" class="inline-block" style="margin-bottom: 2px" /> Routing
When routing is enabled, anchor points placed or moved on the map will be connected by a route calculated on the <a href="https://www.openstreetmap.org" target="_blank">OpenStreetMap</a> road network.
Disable routing to connect anchor points with straight lines.
This setting can also be toggled by pressing <kbd>F5</kbd>.
### <Bike size="16" class="inline-block" style="margin-bottom: 2px" /> Activity
Select the activity type to tailor the routes for.
### <TriangleAlert size="16" class="inline-block" style="margin-bottom: 2px" /> Allow private roads
When enabled, the routing engine will consider private roads when computing routes.
<DocsNote type="warning">
Only use this option if you have local knowledge of the area and have permission to use the roads in question.
</DocsNote>
## Plotting and editing routes
Creating a route or extending an existing one is as simple as clicking on the map to place a new anchor point.
You can also drag an existing anchor point to reroute the segment connecting it with the previous and next anchor point.
Furthermore, new anchor points can be inserted between existing ones by hovering over the segment connecting them and dragging the anchor point that appears to the desired location.
On touch devices, you can tap on the segment to insert a new anchor point.
<DocsNote>
When editing imported GPX files, an initial set of anchor points is created automatically.
To ease the editing process, the more the map is zoomed in, the more anchor points are displayed.
This allows the route to be edited at different levels of detail.
</DocsNote>
Finally, you can delete anchor points by clicking on them and selecting <button><Trash2 size="16" class="inline-block" style="margin-bottom: 4px" /> Delete</button> from the context menu.
<DocsImage src={routingScreenshot} alt="Anchor points allow you to easily edit a route." />
## Additional tools
The following tools automate some common route modification operations.
### <ArrowRightLeft size="16" class="inline-block" style="margin-bottom: 2px" /> Reverse
Reverse the direction of the route.
### <Home size="16" class="inline-block" style="margin-bottom: 2px" /> Back to start
Connect the last point of the route with the starting point, using the chosen routing settings.
### <Repeat size="16" class="inline-block" style="margin-bottom: 2px" /> Round trip
Return to the starting point by the same route.
### <CirclePlay size="16" class="inline-block" style="margin-bottom: 2px" /> Change the start of the loop
When the end point of the route is close enough to the start, you can change the start of the loop by clicking on any anchor point and selecting <button><CirclePlay size="16" class="inline-block" style="margin-bottom: 2px" /> Start loop here</button> from the context menu.

View File

@@ -0,0 +1,33 @@
---
title: Crop and split
---
<script>
import { ScissorsIcon } from 'lucide-svelte';
import Scissors from '$lib/components/toolbar/tools/scissors/Scissors.svelte';
import splitScreenshot from '$lib/assets/img/docs/tools/split.png?enhanced';
import DocsImage from '$lib/components/docs/DocsImage.svelte';
</script>
# <ScissorsIcon size="24" class="inline-block" style="margin-bottom: 5px" /> { title }
## Crop
Using the slider, you can define the part of the selected trace that you want to keep.
The start and end markers on the map and the [statistics and elevation profile](../files-and-stats) are updated in real time to reflect the selection.
Alternatively, you can drag a selection rectangle directly on the elevation profile.
Validate the selection when you are satisfied with the result.
<div class="flex flex-row justify-center">
<Scissors class="text-foreground p-3 border rounded-md shadow-lg" />
</div>
## Split
To split the selected trace into two parts, click on one of the split markers displayed along the trace.
To split at a specific point of your choice, hover over the trace on the map.
Scissors will appear at the cursor position, showing that you can split the trace at that point.
You can choose to split the trace into two GPX files, or to keep the split parts in the same file as [tracks or segments](../gpx).
<DocsImage src={splitScreenshot} alt="Hovering over the selected trace turns your cursor into scissors." />

View File

@@ -0,0 +1,27 @@
---
title: Time
---
<script>
import { CalendarClock } from 'lucide-svelte';
import Time from '$lib/components/toolbar/tools/Time.svelte';
import DocsNote from '$lib/components/docs/DocsNote.svelte';
</script>
# <CalendarClock size="24" class="inline-block" style="margin-bottom: 5px" /> { title }
This tool allows you to change or add timestamps to a trace.
You simply need to use the form shown below and validate it when you are done.
<div class="flex flex-row justify-center">
<Time class="text-foreground p-3 border rounded-md shadow-lg" />
</div>
When you edit the speed, the moving time is adapted accordingly in the form, and vice versa.
Similarly, when you edit the start time, the end time is updated to keep the same total duration, and vice versa.
<DocsNote>
When using this tool with existing timestamps, changing the time or speed will simply shift, stretch, or compress them accordingly.
</DocsNote>

View File

@@ -0,0 +1,40 @@
---
title: FAQ
---
<script>
import DocsNote from '$lib/components/docs/DocsNote.svelte';
</script>
# { title }
### Do I need to donate to use the website?
No.
The website is free to use and always will be (as long as it is financially sustainable).
However, donations are appreciated and help keep the website running.
### Why is this route chosen over that one? _Or_ how can I add something to the map?
**gpx.studio** uses data from <a href="https://www.openstreetmap.org/" target="_blank">OpenStreetMap</a>, which is an open and collaborative world map.
This means you can contribute to the map by adding or editing data on OpenStreetMap.
If you have never contributed to OpenStreetMap before, here is how you can suggest changes:
1. Go to the location where you want to add or edit data on the <a href="https://www.openstreetmap.org/" target="_blank">map</a>.
2. Use the <button>Query features</button> tool on the right to inspect the existing data.
3. Right-click on the location and select <button>Add a note here</button>.
4. Explain what is incorrect or missing in the note and click <button>Add note</button> to submit it.
Someone more experienced with OpenStreetMap will then review your note and make the necessary changes.
<DocsNote>
More information on how to contribute to OpenStreetMap can be found <a href="https://wiki.openstreetmap.org/wiki/How_to_contribute" target="_blank">here</a>.
</DocsNote>
### Why is the elevation profile for my GPX file empty?
If the elevation profile for your GPX file is empty, it means that the GPX file does not contain elevation data.
You can add elevation data to your GPX file by using <a href="https://www.gpsvisualizer.com/elevation" target="_blank">GPS Visualizer</a>.

View File

@@ -0,0 +1,82 @@
---
title: Files and statistics
---
<script>
import { TriangleRight, BrickWall, Zap, HeartPulse, Orbit, Thermometer, SquareActivity } from 'lucide-svelte';
import DocsNote from '$lib/components/docs/DocsNote.svelte';
</script>
# { title }
## File list
Once you have [opened](./menu/file) files, they will be shown as tabs in the file list located at the bottom of the map.
You can reorder them by dragging and dropping the tabs.
And when many files are open, you can scroll through the list of tabs to navigate between them.
<DocsNote>
When using a mouse, you need to hold <kbd>Shift</kbd> to scroll horizontally.
</DocsNote>
### File selection
By clicking on a tab, you can switch between the files to inspect their statistics, and apply [edit actions](./menu/edit) and [tools](./toolbar/) to them.
By holding the <kbd>Ctrl/Cmd</kbd> key, you can add files to the selection or remove them, and by holding <kbd>Shift</kbd>, you can select a range of files.
Most of the [edit actions](./menu/edit) and [tools](./toolbar/) can be applied to multiple files at once.
<DocsNote>
You can also navigate through the files using the arrow keys on your keyboard, and use <kbd>Shift</kbd> to add files to the selection.
</DocsNote>
### Edit actions
By right-clicking on a file tab, you can access the same actions as in the [edit menu](./menu/edit).
### Vertical layout
As mentioned in the [view options section](./menu/view), you can switch between a horizontal and a vertical layout for the file list.
The vertical file list is useful when you have many files open, or files with multiple [tracks, segments, or points of interest](../gpx).
Indeed, this layout allows you to inspect the content of the files through collapsible sections.
You can also apply [edit actions](./menu/edit) and [tools](./toolbar/) to internal file items.
Furthermore, you can drag and drop the inner items to reorder them, or move them in the hierarchy or even to another file.
<DocsNote>
The size of the file list can be adjusted by dragging the separator between the map and the file list.
</DocsNote>
## Elevation profile and statistics
At the bottom of the interface, you can find the elevation profile and statistics for the current selection.
<DocsNote>
The size of the elevation profile can be adjusted by dragging the separator between the map and the elevation profile.
</DocsNote>
### Interactive statistics
When hovering over the elevation profile, a tooltip will show statistics at the cursor position.
To get the statistics for a specific section of the elevation profile, you can drag a selection rectangle on the profile.
Click on the profile to reset the selection.
You can also use the mouse wheel to zoom in and out on the elevation profile, and move left and right by dragging the profile while holding the <kbd>Shift</kbd> key.
### Additional data
Using the buttons on the right of the elevation profile, you can optionally color the elevation profile by:
- **slope** <TriangleRight size="16" class="inline-block" style="margin-bottom: 2px" /> information computed from the elevation data, or
- **surface** <BrickWall size="16" class="inline-block" style="margin-bottom: 2px" /> 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> tags.
This is only available for files created with **gpx.studio**.
If your selection includes it, you can also visualize: **speed** <Zap size="16" class="inline-block" style="margin-bottom: 2px" />, **heart rate** <HeartPulse size="16" class="inline-block" style="margin-bottom: 2px" />, **cadence** <Orbit size="16" class="inline-block" style="margin-bottom: 2px" />, **temperature** <Thermometer size="16" class="inline-block" style="margin-bottom: 2px" />, and **power** <SquareActivity size="16" class="inline-block" style="margin-bottom: 2px" /> data on the elevation profile.

View File

@@ -0,0 +1,38 @@
---
title: Getting started
---
<script lang="ts">
import interfaceScreenshot from '$lib/assets/img/docs/getting-started/interface.png?enhanced';
import DocsImage from '$lib/components/docs/DocsImage.svelte';
</script>
# { title }
Welcome to the official guide for **gpx.studio**!
This guide will walk you through all the components and tools of the interface, helping you become a proficient user of the application.
<DocsImage src={interfaceScreenshot} alt="The gpx.studio interface." />
As shown in the screenshot above, the interface is divided into four main sections organized around the map.
Before we dive into the details of each section, let's have a quick overview of the interface.
## Menu
At the top of the interface, you will find the [main menu](./menu).
This is where you can access common actions such as opening, closing, and exporting files, undoing and redoing actions, and adjusting the application settings.
## Files and statistics
At the bottom of the interface, you will find the list of files currently open in the application.
You can click on a file to select it and display its statistics below the list.
In the [dedicated section](./files-and-stats), we will explain how to select multiple files and switch to a vertical layout for advanced file management.
## Toolbar
On the left side of the interface, you will find the [toolbar](./toolbar), which contains all the tools you can use to edit your files.
## Map controls
Finally, on the right side of the interface, you will find the [map controls](./map-controls).
These controls allow you to navigate the map, zoom in and out, and switch between different map styles.

View File

@@ -0,0 +1,34 @@
---
title: GPX file format
---
<script>
import { Waypoints, MapPin } from 'lucide-svelte';
</script>
# { title }
The <a href="https://www.topografix.com/gpx.asp" target="_blank">GPX file format</a> is an open standard for exchanging GPS data between applications and GPS devices.
It essentially consists of a series of GPS points encoding one or multiple GPS traces, and, optionally, some points of interest.
GPX files may also contain metadata, of which the **name** and **description** fields are the most useful for users.
### <Waypoints size="16" class="inline-block" style="margin-bottom: 2px" /> Tracks, segments, and GPS points
As mentioned above, a GPX file can contain multiple GPS traces.
These are organized in a hierarchical structure, with tracks at the top level.
- A **track** is made of a sequence of disconnected segments.
Furthermore, it can contain metadata such as a **name**, a **description**, and **appearance properties**.
- A **segment** is a sequence of GPS points that form a continuous path.
- A **GPS point** is a location with a latitude, a longitude, and optionally a timestamp and an altitude.
Some devices also store additional information such as heart rate, cadence, temperature, and power.
In most cases, GPX files contain a single track with a single segment.
However, the hierarchy described above allows for more advanced use cases, such as planning multi-day trips with several variants for each day.
### <MapPin size="16" class="inline-block" style="margin-bottom: 2px" /> Points of interest
**Points of interest** (technically called _waypoints_) represent locations of interest to show either on a GPS device or on a digital map.
In addition to its coordinates, a point of interest can have a **name** and a **description**.

View File

@@ -0,0 +1,13 @@
<script>
import { HeartHandshake } from 'lucide-svelte';
</script>
## <HeartHandshake size="18" class="mr-1 inline-block align-baseline" /> Help keep the website free (and ad-free)
Each time you add or move GPS points, our servers calculate the best route on the road network.
We also use APIs from <a href="https://mapbox.com" target="_blank">Mapbox</a> to display beautiful maps, retrieve elevation data and allow you to search for places.
Unfortunately, this is expensive.
If you enjoy using this tool and find it valuable, please consider making a small donation to help keep the website free and ad-free.
Thank you very much for your support! ❤️

View File

@@ -0,0 +1,5 @@
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**.
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.

View File

@@ -0,0 +1,12 @@
<script>
import { Languages } from 'lucide-svelte';
</script>
## <Languages size="18" class="mr-1 inline-block align-baseline" /> Translation
The website is translated by volunteers using a collaborative translation platform.
You can contribute by adding or improving translations on our <a href="https://crowdin.com/project/gpxstudio" target="_blank">Crowdin project</a>.
If you would like to start translating into a new language, please <a href="#contact">get in touch</a>.
Jakákoliv pomoc je velmi ceněna!

View File

@@ -0,0 +1,27 @@
---
title: Integration
---
<script>
import DocsNote from '$lib/components/docs/DocsNote.svelte';
import EmbeddingPlayground from '$lib/components/embedding/EmbeddingPlayground.svelte';
</script>
# { title }
You can use **gpx.studio** to create maps showing your GPX files and embed them in your website.
All you need is:
1. A <a href="https://account.mapbox.com/auth/signup" target="_blank">Mapbox access token</a> to load the map, and
2. GPX files hosted on your server or on Google Drive, or accessible via a public URL.
You can then play with the configurator below to customize your map and generate the corresponding HTML code.
<DocsNote type="warning">
You will need to set up <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS" target="_blank">Cross-Origin Resource Sharing (CORS)</a> headers on your server to allow <b>gpx.studio</b> to load your GPX files.
</DocsNote>
<EmbeddingPlayground />

View File

@@ -0,0 +1,67 @@
---
title: Map controls
---
<script>
import { Plus, Minus, Diff, Compass, Search, LocateFixed, PersonStanding, Layers3 } from 'lucide-svelte';
import DocsNote from '$lib/components/docs/DocsNote.svelte';
import DocsLayers from '$lib/components/docs/DocsLayers.svelte';
</script>
# { title }
### <Diff size="16" class="inline-block" style="margin-bottom: 2px" /> Map navigation
The controls at the top allow you to zoom in <Plus size="16" class="inline-block" style="margin-bottom: 2px" /> and out <Minus size="16" class="inline-block" style="margin-bottom: 2px" />, and to change the orientation and tilt of the map <Compass size="16" class="inline-block" style="margin-bottom: 2px" />.
<DocsNote>
To control the orientation and tilt of the map, you can also drag the map while holding <kbd>Ctrl</kbd>.
</DocsNote>
### <Search size="16" class="inline-block" style="margin-bottom: 2px" /> Search bar
You can use the search bar to look for an address and navigate to it on the map.
### <LocateFixed size="16" class="inline-block" style="margin-bottom: 2px" /> Locate button
The locate button centers the map on your current location.
<DocsNote>
This only works if you have allowed your browser and <b>gpx.studio</b> to access your location.
</DocsNote>
### <PersonStanding size="16" class="inline-block" style="margin-bottom: 2px" /> Street view
This button can be used to enable street view mode on the map.
Depending on the street view source chosen in the [settings](./menu/settings), street view imagery can be accessed differently.
- <a href="https://www.mapillary.com/" target="_blank">Mapillary</a>: the street view coverage will appear as green lines on the map. When zoomed in enough, green dots will show the exact locations where street view imagery is available. Hovering over a green dot will show the street view image at that location.
- <a href="https://www.google.com/streetview/" target="_blank">Google Street View</a>: click on the map to open a new tab with the street view imagery at that location.
### <Layers3 size="16" class="inline-block" style="margin-bottom: 2px" /> Map layers
The map layers button allows you to switch between different basemaps, and toggle map overlays and categories of points of interest.
- **Basemaps** are background maps that present the main geographic features of the world.
Depending on their purpose, basemaps have different styles and levels of detail.
Only one basemap can be displayed at a time.
- **Overlays** are additional layers that can be displayed on top of the basemap to provide complementary information.
- **Points of interest** can be added to the map to show different categories of places, such as shops, restaurants, or accommodations.
<div class="flex flex-col items-center">
<DocsLayers />
<span class="text-sm text-center mt-2">
Hover over the map to show the <a href="https://hiking.waymarkedtrails.org" target="_blank">Waymarked Trails hiking</a> overlay on top of the <a href="https://www.mapbox.com/maps/outdoors" target="_blank">Mapbox Outdoors</a> basemap.
</span>
</div>
A large collection of global and local basemaps and overlays is available in **gpx.studio**, as well as a selection of point-of-interest categories.
They can be enabled in the [map layer settings dialog](./menu/settings).
In these settings, you can also manage the opacity of the overlays.
For advanced users, it is possible to add custom basemaps and overlays by providing <a href="https://en.wikipedia.org/wiki/Web_Map_Tile_Service" target="_blank">WMTS</a>, <a href="https://en.wikipedia.org/wiki/Web_Map_Service" target="_blank">WMS</a>, or <a href="https://docs.mapbox.com/help/glossary/style/" target="_blank">Mapbox style JSON</a> URLs.

View File

@@ -0,0 +1,17 @@
---
title: Menu
---
<script lang="ts">
import DocsNote from '$lib/components/docs/DocsNote.svelte';
</script>
# { title }
The main menu, located at the top of the interface, provides access to actions, options, and settings divided into several categories, explained separately in the following sections.
<DocsNote>
Most of the menu actions can also be performed using the keyboard shortcuts displayed in the menu.
</DocsNote>

View File

@@ -0,0 +1,74 @@
---
title: Edit actions
---
<script lang="ts">
import { Undo2, Redo2, Info, PaintBucket, EyeOff, FileStack, ClipboardCopy, Scissors, ClipboardPaste, Trash2, Maximize } from 'lucide-svelte';
import DocsNote from '$lib/components/docs/DocsNote.svelte';
</script>
# { title }
Unlike the file actions, the edit actions can potentially modify the content of the currently selected files.
Moreover, when the vertical 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.
### <Undo2 size="16" class="inline-block" style="margin-bottom: 2px" /><Redo2 size="16" class="inline-block" style="margin-bottom: 2px" /> Undo and redo
Using these buttons, you can undo or redo the last actions you performed.
This applies to all actions of the interface but not to view options, application settings, or map navigation.
### <Info size="16" class="inline-block" style="margin-bottom: 2px" /> Info...
Open the information dialog of the currently selected file item, where you can see and edit its name and description.
### <PaintBucket size="16" class="inline-block" style="margin-bottom: 2px" /> Appearance...
Open the appearance dialog, where you can change the color, opacity, and width of the selected file items on the map.
### <EyeOff size="16" class="inline-block" style="margin-bottom: 2px" /> Hide/unhide
Toggle the visibility of the selected file items on the map.
### <FileStack size="16" class="inline-block" style="margin-bottom: 2px" /> Select all
Add all file items in the current hierarchy level to the selection.
### <Maximize size="16" class="inline-block" style="margin-bottom: 2px" /> Center
Center the map on the selected file items.
### <ClipboardCopy size="16" class="inline-block" style="margin-bottom: 2px" /> Copy
Copy the selected file items to the clipboard.
<DocsNote>
This action is only available when the vertical layout of the files list is enabled.
</DocsNote>
### <Scissors size="16" class="inline-block" style="margin-bottom: 2px" /> Cut
Cut the selected file items to the clipboard.
<DocsNote>
This action is only available when the vertical layout of the files list is enabled.
</DocsNote>
### <ClipboardPaste size="16" class="inline-block" style="margin-bottom: 2px" /> Paste
Paste the file items from the clipboard to the current hierarchy level if they are compatible with it.
<DocsNote>
This action is only available when the vertical layout of the files list is enabled.
</DocsNote>
### <Trash2 size="16" class="inline-block" style="margin-bottom: 2px" /> Delete
Delete the selected file items.

View File

@@ -0,0 +1,52 @@
---
title: File actions
---
<script lang="ts">
import { Plus, FolderOpen, Copy, FileX, Download } from 'lucide-svelte';
import DocsNote from '$lib/components/docs/DocsNote.svelte';
</script>
# { title }
The file actions menu contains a set of pretty self-explanatory file operations.
### <Plus size="16" class="inline-block" style="margin-bottom: 2px" /> New
Create a new empty file.
### <FolderOpen size="16" class="inline-block" style="margin-bottom: 2px" /> Open...
Open files from your computer.
<DocsNote>
You can also drag and drop files directly from your file system into the window.
</DocsNote>
### <Copy size="16" class="inline-block" style="margin-bottom: 2px" /> Duplicate
Create a copy of the currently selected files.
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Close
Close the currently selected files.
### <FileX size="16" class="inline-block" style="margin-bottom: 2px" /> Close all
Close all files.
### <Download size="16" class="inline-block" style="margin-bottom: 2px" /> Export...
Open the export dialog to save the currently selected files to your computer.
### <Download size="16" class="inline-block" style="margin-bottom: 2px" /> Export all...
Open the export dialog to save all files to your computer.
<DocsNote type="warning">
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>

View File

@@ -0,0 +1,50 @@
---
title: Settings
---
<script lang="ts">
import { Ruler, Zap, Thermometer, Languages, Sun, PersonStanding, Layers3 } from 'lucide-svelte';
import DocsNote from '$lib/components/docs/DocsNote.svelte';
</script>
# { title }
### <Ruler size="16" class="inline-block" style="margin-bottom: 2px" /> Distance units
Change the units used to display distances in the interface.
### <Zap size="16" class="inline-block" style="margin-bottom: 2px" /> Velocity units
Change the units used to display velocities in the interface.
You can choose between distance per hour or minutes per distance, which can be more suitable for running activities.
### <Thermometer size="16" class="inline-block" style="margin-bottom: 2px" /> Temperature units
Change the units used to display temperatures in the interface.
### <Languages size="16" class="inline-block" style="margin-bottom: 2px" /> Language
Change the language used in the interface.
<DocsNote>
You can contribute by adding or improving translations on our <a href="https://crowdin.com/project/gpxstudio" target="_blank">Crowdin project</a>.
If you would like to start translating into a new language, please <a href="#contact">get in touch</a>.
Jakákoliv pomoc je velmi ceněna!
</DocsNote>
### <Sun size="16" class="inline-block" style="margin-bottom: 2px" /> Theme
Change the theme used in the interface.
### <PersonStanding size="16" class="inline-block" style="margin-bottom: 2px" /> Street view source
Change the source used for the [street view control](../map-controls).
The default one is <a href="https://www.mapillary.com" target="_blank">Mapillary</a>, but you can also use <a href="https://www.google.com/streetview/" target="_blank">Google Street View</a>.
Learn more about how to use the street view control in the [map controls section](../map-controls).
### <Layers3 size="16" class="inline-block" style="margin-bottom: 2px" /> Map layers...
Open a dialog where you can enable or disable map layers, add custom ones, change the opacity of overlays, and more.
More information about map layers can be found in the [map controls section](../map-controls).

View File

@@ -0,0 +1,48 @@
---
title: View options
---
<script lang="ts">
import { ChartArea, GalleryVertical, Map, Layers2, Coins, Milestone, Box } from 'lucide-svelte';
import DocsNote from '$lib/components/docs/DocsNote.svelte';
</script>
# { title }
This menu provides options to rearrange the interface and the map view.
### <ChartArea size="16" class="inline-block" style="margin-bottom: 2px" /> Elevation profile
Hide the elevation profile to make room for the map, or show it to inspect the current selection.
### <GalleryVertical size="16" class="inline-block" style="margin-bottom: 2px" /> Vertical file list
Switch between a vertical and a horizontal layout for the file list.
The [vertical file list](../files-and-stats) is useful when you have many files open, or files with multiple [tracks, segments, or points of interest](../gpx).
### <Map size="16" class="inline-block" style="margin-bottom: 2px" /> Switch to previous basemap
Change the basemap to the one previously selected through the [map layer control](../map-controls).
### <Layers2 size="16" class="inline-block" style="margin-bottom: 2px" /> Toggle overlays
Toggle the visibility of the map overlays selected through the [map layer control](../map-controls).
### <Coins size="16" class="inline-block" style="margin-bottom: 2px" /> Distance markers
Toggle the visibility of distance markers on the map.
They are displayed for the current selection, like the [elevation profile](../files-and-stats).
### <Milestone size="16" class="inline-block" style="margin-bottom: 2px" /> Direction arrows
Toggle the visibility of direction arrows on the map.
### <Box size="16" class="inline-block" style="margin-bottom: 2px" /> Toggle 3D
Enter or exit the 3D map view.
<DocsNote>
To control the orientation and tilt of the map, you can also drag the map while holding <kbd>Ctrl</kbd>.
</DocsNote>

Some files were not shown because too many files have changed in this diff Show More