mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2025-08-31 15:43:25 +00:00
Merge branch 'dev' into elevation-tool
This commit is contained in:
2
.github/workflows/deploy.yml
vendored
2
.github/workflows/deploy.yml
vendored
@@ -36,7 +36,7 @@ jobs:
|
||||
|
||||
- name: Build website
|
||||
env:
|
||||
BASE_PATH: '/${{ github.event.repository.name }}'
|
||||
BASE_PATH: ''
|
||||
run: |
|
||||
npm run build --prefix website
|
||||
|
||||
|
@@ -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.
|
||||
|
||||

|
||||
|
||||
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
5
crowdin.yml
Normal 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%
|
@@ -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
3615
gpx/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
308
gpx/src/gpx.ts
308
gpx/src/gpx.ts
@@ -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();
|
||||
pt.time = new Date(start.getTime() + ratio * (point.time.getTime() - points[0].time.getTime()));
|
||||
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) {
|
||||
|
@@ -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;
|
||||
|
@@ -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 };
|
||||
}
|
||||
}
|
@@ -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;
|
||||
};
|
||||
|
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
@@ -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);
|
||||
});
|
||||
});
|
@@ -7,7 +7,6 @@
|
||||
"moduleResolution": "node",
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
"test"
|
||||
"src"
|
||||
],
|
||||
}
|
157
website/package-lock.json
generated
157
website/package-lock.json
generated
@@ -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",
|
||||
|
@@ -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",
|
||||
|
43
website/src/hooks.server.js
Normal file
43
website/src/hooks.server.js
Normal 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;
|
||||
}
|
1864
website/src/lib/assets/custom/bikerouter-gravel.json
Normal file
1864
website/src/lib/assets/custom/bikerouter-gravel.json
Normal file
File diff suppressed because it is too large
Load Diff
15495
website/src/lib/assets/custom/ign-fr-plan.json
Normal file
15495
website/src/lib/assets/custom/ign-fr-plan.json
Normal file
File diff suppressed because it is too large
Load Diff
10837
website/src/lib/assets/custom/ign-fr-satellite.json
Normal file
10837
website/src/lib/assets/custom/ign-fr-satellite.json
Normal file
File diff suppressed because it is too large
Load Diff
15495
website/src/lib/assets/custom/ign-fr-topo.json
Normal file
15495
website/src/lib/assets/custom/ign-fr-topo.json
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Before Width: | Height: | Size: 4.2 MiB |
@@ -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: '© <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: '© <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: '© <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: '© <a href="https://www.lantmateriet.se" target="_blank">Lantmäteriet</a>'
|
||||
}
|
||||
},
|
||||
layers: [{
|
||||
id: 'swedenSatellite',
|
||||
type: 'raster',
|
||||
source: 'swedenSatellite',
|
||||
}],
|
||||
},
|
||||
finlandTopo: {
|
||||
@@ -283,200 +286,309 @@ 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: {
|
||||
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: '© <a href="https://github.com/cyclosm/cyclosm-cartocss-style/releases" title="CyclOSM - Open Bicycle render">CyclOSM</a> © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
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: '© <a href="https://github.com/cyclosm/cyclosm-cartocss-style/releases" title="CyclOSM - Open Bicycle render">CyclOSM</a> © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
}
|
||||
},
|
||||
layers: [{
|
||||
id: 'cyclOSMlite',
|
||||
type: 'raster',
|
||||
source: 'cyclOSMlite',
|
||||
}],
|
||||
},
|
||||
bikerouterGravel: bikerouterGravel,
|
||||
swisstopoSlope: {
|
||||
type: 'raster',
|
||||
tiles: ['https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.hangneigung-ueber_30/default/current/3857/{z}/{x}/{y}.png'],
|
||||
tileSize: 256,
|
||||
maxzoom: 17,
|
||||
attribution: '© <a href="https://www.swisstopo.admin.ch" target="_blank">swisstopo</a>',
|
||||
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'],
|
||||
tileSize: 256,
|
||||
maxzoom: 17,
|
||||
attribution: '© <a href="https://www.swisstopo.admin.ch" target="_blank">swisstopo</a>',
|
||||
},
|
||||
},
|
||||
layers: [{
|
||||
id: 'swisstopoSlope',
|
||||
type: 'raster',
|
||||
source: 'swisstopoSlope',
|
||||
}],
|
||||
},
|
||||
swisstopoHiking: {
|
||||
type: 'raster',
|
||||
tiles: ['https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.swisstlm3d-wanderwege/default/current/3857/{z}/{x}/{y}.png'],
|
||||
tileSize: 256,
|
||||
maxzoom: 18,
|
||||
attribution: '© <a href="https://www.swisstopo.admin.ch" target="_blank">swisstopo</a>'
|
||||
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'],
|
||||
tileSize: 256,
|
||||
maxzoom: 18,
|
||||
attribution: '© <a href="https://www.swisstopo.admin.ch" target="_blank">swisstopo</a>'
|
||||
},
|
||||
},
|
||||
layers: [{
|
||||
id: 'swisstopoHiking',
|
||||
type: 'raster',
|
||||
source: 'swisstopoHiking',
|
||||
}],
|
||||
},
|
||||
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: '© <a href="https://www.swisstopo.admin.ch" target="_blank">swisstopo</a>'
|
||||
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: '© <a href="https://www.swisstopo.admin.ch" target="_blank">swisstopo</a>'
|
||||
},
|
||||
},
|
||||
layers: [{
|
||||
id: 'swisstopoHikingClosures',
|
||||
type: 'raster',
|
||||
source: 'swisstopoHikingClosures',
|
||||
}],
|
||||
},
|
||||
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: '© <a href="https://www.swisstopo.admin.ch" target="_blank">swisstopo</a>'
|
||||
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: '© <a href="https://www.swisstopo.admin.ch" target="_blank">swisstopo</a>'
|
||||
}
|
||||
},
|
||||
layers: [{
|
||||
id: 'swisstopoCycling',
|
||||
type: 'raster',
|
||||
source: 'swisstopoCycling',
|
||||
}],
|
||||
},
|
||||
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: '© <a href="https://www.swisstopo.admin.ch" target="_blank">swisstopo</a>'
|
||||
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: '© <a href="https://www.swisstopo.admin.ch" target="_blank">swisstopo</a>'
|
||||
}
|
||||
},
|
||||
layers: [{
|
||||
id: 'swisstopoCyclingClosures',
|
||||
type: 'raster',
|
||||
source: 'swisstopoCyclingClosures',
|
||||
}],
|
||||
},
|
||||
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: '© <a href="https://www.swisstopo.admin.ch" target="_blank">swisstopo</a>'
|
||||
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: '© <a href="https://www.swisstopo.admin.ch" target="_blank">swisstopo</a>'
|
||||
}
|
||||
},
|
||||
layers: [{
|
||||
id: 'swisstopoMountainBike',
|
||||
type: 'raster',
|
||||
source: 'swisstopoMountainBike',
|
||||
}],
|
||||
},
|
||||
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: '© <a href="https://www.swisstopo.admin.ch" target="_blank">swisstopo</a>'
|
||||
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: '© <a href="https://www.swisstopo.admin.ch" target="_blank">swisstopo</a>'
|
||||
}
|
||||
},
|
||||
layers: [{
|
||||
id: 'swisstopoMountainBikeClosures',
|
||||
type: 'raster',
|
||||
source: 'swisstopoMountainBikeClosures',
|
||||
}],
|
||||
},
|
||||
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: '© <a href="https://www.swisstopo.admin.ch" target="_blank">swisstopo</a>'
|
||||
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: '© <a href="https://www.swisstopo.admin.ch" target="_blank">swisstopo</a>'
|
||||
}
|
||||
},
|
||||
layers: [{
|
||||
id: 'swisstopoSkiTouring',
|
||||
type: 'raster',
|
||||
source: 'swisstopoSkiTouring',
|
||||
}],
|
||||
},
|
||||
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'
|
||||
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: {
|
||||
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'
|
||||
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,
|
||||
attribution: 'IGN-F/Géoportail'
|
||||
}
|
||||
},
|
||||
layers: [{
|
||||
id: 'ignSlope',
|
||||
type: 'raster',
|
||||
source: 'ignSlope',
|
||||
}],
|
||||
},
|
||||
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'],
|
||||
tileSize: 256,
|
||||
maxzoom: 16,
|
||||
attribution: 'IGN-F/Géoportail'
|
||||
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'],
|
||||
tileSize: 256,
|
||||
maxzoom: 16,
|
||||
attribution: 'IGN-F/Géoportail'
|
||||
},
|
||||
},
|
||||
layers: [{
|
||||
id: 'ignSkiTouring',
|
||||
type: 'raster',
|
||||
source: 'ignSkiTouring',
|
||||
}],
|
||||
},
|
||||
waymarkedTrailsHiking: {
|
||||
type: 'raster',
|
||||
tiles: ['https://tile.waymarkedtrails.org/hiking/{z}/{x}/{y}.png'],
|
||||
tileSize: 256,
|
||||
maxzoom: 18,
|
||||
attribution: '© <a href="https://www.waymarkedtrails.org" target="_blank">Waymarked Trails</a>'
|
||||
version: 8,
|
||||
sources: {
|
||||
waymarkedTrailsHiking: {
|
||||
type: 'raster',
|
||||
tiles: ['https://tile.waymarkedtrails.org/hiking/{z}/{x}/{y}.png'],
|
||||
tileSize: 256,
|
||||
maxzoom: 18,
|
||||
attribution: '© <a href="https://www.waymarkedtrails.org" target="_blank">Waymarked Trails</a>'
|
||||
}
|
||||
},
|
||||
layers: [{
|
||||
id: 'waymarkedTrailsHiking',
|
||||
type: 'raster',
|
||||
source: 'waymarkedTrailsHiking',
|
||||
}],
|
||||
},
|
||||
waymarkedTrailsCycling: {
|
||||
type: 'raster',
|
||||
tiles: ['https://tile.waymarkedtrails.org/cycling/{z}/{x}/{y}.png'],
|
||||
tileSize: 256,
|
||||
maxzoom: 18,
|
||||
attribution: '© <a href="https://www.waymarkedtrails.org" target="_blank">Waymarked Trails</a>'
|
||||
version: 8,
|
||||
sources: {
|
||||
waymarkedTrailsCycling: {
|
||||
type: 'raster',
|
||||
tiles: ['https://tile.waymarkedtrails.org/cycling/{z}/{x}/{y}.png'],
|
||||
tileSize: 256,
|
||||
maxzoom: 18,
|
||||
attribution: '© <a href="https://www.waymarkedtrails.org" target="_blank">Waymarked Trails</a>'
|
||||
}
|
||||
},
|
||||
layers: [{
|
||||
id: 'waymarkedTrailsCycling',
|
||||
type: 'raster',
|
||||
source: 'waymarkedTrailsCycling',
|
||||
}],
|
||||
},
|
||||
waymarkedTrailsMTB: {
|
||||
type: 'raster',
|
||||
tiles: ['https://tile.waymarkedtrails.org/mtb/{z}/{x}/{y}.png'],
|
||||
tileSize: 256,
|
||||
maxzoom: 18,
|
||||
attribution: '© <a href="https://www.waymarkedtrails.org" target="_blank">Waymarked Trails</a>'
|
||||
version: 8,
|
||||
sources: {
|
||||
waymarkedTrailsMTB: {
|
||||
type: 'raster',
|
||||
tiles: ['https://tile.waymarkedtrails.org/mtb/{z}/{x}/{y}.png'],
|
||||
tileSize: 256,
|
||||
maxzoom: 18,
|
||||
attribution: '© <a href="https://www.waymarkedtrails.org" target="_blank">Waymarked Trails</a>'
|
||||
}
|
||||
},
|
||||
layers: [{
|
||||
id: 'waymarkedTrailsMTB',
|
||||
type: 'raster',
|
||||
source: 'waymarkedTrailsMTB',
|
||||
}],
|
||||
},
|
||||
waymarkedTrailsSkating: {
|
||||
type: 'raster',
|
||||
tiles: ['https://tile.waymarkedtrails.org/skating/{z}/{x}/{y}.png'],
|
||||
tileSize: 256,
|
||||
maxzoom: 18,
|
||||
attribution: '© <a href="https://www.waymarkedtrails.org" target="_blank">Waymarked Trails</a>'
|
||||
version: 8,
|
||||
sources: {
|
||||
waymarkedTrailsSkating: {
|
||||
type: 'raster',
|
||||
tiles: ['https://tile.waymarkedtrails.org/skating/{z}/{x}/{y}.png'],
|
||||
tileSize: 256,
|
||||
maxzoom: 18,
|
||||
attribution: '© <a href="https://www.waymarkedtrails.org" target="_blank">Waymarked Trails</a>'
|
||||
}
|
||||
},
|
||||
layers: [{
|
||||
id: 'waymarkedTrailsSkating',
|
||||
type: 'raster',
|
||||
source: 'waymarkedTrailsSkating',
|
||||
}],
|
||||
},
|
||||
waymarkedTrailsHorseRiding: {
|
||||
type: 'raster',
|
||||
tiles: ['https://tile.waymarkedtrails.org/riding/{z}/{x}/{y}.png'],
|
||||
tileSize: 256,
|
||||
maxzoom: 18,
|
||||
attribution: '© <a href="https://www.waymarkedtrails.org" target="_blank">Waymarked Trails</a>'
|
||||
version: 8,
|
||||
sources: {
|
||||
waymarkedTrailsHorseRiding: {
|
||||
type: 'raster',
|
||||
tiles: ['https://tile.waymarkedtrails.org/riding/{z}/{x}/{y}.png'],
|
||||
tileSize: 256,
|
||||
maxzoom: 18,
|
||||
attribution: '© <a href="https://www.waymarkedtrails.org" target="_blank">Waymarked Trails</a>'
|
||||
}
|
||||
},
|
||||
layers: [{
|
||||
id: 'waymarkedTrailsHorseRiding',
|
||||
type: 'raster',
|
||||
source: 'waymarkedTrailsHorseRiding',
|
||||
}],
|
||||
},
|
||||
waymarkedTrailsWinter: {
|
||||
type: 'raster',
|
||||
tiles: ['https://tile.waymarkedtrails.org/slopes/{z}/{x}/{y}.png'],
|
||||
tileSize: 256,
|
||||
maxzoom: 18,
|
||||
attribution: '© <a href="https://www.waymarkedtrails.org" target="_blank">Waymarked Trails</a>'
|
||||
},
|
||||
stravaHeatmapRun: {
|
||||
type: 'raster',
|
||||
tiles: [],
|
||||
tileSize: 1024,
|
||||
maxzoom: 15,
|
||||
attribution: '© <a href="https://www.strava.com" target="_blank">Strava</a>'
|
||||
},
|
||||
stravaHeatmapTrailRun: {
|
||||
type: 'raster',
|
||||
tiles: [],
|
||||
tileSize: 1024,
|
||||
maxzoom: 15,
|
||||
attribution: '© <a href="https://www.strava.com" target="_blank">Strava</a>'
|
||||
},
|
||||
stravaHeatmapHike: {
|
||||
type: 'raster',
|
||||
tiles: [],
|
||||
tileSize: 1024,
|
||||
maxzoom: 15,
|
||||
attribution: '© <a href="https://www.strava.com" target="_blank">Strava</a>'
|
||||
},
|
||||
stravaHeatmapRide: {
|
||||
type: 'raster',
|
||||
tiles: [],
|
||||
tileSize: 1024,
|
||||
maxzoom: 15,
|
||||
attribution: '© <a href="https://www.strava.com" target="_blank">Strava</a>'
|
||||
},
|
||||
stravaHeatmapGravel: {
|
||||
type: 'raster',
|
||||
tiles: [],
|
||||
tileSize: 1024,
|
||||
maxzoom: 15,
|
||||
attribution: '© <a href="https://www.strava.com" target="_blank">Strava</a>'
|
||||
},
|
||||
stravaHeatmapMTB: {
|
||||
type: 'raster',
|
||||
tiles: [],
|
||||
tileSize: 1024,
|
||||
maxzoom: 15,
|
||||
attribution: '© <a href="https://www.strava.com" target="_blank">Strava</a>'
|
||||
},
|
||||
stravaHeatmapWater: {
|
||||
type: 'raster',
|
||||
tiles: [],
|
||||
tileSize: 1024,
|
||||
maxzoom: 15,
|
||||
attribution: '© <a href="https://www.strava.com" target="_blank">Strava</a>'
|
||||
},
|
||||
stravaHeatmapWinter: {
|
||||
type: 'raster',
|
||||
tiles: [],
|
||||
tileSize: 1024,
|
||||
maxzoom: 15,
|
||||
attribution: '© <a href="https://www.strava.com" target="_blank">Strava</a>'
|
||||
version: 8,
|
||||
sources: {
|
||||
waymarkedTrailsWinter: {
|
||||
type: 'raster',
|
||||
tiles: ['https://tile.waymarkedtrails.org/slopes/{z}/{x}/{y}.png'],
|
||||
tileSize: 256,
|
||||
maxzoom: 18,
|
||||
attribution: '© <a href="https://www.waymarkedtrails.org" target="_blank">Waymarked Trails</a>'
|
||||
}
|
||||
},
|
||||
layers: [{
|
||||
id: 'waymarkedTrailsWinter',
|
||||
type: 'raster',
|
||||
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"]
|
||||
}
|
||||
},
|
||||
symbol: "Restaurant"
|
||||
},
|
||||
"toilets": {
|
||||
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',
|
||||
}
|
60
website/src/lib/assets/symbols.ts
Normal file
60
website/src/lib/assets/symbols.ts
Normal 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);
|
||||
}
|
||||
}
|
@@ -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) {
|
||||
|
@@ -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="flex flex-row items-center gap-4 border rounded-md p-2">
|
||||
<div
|
||||
class="w-full flex flex-row items-center justify-center gap-4 border rounded-md p-2 bg-accent"
|
||||
>
|
||||
<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>
|
||||
|
@@ -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>
|
@@ -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" />
|
||||
<slot />
|
||||
<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>
|
||||
|
@@ -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)}>
|
||||
<Select.Item value={lang}>{label}</Select.Item>
|
||||
</a>
|
||||
{#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">
|
||||
{#each Object.entries(languages) as [lang, label]}
|
||||
<a href={getURLForLanguage(lang)}>
|
||||
{label}
|
||||
</a>
|
||||
{/each}
|
||||
{#if !$page.url.pathname.includes('404')}
|
||||
{#each Object.entries(languages) as [lang, label]}
|
||||
<a href={getURLForLanguage(lang, $page.url.pathname)}>
|
||||
{label}
|
||||
</a>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
@@ -1,318 +1,355 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import 'mapbox-gl/dist/mapbox-gl.css';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import 'mapbox-gl/dist/mapbox-gl.css';
|
||||
|
||||
import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder';
|
||||
import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css';
|
||||
import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder';
|
||||
import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css';
|
||||
|
||||
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 { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { map } from '$lib/stores';
|
||||
import { settings } from '$lib/db';
|
||||
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;
|
||||
export let geocoder = true;
|
||||
export let hash = true;
|
||||
export let accessToken = PUBLIC_MAPBOX_TOKEN;
|
||||
export let geolocate = true;
|
||||
export let geocoder = true;
|
||||
export let hash = true;
|
||||
|
||||
mapboxgl.accessToken = accessToken;
|
||||
mapboxgl.accessToken = accessToken;
|
||||
|
||||
let webgl2Supported = true;
|
||||
let fitBoundsOptions: mapboxgl.FitBoundsOptions = {
|
||||
maxZoom: 15,
|
||||
linear: true,
|
||||
easing: () => 1
|
||||
};
|
||||
let webgl2Supported = true;
|
||||
let fitBoundsOptions: mapboxgl.FitBoundsOptions = {
|
||||
maxZoom: 15,
|
||||
linear: true,
|
||||
easing: () => 1
|
||||
};
|
||||
|
||||
const { distanceUnits, elevationProfile, verticalFileView, bottomPanelSize, rightPanelSize } =
|
||||
settings;
|
||||
let scaleControl = new mapboxgl.ScaleControl({
|
||||
unit: $distanceUnits
|
||||
});
|
||||
const { distanceUnits, elevationProfile, verticalFileView, bottomPanelSize, rightPanelSize } =
|
||||
settings;
|
||||
let scaleControl = new mapboxgl.ScaleControl({
|
||||
unit: $distanceUnits
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
let gl = document.createElement('canvas').getContext('webgl2');
|
||||
if (!gl) {
|
||||
webgl2Supported = false;
|
||||
return;
|
||||
}
|
||||
onMount(() => {
|
||||
let gl = document.createElement('canvas').getContext('webgl2');
|
||||
if (!gl) {
|
||||
webgl2Supported = false;
|
||||
return;
|
||||
}
|
||||
|
||||
let newMap = new mapboxgl.Map({
|
||||
container: 'map',
|
||||
style: { version: 8, sources: {}, layers: [] },
|
||||
zoom: 0,
|
||||
hash: hash,
|
||||
language: get(locale),
|
||||
attributionControl: false,
|
||||
logoPosition: 'bottom-right',
|
||||
boxZoom: false
|
||||
});
|
||||
newMap.on('load', () => {
|
||||
$map = newMap; // only set the store after the map has loaded
|
||||
scaleControl.setUnit($distanceUnits);
|
||||
});
|
||||
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';
|
||||
}
|
||||
|
||||
newMap.addControl(
|
||||
new mapboxgl.AttributionControl({
|
||||
compact: true
|
||||
})
|
||||
);
|
||||
let newMap = new mapboxgl.Map({
|
||||
container: 'map',
|
||||
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,
|
||||
attributionControl: false,
|
||||
logoPosition: 'bottom-right',
|
||||
boxZoom: false
|
||||
});
|
||||
newMap.on('load', () => {
|
||||
$map = newMap; // only set the store after the map has loaded
|
||||
scaleControl.setUnit($distanceUnits);
|
||||
});
|
||||
|
||||
newMap.addControl(new mapboxgl.NavigationControl());
|
||||
newMap.addControl(
|
||||
new mapboxgl.AttributionControl({
|
||||
compact: true
|
||||
})
|
||||
);
|
||||
|
||||
if (geocoder) {
|
||||
newMap.addControl(
|
||||
new MapboxGeocoder({
|
||||
accessToken: mapboxgl.accessToken,
|
||||
mapboxgl: mapboxgl,
|
||||
collapsed: true,
|
||||
flyTo: fitBoundsOptions,
|
||||
language: get(locale)
|
||||
})
|
||||
);
|
||||
}
|
||||
newMap.addControl(
|
||||
new mapboxgl.NavigationControl({
|
||||
visualizePitch: true
|
||||
})
|
||||
);
|
||||
|
||||
if (geolocate) {
|
||||
newMap.addControl(
|
||||
new mapboxgl.GeolocateControl({
|
||||
positionOptions: {
|
||||
enableHighAccuracy: true
|
||||
},
|
||||
fitBoundsOptions,
|
||||
trackUserLocation: true,
|
||||
showUserHeading: true
|
||||
})
|
||||
);
|
||||
}
|
||||
if (geocoder) {
|
||||
newMap.addControl(
|
||||
new MapboxGeocoder({
|
||||
accessToken: mapboxgl.accessToken,
|
||||
mapboxgl: mapboxgl,
|
||||
collapsed: true,
|
||||
flyTo: fitBoundsOptions,
|
||||
language
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
newMap.addControl(scaleControl);
|
||||
if (geolocate) {
|
||||
newMap.addControl(
|
||||
new mapboxgl.GeolocateControl({
|
||||
positionOptions: {
|
||||
enableHighAccuracy: true
|
||||
},
|
||||
fitBoundsOptions,
|
||||
trackUserLocation: true,
|
||||
showUserHeading: true
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
newMap.on('style.load', () => {
|
||||
newMap.addSource('mapbox-dem', {
|
||||
type: 'raster-dem',
|
||||
url: 'mapbox://mapbox.mapbox-terrain-dem-v1',
|
||||
tileSize: 512,
|
||||
maxzoom: 14
|
||||
});
|
||||
newMap.setTerrain({
|
||||
source: 'mapbox-dem',
|
||||
exaggeration: newMap.getPitch() > 0 ? 1 : 0
|
||||
});
|
||||
newMap.setFog({
|
||||
color: 'rgb(186, 210, 235)',
|
||||
'high-color': 'rgb(36, 92, 223)',
|
||||
'horizon-blend': 0.1,
|
||||
'space-color': 'rgb(156, 240, 255)'
|
||||
});
|
||||
newMap.on('pitch', () => {
|
||||
if (newMap.getPitch() > 0) {
|
||||
newMap.setTerrain({
|
||||
source: 'mapbox-dem',
|
||||
exaggeration: 1
|
||||
});
|
||||
} else {
|
||||
newMap.setTerrain({
|
||||
source: 'mapbox-dem',
|
||||
exaggeration: 0
|
||||
});
|
||||
}
|
||||
});
|
||||
// add dummy layer to place the overlay layers below
|
||||
newMap.addLayer({
|
||||
id: 'overlays',
|
||||
type: 'background',
|
||||
paint: {
|
||||
'background-color': 'rgba(0, 0, 0, 0)'
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
newMap.addControl(scaleControl);
|
||||
|
||||
onDestroy(() => {
|
||||
if ($map) {
|
||||
$map.remove();
|
||||
$map = null;
|
||||
}
|
||||
});
|
||||
newMap.on('style.load', () => {
|
||||
newMap.addSource('mapbox-dem', {
|
||||
type: 'raster-dem',
|
||||
url: 'mapbox://mapbox.mapbox-terrain-dem-v1',
|
||||
tileSize: 512,
|
||||
maxzoom: 14
|
||||
});
|
||||
newMap.setTerrain({
|
||||
source: 'mapbox-dem',
|
||||
exaggeration: newMap.getPitch() > 0 ? 1 : 0
|
||||
});
|
||||
newMap.setFog({
|
||||
color: 'rgb(186, 210, 235)',
|
||||
'high-color': 'rgb(36, 92, 223)',
|
||||
'horizon-blend': 0.1,
|
||||
'space-color': 'rgb(156, 240, 255)'
|
||||
});
|
||||
newMap.on('pitch', () => {
|
||||
if (newMap.getPitch() > 0) {
|
||||
newMap.setTerrain({
|
||||
source: 'mapbox-dem',
|
||||
exaggeration: 1
|
||||
});
|
||||
} else {
|
||||
newMap.setTerrain({
|
||||
source: 'mapbox-dem',
|
||||
exaggeration: 0
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
$: if (
|
||||
$map &&
|
||||
(!$verticalFileView || !$elevationProfile || $bottomPanelSize || $rightPanelSize)
|
||||
) {
|
||||
$map.resize();
|
||||
}
|
||||
onDestroy(() => {
|
||||
if ($map) {
|
||||
$map.remove();
|
||||
$map = null;
|
||||
}
|
||||
});
|
||||
|
||||
$: if (
|
||||
$map &&
|
||||
(!$verticalFileView || !$elevationProfile || $bottomPanelSize || $rightPanelSize)
|
||||
) {
|
||||
$map.resize();
|
||||
}
|
||||
</script>
|
||||
|
||||
<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' : ''}"
|
||||
>
|
||||
<p>{$_('webgl2_required')}</p>
|
||||
<Button href="https://get.webgl.org/webgl2/" target="_blank">
|
||||
{$_('enable_webgl2')}
|
||||
</Button>
|
||||
</div>
|
||||
<div id="map" class="h-full {webgl2Supported ? '' : 'hidden'}"></div>
|
||||
<div
|
||||
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">
|
||||
{$_('enable_webgl2')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
div :global(.mapboxgl-map) {
|
||||
@apply font-sans;
|
||||
}
|
||||
div :global(.mapboxgl-map) {
|
||||
@apply font-sans;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-top-right > .mapboxgl-ctrl) {
|
||||
@apply shadow-md;
|
||||
@apply bg-background;
|
||||
@apply text-foreground;
|
||||
}
|
||||
div :global(.mapboxgl-ctrl-top-right > .mapboxgl-ctrl) {
|
||||
@apply shadow-md;
|
||||
@apply bg-background;
|
||||
@apply text-foreground;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-icon) {
|
||||
@apply dark:brightness-[4.7];
|
||||
}
|
||||
div :global(.mapboxgl-ctrl-icon) {
|
||||
@apply dark:brightness-[4.7];
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-geocoder) {
|
||||
@apply flex;
|
||||
@apply flex-row;
|
||||
@apply w-fit;
|
||||
@apply min-w-fit;
|
||||
@apply items-center;
|
||||
@apply shadow-md;
|
||||
}
|
||||
div :global(.mapboxgl-ctrl-geocoder) {
|
||||
@apply flex;
|
||||
@apply flex-row;
|
||||
@apply w-fit;
|
||||
@apply min-w-fit;
|
||||
@apply items-center;
|
||||
@apply shadow-md;
|
||||
}
|
||||
|
||||
div :global(.suggestions) {
|
||||
@apply shadow-md;
|
||||
@apply bg-background;
|
||||
@apply text-foreground;
|
||||
}
|
||||
div :global(.suggestions) {
|
||||
@apply shadow-md;
|
||||
@apply bg-background;
|
||||
@apply text-foreground;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-geocoder .suggestions > li > a) {
|
||||
@apply text-foreground;
|
||||
@apply hover:text-accent-foreground;
|
||||
@apply hover:bg-accent;
|
||||
}
|
||||
div :global(.mapboxgl-ctrl-geocoder .suggestions > li > a) {
|
||||
@apply text-foreground;
|
||||
@apply hover:text-accent-foreground;
|
||||
@apply hover:bg-accent;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-geocoder .suggestions > .active > a) {
|
||||
@apply bg-background;
|
||||
}
|
||||
div :global(.mapboxgl-ctrl-geocoder .suggestions > .active > a) {
|
||||
@apply bg-background;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-geocoder--button) {
|
||||
@apply bg-transparent;
|
||||
@apply hover:bg-transparent;
|
||||
}
|
||||
div :global(.mapboxgl-ctrl-geocoder--button) {
|
||||
@apply bg-transparent;
|
||||
@apply hover:bg-transparent;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-geocoder--icon) {
|
||||
@apply fill-foreground;
|
||||
@apply hover:fill-accent-foreground;
|
||||
}
|
||||
div :global(.mapboxgl-ctrl-geocoder--icon) {
|
||||
@apply fill-foreground;
|
||||
@apply hover:fill-accent-foreground;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-geocoder--icon-search) {
|
||||
@apply relative;
|
||||
@apply top-0;
|
||||
@apply left-0;
|
||||
@apply my-2;
|
||||
@apply w-[29px];
|
||||
}
|
||||
div :global(.mapboxgl-ctrl-geocoder--icon-search) {
|
||||
@apply relative;
|
||||
@apply top-0;
|
||||
@apply left-0;
|
||||
@apply my-2;
|
||||
@apply w-[29px];
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-geocoder--input) {
|
||||
@apply relative;
|
||||
@apply w-64;
|
||||
@apply py-0;
|
||||
@apply pl-2;
|
||||
@apply focus:outline-none;
|
||||
@apply transition-[width];
|
||||
@apply duration-200;
|
||||
@apply text-foreground;
|
||||
}
|
||||
div :global(.mapboxgl-ctrl-geocoder--input) {
|
||||
@apply relative;
|
||||
@apply w-64;
|
||||
@apply py-0;
|
||||
@apply pl-2;
|
||||
@apply focus:outline-none;
|
||||
@apply transition-[width];
|
||||
@apply duration-200;
|
||||
@apply text-foreground;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-geocoder--collapsed .mapboxgl-ctrl-geocoder--input) {
|
||||
@apply w-0;
|
||||
@apply p-0;
|
||||
}
|
||||
div :global(.mapboxgl-ctrl-geocoder--collapsed .mapboxgl-ctrl-geocoder--input) {
|
||||
@apply w-0;
|
||||
@apply p-0;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-top-right) {
|
||||
@apply z-40;
|
||||
@apply flex;
|
||||
@apply flex-col;
|
||||
@apply items-end;
|
||||
@apply h-full;
|
||||
@apply overflow-hidden;
|
||||
}
|
||||
div :global(.mapboxgl-ctrl-top-right) {
|
||||
@apply z-40;
|
||||
@apply flex;
|
||||
@apply flex-col;
|
||||
@apply items-end;
|
||||
@apply h-full;
|
||||
@apply overflow-hidden;
|
||||
}
|
||||
|
||||
.horizontal :global(.mapboxgl-ctrl-bottom-left) {
|
||||
@apply bottom-[42px];
|
||||
}
|
||||
.horizontal :global(.mapboxgl-ctrl-bottom-left) {
|
||||
@apply bottom-[42px];
|
||||
}
|
||||
|
||||
.horizontal :global(.mapboxgl-ctrl-bottom-right) {
|
||||
@apply bottom-[42px];
|
||||
}
|
||||
.horizontal :global(.mapboxgl-ctrl-bottom-right) {
|
||||
@apply bottom-[42px];
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-attrib) {
|
||||
@apply dark:bg-transparent;
|
||||
}
|
||||
div :global(.mapboxgl-ctrl-attrib) {
|
||||
@apply dark:bg-transparent;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-compact-show.mapboxgl-ctrl-attrib) {
|
||||
@apply dark:bg-background;
|
||||
}
|
||||
div :global(.mapboxgl-compact-show.mapboxgl-ctrl-attrib) {
|
||||
@apply dark:bg-background;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-attrib-button) {
|
||||
@apply dark:bg-foreground;
|
||||
}
|
||||
div :global(.mapboxgl-ctrl-attrib-button) {
|
||||
@apply dark:bg-foreground;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-compact-show .mapboxgl-ctrl-attrib-button) {
|
||||
@apply dark:bg-foreground;
|
||||
}
|
||||
div :global(.mapboxgl-compact-show .mapboxgl-ctrl-attrib-button) {
|
||||
@apply dark:bg-foreground;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-ctrl-attrib a) {
|
||||
@apply text-foreground;
|
||||
}
|
||||
div :global(.mapboxgl-ctrl-attrib a) {
|
||||
@apply text-foreground;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-popup) {
|
||||
@apply w-fit;
|
||||
@apply z-20;
|
||||
}
|
||||
div :global(.mapboxgl-popup) {
|
||||
@apply w-fit;
|
||||
@apply z-20;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-popup-content) {
|
||||
@apply p-0;
|
||||
@apply bg-transparent;
|
||||
@apply shadow-none;
|
||||
}
|
||||
div :global(.mapboxgl-popup-content) {
|
||||
@apply p-0;
|
||||
@apply bg-transparent;
|
||||
@apply shadow-none;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-popup-anchor-top .mapboxgl-popup-tip) {
|
||||
@apply border-b-background;
|
||||
}
|
||||
div :global(.mapboxgl-popup-anchor-top .mapboxgl-popup-tip) {
|
||||
@apply border-b-background;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-popup-anchor-top-left .mapboxgl-popup-tip) {
|
||||
@apply border-b-background;
|
||||
}
|
||||
div :global(.mapboxgl-popup-anchor-top-left .mapboxgl-popup-tip) {
|
||||
@apply border-b-background;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-popup-anchor-top-right .mapboxgl-popup-tip) {
|
||||
@apply border-b-background;
|
||||
}
|
||||
div :global(.mapboxgl-popup-anchor-top-right .mapboxgl-popup-tip) {
|
||||
@apply border-b-background;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip) {
|
||||
@apply border-t-background;
|
||||
@apply drop-shadow-md;
|
||||
}
|
||||
div :global(.mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip) {
|
||||
@apply border-t-background;
|
||||
@apply drop-shadow-md;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-popup-anchor-bottom-left .mapboxgl-popup-tip) {
|
||||
@apply border-t-background;
|
||||
@apply drop-shadow-md;
|
||||
}
|
||||
div :global(.mapboxgl-popup-anchor-bottom-left .mapboxgl-popup-tip) {
|
||||
@apply border-t-background;
|
||||
@apply drop-shadow-md;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-popup-anchor-bottom-right .mapboxgl-popup-tip) {
|
||||
@apply border-t-background;
|
||||
@apply drop-shadow-md;
|
||||
}
|
||||
div :global(.mapboxgl-popup-anchor-bottom-right .mapboxgl-popup-tip) {
|
||||
@apply border-t-background;
|
||||
@apply drop-shadow-md;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-popup-anchor-left .mapboxgl-popup-tip) {
|
||||
@apply border-r-background;
|
||||
}
|
||||
div :global(.mapboxgl-popup-anchor-left .mapboxgl-popup-tip) {
|
||||
@apply border-r-background;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-popup-anchor-right .mapboxgl-popup-tip) {
|
||||
@apply border-l-background;
|
||||
}
|
||||
div :global(.mapboxgl-popup-anchor-right .mapboxgl-popup-tip) {
|
||||
@apply border-l-background;
|
||||
}
|
||||
</style>
|
||||
|
@@ -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 (e.shiftKey) {
|
||||
dbUtils.deleteAllFiles();
|
||||
} else {
|
||||
dbUtils.deleteSelection();
|
||||
if (!targetInput) {
|
||||
if (e.shiftKey) {
|
||||
dbUtils.deleteAllFiles();
|
||||
} else {
|
||||
dbUtils.deleteSelection();
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
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();
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
@@ -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') : ''}
|
||||
{:else}
|
||||
{secondsToHHMMSS(distancePerHourToSecondsPerDistance(value))}
|
||||
{showUnits ? $_('units.minutes_per_kilometer') : ''}
|
||||
{/if}
|
||||
{:else if $velocityUnits === 'speed'}
|
||||
{kilometersToMiles(value).toFixed(decimals ?? 2)}
|
||||
{showUnits ? $_('units.miles_per_hour') : ''}
|
||||
{#if $velocityUnits === 'speed'}
|
||||
{getConvertedVelocity(value, $velocityUnits, $distanceUnits).toFixed(decimals ?? 2)}
|
||||
{showUnits ? getVelocityUnits($velocityUnits, $distanceUnits) : ''}
|
||||
{: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'}
|
||||
|
@@ -1,106 +1,112 @@
|
||||
<script lang="ts">
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
import { base } from '$app/paths';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
|
||||
export let path: string;
|
||||
export let titleOnly: boolean = false;
|
||||
export let path: string;
|
||||
export let titleOnly: boolean = false;
|
||||
|
||||
let module = undefined;
|
||||
let metadata: Record<string, any> = {};
|
||||
let module = undefined;
|
||||
let metadata: Record<string, any> = {};
|
||||
|
||||
const modules = import.meta.glob('/src/lib/docs/**/*.mdx');
|
||||
const modules = import.meta.glob('/src/lib/docs/**/*.mdx');
|
||||
|
||||
function loadModule(path: string) {
|
||||
modules[path]().then((mod) => {
|
||||
module = mod.default;
|
||||
metadata = mod.metadata;
|
||||
});
|
||||
}
|
||||
function loadModule(path: string) {
|
||||
modules[path]?.().then((mod) => {
|
||||
module = mod.default;
|
||||
metadata = mod.metadata;
|
||||
});
|
||||
}
|
||||
|
||||
$: if ($locale) {
|
||||
loadModule(`/src/lib/docs/${$locale}/${path}`);
|
||||
}
|
||||
$: if ($locale) {
|
||||
if (modules.hasOwnProperty(`/src/lib/docs/${$locale}/${path}`)) {
|
||||
loadModule(`/src/lib/docs/${$locale}/${path}`);
|
||||
} else if (browser) {
|
||||
goto(`${base}/404`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if module !== undefined}
|
||||
{#if titleOnly}
|
||||
{metadata.title}
|
||||
{:else}
|
||||
<div class="markdown space-y-3">
|
||||
<svelte:component this={module} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if titleOnly}
|
||||
{metadata.title}
|
||||
{:else}
|
||||
<div class="markdown flex flex-col gap-3">
|
||||
<svelte:component this={module} />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style lang="postcss">
|
||||
:global(.markdown) {
|
||||
@apply text-muted-foreground;
|
||||
}
|
||||
:global(.markdown) {
|
||||
@apply text-muted-foreground;
|
||||
}
|
||||
|
||||
:global(.markdown h1) {
|
||||
@apply text-foreground;
|
||||
@apply text-3xl;
|
||||
@apply font-semibold;
|
||||
@apply mb-6;
|
||||
}
|
||||
:global(.markdown h1) {
|
||||
@apply text-foreground;
|
||||
@apply text-3xl;
|
||||
@apply font-semibold;
|
||||
@apply mb-3 pt-6;
|
||||
}
|
||||
|
||||
:global(.markdown h2) {
|
||||
@apply text-foreground;
|
||||
@apply text-2xl;
|
||||
@apply font-semibold;
|
||||
@apply mb-3;
|
||||
@apply pt-3;
|
||||
}
|
||||
:global(.markdown h2) {
|
||||
@apply text-foreground;
|
||||
@apply text-2xl;
|
||||
@apply font-semibold;
|
||||
@apply pt-3;
|
||||
}
|
||||
|
||||
:global(.markdown h3) {
|
||||
@apply text-foreground;
|
||||
@apply text-lg;
|
||||
@apply font-semibold;
|
||||
@apply pt-1.5;
|
||||
}
|
||||
:global(.markdown h3) {
|
||||
@apply text-foreground;
|
||||
@apply text-lg;
|
||||
@apply font-semibold;
|
||||
@apply pt-1.5;
|
||||
}
|
||||
|
||||
:global(.markdown p > button) {
|
||||
@apply border;
|
||||
@apply rounded-md;
|
||||
@apply px-1;
|
||||
}
|
||||
:global(.markdown p > button, .markdown li > button) {
|
||||
@apply border;
|
||||
@apply rounded-md;
|
||||
@apply px-1;
|
||||
}
|
||||
|
||||
:global(.markdown > a) {
|
||||
@apply text-blue-500;
|
||||
@apply hover:underline;
|
||||
}
|
||||
:global(.markdown > a) {
|
||||
@apply text-blue-500;
|
||||
@apply hover:underline;
|
||||
}
|
||||
|
||||
:global(.markdown p > a) {
|
||||
@apply text-blue-500;
|
||||
@apply hover:underline;
|
||||
}
|
||||
:global(.markdown p > a) {
|
||||
@apply text-blue-500;
|
||||
@apply hover:underline;
|
||||
}
|
||||
|
||||
:global(.markdown li > a) {
|
||||
@apply text-blue-500;
|
||||
@apply hover:underline;
|
||||
}
|
||||
:global(.markdown li > a) {
|
||||
@apply text-blue-500;
|
||||
@apply hover:underline;
|
||||
}
|
||||
|
||||
:global(.markdown kbd) {
|
||||
@apply p-1;
|
||||
@apply rounded-md;
|
||||
@apply border;
|
||||
}
|
||||
:global(.markdown kbd) {
|
||||
@apply p-1;
|
||||
@apply rounded-md;
|
||||
@apply border;
|
||||
}
|
||||
|
||||
:global(.markdown ul) {
|
||||
@apply list-disc;
|
||||
@apply pl-4;
|
||||
}
|
||||
:global(.markdown ul) {
|
||||
@apply list-disc;
|
||||
@apply pl-4;
|
||||
}
|
||||
|
||||
:global(.markdown ol) {
|
||||
@apply list-decimal;
|
||||
@apply pl-4;
|
||||
}
|
||||
:global(.markdown ol) {
|
||||
@apply list-decimal;
|
||||
@apply pl-4;
|
||||
}
|
||||
|
||||
:global(.markdown li) {
|
||||
@apply mt-1;
|
||||
@apply first:mt-0;
|
||||
}
|
||||
:global(.markdown li) {
|
||||
@apply mt-1;
|
||||
@apply first:mt-0;
|
||||
}
|
||||
|
||||
:global(.markdown hr) {
|
||||
@apply my-5;
|
||||
}
|
||||
:global(.markdown hr) {
|
||||
@apply my-5;
|
||||
}
|
||||
</style>
|
||||
|
@@ -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,11 +53,15 @@ export function getPreviousGuide(currentGuide: string): string | undefined {
|
||||
return `${previousGuide}/${guides[previousGuide][guides[previousGuide].length - 1]}`;
|
||||
}
|
||||
} else {
|
||||
let subguideIndex = guides[subguides[0]].indexOf(subguides[1]);
|
||||
if (subguideIndex > 0) {
|
||||
return `${subguides[0]}/${guides[subguides[0]][subguideIndex - 1]}`;
|
||||
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 subguides[0];
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,21 +70,29 @@ export function getNextGuide(currentGuide: string): string | undefined {
|
||||
let subguides = currentGuide.split('/');
|
||||
|
||||
if (subguides.length === 1) {
|
||||
if (guides[currentGuide].length === 0) {
|
||||
let keys = Object.keys(guides);
|
||||
let index = keys.indexOf(currentGuide);
|
||||
return keys[index + 1];
|
||||
if (guides.hasOwnProperty(currentGuide)) {
|
||||
if (guides[currentGuide].length === 0) {
|
||||
let keys = Object.keys(guides);
|
||||
let index = keys.indexOf(currentGuide);
|
||||
return keys[index + 1];
|
||||
} else {
|
||||
return `${currentGuide}/${guides[currentGuide][0]}`;
|
||||
}
|
||||
} else {
|
||||
return `${currentGuide}/${guides[currentGuide][0]}`;
|
||||
return undefined;
|
||||
}
|
||||
} else {
|
||||
let subguideIndex = guides[subguides[0]].indexOf(subguides[1]);
|
||||
if (subguideIndex < guides[subguides[0]].length - 1) {
|
||||
return `${subguides[0]}/${guides[subguides[0]][subguideIndex + 1]}`;
|
||||
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]}`;
|
||||
} else {
|
||||
let keys = Object.keys(guides);
|
||||
let index = keys.indexOf(subguides[0]);
|
||||
return keys[index + 1];
|
||||
}
|
||||
} else {
|
||||
let keys = Object.keys(guides);
|
||||
let index = keys.indexOf(subguides[0]);
|
||||
return keys[index + 1];
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
@@ -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'));
|
||||
|
@@ -1,66 +1,141 @@
|
||||
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;
|
||||
files: string[];
|
||||
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',
|
||||
token: string;
|
||||
files: string[];
|
||||
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' | 'nautical';
|
||||
velocityUnits: 'speed' | 'pace';
|
||||
temperatureUnits: 'celsius' | 'fahrenheit';
|
||||
theme: 'system' | 'light' | 'dark';
|
||||
};
|
||||
|
||||
export const defaultEmbeddingOptions = {
|
||||
token: '',
|
||||
files: [],
|
||||
basemap: 'mapboxOutdoors',
|
||||
elevation: {
|
||||
show: true,
|
||||
height: 170,
|
||||
controls: true,
|
||||
fill: undefined,
|
||||
speed: false,
|
||||
hr: false,
|
||||
cad: false,
|
||||
temp: false,
|
||||
power: false,
|
||||
},
|
||||
distanceMarkers: false,
|
||||
directionMarkers: false,
|
||||
distanceUnits: 'metric',
|
||||
velocityUnits: 'speed',
|
||||
temperatureUnits: 'celsius',
|
||||
token: '',
|
||||
files: [],
|
||||
basemap: 'mapboxOutdoors',
|
||||
elevation: {
|
||||
show: true,
|
||||
height: 170,
|
||||
controls: true,
|
||||
fill: undefined,
|
||||
speed: false,
|
||||
hr: false,
|
||||
cad: false,
|
||||
temp: 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));
|
||||
return JSON.parse(JSON.stringify(defaultEmbeddingOptions));
|
||||
}
|
||||
|
||||
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])) {
|
||||
cleanedOptions[key] = getCleanedEmbeddingOptions(cleanedOptions[key], defaultOptions[key]);
|
||||
if (Object.keys(cleanedOptions[key]).length === 0) {
|
||||
delete cleanedOptions[key];
|
||||
}
|
||||
} else if (JSON.stringify(cleanedOptions[key]) === JSON.stringify(defaultOptions[key])) {
|
||||
delete cleanedOptions[key];
|
||||
}
|
||||
}
|
||||
return cleanedOptions;
|
||||
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 const allowedEmbeddingBasemaps = Object.keys(basemaps).filter(basemap => !['ordnanceSurvey'].includes(basemap));
|
||||
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])
|
||||
) {
|
||||
cleanedOptions[key] = getCleanedEmbeddingOptions(cleanedOptions[key], defaultOptions[key]);
|
||||
if (Object.keys(cleanedOptions[key]).length === 0) {
|
||||
delete cleanedOptions[key];
|
||||
}
|
||||
} else if (JSON.stringify(cleanedOptions[key]) === JSON.stringify(defaultOptions[key])) {
|
||||
delete cleanedOptions[key];
|
||||
}
|
||||
}
|
||||
return cleanedOptions;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
@@ -1,296 +1,330 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import * as RadioGroup from '$lib/components/ui/radio-group';
|
||||
import {
|
||||
Zap,
|
||||
HeartPulse,
|
||||
Orbit,
|
||||
Thermometer,
|
||||
SquareActivity,
|
||||
Coins,
|
||||
Milestone,
|
||||
Video
|
||||
} from 'lucide-svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import {
|
||||
allowedEmbeddingBasemaps,
|
||||
getCleanedEmbeddingOptions,
|
||||
getDefaultEmbeddingOptions
|
||||
} from './Embedding';
|
||||
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
|
||||
import Embedding from './Embedding.svelte';
|
||||
import { map } from '$lib/stores';
|
||||
import { tick } from 'svelte';
|
||||
import { base } from '$app/paths';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import * as RadioGroup from '$lib/components/ui/radio-group';
|
||||
import {
|
||||
Zap,
|
||||
HeartPulse,
|
||||
Orbit,
|
||||
Thermometer,
|
||||
SquareActivity,
|
||||
Coins,
|
||||
Milestone,
|
||||
Video
|
||||
} from 'lucide-svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import {
|
||||
allowedEmbeddingBasemaps,
|
||||
getCleanedEmbeddingOptions,
|
||||
getDefaultEmbeddingOptions,
|
||||
getURLForGoogleDriveFile
|
||||
} from './Embedding';
|
||||
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
|
||||
import Embedding from './Embedding.svelte';
|
||||
import { map } from '$lib/stores';
|
||||
import { tick } from 'svelte';
|
||||
import { base } from '$app/paths';
|
||||
|
||||
let options = getDefaultEmbeddingOptions();
|
||||
options.token = 'YOUR_MAPBOX_TOKEN';
|
||||
options.files = [
|
||||
'https://raw.githubusercontent.com/gpxstudio/gpx.studio/main/gpx/test-data/simple.gpx'
|
||||
];
|
||||
let options = getDefaultEmbeddingOptions();
|
||||
options.token = 'YOUR_MAPBOX_TOKEN';
|
||||
options.files = [
|
||||
'https://raw.githubusercontent.com/gpxstudio/gpx.studio/main/gpx/test-data/simple.gpx'
|
||||
];
|
||||
|
||||
let files = options.files[0];
|
||||
$: if (files) {
|
||||
let urls = files.split(',');
|
||||
urls = urls.filter((url) => url.length > 0);
|
||||
if (JSON.stringify(urls) !== JSON.stringify(options.files)) {
|
||||
options.files = urls;
|
||||
}
|
||||
}
|
||||
let files = options.files[0];
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
let manualCamera = false;
|
||||
let manualCamera = false;
|
||||
|
||||
let zoom = '0';
|
||||
let lat = '0';
|
||||
let lon = '0';
|
||||
let bearing = '0';
|
||||
let pitch = '0';
|
||||
let zoom = '0';
|
||||
let lat = '0';
|
||||
let lon = '0';
|
||||
let bearing = '0';
|
||||
let pitch = '0';
|
||||
|
||||
$: hash = manualCamera ? `#${zoom}/${lat}/${lon}/${bearing}/${pitch}` : '';
|
||||
$: hash = manualCamera ? `#${zoom}/${lat}/${lon}/${bearing}/${pitch}` : '';
|
||||
|
||||
$: iframeOptions =
|
||||
options.token.length === 0 || options.token === 'YOUR_MAPBOX_TOKEN'
|
||||
? Object.assign({}, options, { token: PUBLIC_MAPBOX_TOKEN })
|
||||
: options;
|
||||
$: iframeOptions =
|
||||
options.token.length === 0 || options.token === 'YOUR_MAPBOX_TOKEN'
|
||||
? Object.assign({}, options, { token: PUBLIC_MAPBOX_TOKEN })
|
||||
: options;
|
||||
|
||||
async function resizeMap() {
|
||||
if ($map) {
|
||||
await tick();
|
||||
$map.resize();
|
||||
}
|
||||
}
|
||||
async function resizeMap() {
|
||||
if ($map) {
|
||||
await tick();
|
||||
$map.resize();
|
||||
}
|
||||
}
|
||||
|
||||
$: if (options.elevation.height || options.elevation.show) {
|
||||
resizeMap();
|
||||
}
|
||||
$: if (options.elevation.height || options.elevation.show) {
|
||||
resizeMap();
|
||||
}
|
||||
|
||||
function updateCamera() {
|
||||
if ($map) {
|
||||
let center = $map.getCenter();
|
||||
lat = center.lat.toFixed(4);
|
||||
lon = center.lng.toFixed(4);
|
||||
zoom = $map.getZoom().toFixed(2);
|
||||
bearing = $map.getBearing().toFixed(1);
|
||||
pitch = $map.getPitch().toFixed(0);
|
||||
}
|
||||
}
|
||||
function updateCamera() {
|
||||
if ($map) {
|
||||
let center = $map.getCenter();
|
||||
lat = center.lat.toFixed(4);
|
||||
lon = center.lng.toFixed(4);
|
||||
zoom = $map.getZoom().toFixed(2);
|
||||
bearing = $map.getBearing().toFixed(1);
|
||||
pitch = $map.getPitch().toFixed(0);
|
||||
}
|
||||
}
|
||||
|
||||
$: if ($map) {
|
||||
$map.on('moveend', updateCamera);
|
||||
}
|
||||
$: if ($map) {
|
||||
$map.on('moveend', updateCamera);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{$_('embedding.title')}</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<fieldset class="flex flex-col gap-3">
|
||||
<Label for="token">{$_('embedding.mapbox_token')}</Label>
|
||||
<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="basemap">{$_('embedding.basemap')}</Label>
|
||||
<Select.Root
|
||||
selected={{ value: options.basemap, label: $_(`layers.label.${options.basemap}`) }}
|
||||
onSelectedChange={(selected) => {
|
||||
if (selected?.value) {
|
||||
options.basemap = selected?.value;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Select.Trigger id="basemap" class="w-full h-8">
|
||||
<Select.Value />
|
||||
</Select.Trigger>
|
||||
<Select.Content class="max-h-60 overflow-y-scroll">
|
||||
{#each allowedEmbeddingBasemaps as basemap}
|
||||
<Select.Item value={basemap}>{$_(`layers.label.${basemap}`)}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Label for="profile">{$_('menu.elevation_profile')}</Label>
|
||||
<Checkbox id="profile" bind:checked={options.elevation.show} />
|
||||
</div>
|
||||
{#if options.elevation.show}
|
||||
<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" />
|
||||
</Label>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<span class="shrink-0">
|
||||
{$_('embedding.fill_by')}
|
||||
</span>
|
||||
<Select.Root
|
||||
selected={{ value: 'none', label: $_('embedding.none') }}
|
||||
onSelectedChange={(selected) => {
|
||||
let value = selected?.value;
|
||||
if (value === 'none') {
|
||||
options.elevation.fill = undefined;
|
||||
} else if (value === 'slope' || value === 'surface') {
|
||||
options.elevation.fill = value;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Select.Trigger class="grow h-8">
|
||||
<Select.Value />
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="slope">{$_('quantities.slope')}</Select.Item>
|
||||
<Select.Item value="surface">{$_('quantities.surface')}</Select.Item>
|
||||
<Select.Item value="none">{$_('embedding.none')}</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Checkbox id="controls" bind:checked={options.elevation.controls} />
|
||||
<Label for="controls">{$_('embedding.show_controls')}</Label>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Checkbox id="show-speed" bind:checked={options.elevation.speed} />
|
||||
<Label for="show-speed" class="flex flex-row items-center gap-1">
|
||||
<Zap size="16" />
|
||||
{$_('chart.show_speed')}
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Checkbox id="show-hr" bind:checked={options.elevation.hr} />
|
||||
<Label for="show-hr" class="flex flex-row items-center gap-1">
|
||||
<HeartPulse size="16" />
|
||||
{$_('chart.show_heartrate')}
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Checkbox id="show-cad" bind:checked={options.elevation.cad} />
|
||||
<Label for="show-cad" class="flex flex-row items-center gap-1">
|
||||
<Orbit size="16" />
|
||||
{$_('chart.show_cadence')}
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Checkbox id="show-temp" bind:checked={options.elevation.temp} />
|
||||
<Label for="show-temp" class="flex flex-row items-center gap-1">
|
||||
<Thermometer size="16" />
|
||||
{$_('chart.show_temperature')}
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Checkbox id="show-power" bind:checked={options.elevation.power} />
|
||||
<Label for="show-power" class="flex flex-row items-center gap-1">
|
||||
<SquareActivity size="16" />
|
||||
{$_('chart.show_power')}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Checkbox id="distance-markers" bind:checked={options.distanceMarkers} />
|
||||
<Label for="distance-markers" class="flex flex-row items-center gap-1">
|
||||
<Coins size="16" />
|
||||
{$_('menu.distance_markers')}
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Checkbox id="direction-markers" bind:checked={options.directionMarkers} />
|
||||
<Label for="direction-markers" class="flex flex-row items-center gap-1">
|
||||
<Milestone size="16" />
|
||||
{$_('menu.direction_markers')}
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex flex-row flex-wrap justify-between gap-3">
|
||||
<Label class="flex flex-col items-start gap-2">
|
||||
{$_('menu.distance_units')}
|
||||
<RadioGroup.Root bind:value={options.distanceUnits}>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="metric" id="metric" />
|
||||
<Label for="metric">{$_('menu.metric')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="imperial" id="imperial" />
|
||||
<Label for="imperial">{$_('menu.imperial')}</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</Label>
|
||||
<Label class="flex flex-col items-start gap-2">
|
||||
{$_('menu.velocity_units')}
|
||||
<RadioGroup.Root bind:value={options.velocityUnits}>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="speed" id="speed" />
|
||||
<Label for="speed">{$_('quantities.speed')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="pace" id="pace" />
|
||||
<Label for="pace">{$_('quantities.pace')}</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</Label>
|
||||
<Label class="flex flex-col items-start gap-2">
|
||||
{$_('menu.temperature_units')}
|
||||
<RadioGroup.Root bind:value={options.temperatureUnits}>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="celsius" id="celsius" />
|
||||
<Label for="celsius">{$_('menu.celsius')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="fahrenheit" id="fahrenheit" />
|
||||
<Label for="fahrenheit">{$_('menu.fahrenheit')}</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</Label>
|
||||
</div>
|
||||
<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} />
|
||||
<Label for="manual-camera" class="flex flex-row items-center gap-1">
|
||||
<Video size="16" />
|
||||
{$_('embedding.manual_camera')}
|
||||
</Label>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{$_('embedding.manual_camera_description')}
|
||||
</p>
|
||||
<div class="flex flex-row flex-wrap items-center gap-6">
|
||||
<Label class="flex flex-col gap-1">
|
||||
<span>{$_('embedding.latitude')}</span>
|
||||
<span>{lat}</span>
|
||||
</Label>
|
||||
<Label class="flex flex-col gap-1">
|
||||
<span>{$_('embedding.longitude')}</span>
|
||||
<span>{lon}</span>
|
||||
</Label>
|
||||
<Label class="flex flex-col gap-1">
|
||||
<span>{$_('embedding.zoom')}</span>
|
||||
<span>{zoom}</span>
|
||||
</Label>
|
||||
<Label class="flex flex-col gap-1">
|
||||
<span>{$_('embedding.bearing')}</span>
|
||||
<span>{bearing}</span>
|
||||
</Label>
|
||||
<Label class="flex flex-col gap-1">
|
||||
<span>{$_('embedding.pitch')}</span>
|
||||
<span>{pitch}</span>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<Label>
|
||||
{$_('embedding.preview')}
|
||||
</Label>
|
||||
<div class="relative h-[600px]">
|
||||
<Embedding bind:options={iframeOptions} bind:hash useHash={false} />
|
||||
</div>
|
||||
<Label>
|
||||
{$_('embedding.code')}
|
||||
</Label>
|
||||
<pre class="bg-primary text-primary-foreground p-3 rounded-md whitespace-normal break-all">
|
||||
<Card.Header>
|
||||
<Card.Title>{$_('embedding.title')}</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<fieldset class="flex flex-col gap-3">
|
||||
<Label for="token">{$_('embedding.mapbox_token')}</Label>
|
||||
<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}`) }}
|
||||
onSelectedChange={(selected) => {
|
||||
if (selected?.value) {
|
||||
options.basemap = selected?.value;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Select.Trigger id="basemap" class="w-full h-8">
|
||||
<Select.Value />
|
||||
</Select.Trigger>
|
||||
<Select.Content class="max-h-60 overflow-y-scroll">
|
||||
{#each allowedEmbeddingBasemaps as basemap}
|
||||
<Select.Item value={basemap}>{$_(`layers.label.${basemap}`)}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Label for="profile">{$_('menu.elevation_profile')}</Label>
|
||||
<Checkbox id="profile" bind:checked={options.elevation.show} />
|
||||
</div>
|
||||
{#if options.elevation.show}
|
||||
<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"
|
||||
/>
|
||||
</Label>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<span class="shrink-0">
|
||||
{$_('embedding.fill_by')}
|
||||
</span>
|
||||
<Select.Root
|
||||
selected={{ value: 'none', label: $_('embedding.none') }}
|
||||
onSelectedChange={(selected) => {
|
||||
let value = selected?.value;
|
||||
if (value === 'none') {
|
||||
options.elevation.fill = undefined;
|
||||
} else if (value === 'slope' || value === 'surface') {
|
||||
options.elevation.fill = value;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Select.Trigger class="grow h-8">
|
||||
<Select.Value />
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="slope">{$_('quantities.slope')}</Select.Item>
|
||||
<Select.Item value="surface">{$_('quantities.surface')}</Select.Item
|
||||
>
|
||||
<Select.Item value="none">{$_('embedding.none')}</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Checkbox id="controls" bind:checked={options.elevation.controls} />
|
||||
<Label for="controls">{$_('embedding.show_controls')}</Label>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Checkbox id="show-speed" bind:checked={options.elevation.speed} />
|
||||
<Label for="show-speed" class="flex flex-row items-center gap-1">
|
||||
<Zap size="16" />
|
||||
{$_('chart.show_speed')}
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Checkbox id="show-hr" bind:checked={options.elevation.hr} />
|
||||
<Label for="show-hr" class="flex flex-row items-center gap-1">
|
||||
<HeartPulse size="16" />
|
||||
{$_('chart.show_heartrate')}
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Checkbox id="show-cad" bind:checked={options.elevation.cad} />
|
||||
<Label for="show-cad" class="flex flex-row items-center gap-1">
|
||||
<Orbit size="16" />
|
||||
{$_('chart.show_cadence')}
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Checkbox id="show-temp" bind:checked={options.elevation.temp} />
|
||||
<Label for="show-temp" class="flex flex-row items-center gap-1">
|
||||
<Thermometer size="16" />
|
||||
{$_('chart.show_temperature')}
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Checkbox id="show-power" bind:checked={options.elevation.power} />
|
||||
<Label for="show-power" class="flex flex-row items-center gap-1">
|
||||
<SquareActivity size="16" />
|
||||
{$_('chart.show_power')}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Checkbox id="distance-markers" bind:checked={options.distanceMarkers} />
|
||||
<Label for="distance-markers" class="flex flex-row items-center gap-1">
|
||||
<Coins size="16" />
|
||||
{$_('menu.distance_markers')}
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Checkbox id="direction-markers" bind:checked={options.directionMarkers} />
|
||||
<Label for="direction-markers" class="flex flex-row items-center gap-1">
|
||||
<Milestone size="16" />
|
||||
{$_('menu.direction_markers')}
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex flex-row flex-wrap justify-between gap-3">
|
||||
<Label class="flex flex-col items-start gap-2">
|
||||
{$_('menu.distance_units')}
|
||||
<RadioGroup.Root bind:value={options.distanceUnits}>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="metric" id="metric" />
|
||||
<Label for="metric">{$_('menu.metric')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<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">
|
||||
{$_('menu.velocity_units')}
|
||||
<RadioGroup.Root bind:value={options.velocityUnits}>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="speed" id="speed" />
|
||||
<Label for="speed">{$_('quantities.speed')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="pace" id="pace" />
|
||||
<Label for="pace">{$_('quantities.pace')}</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</Label>
|
||||
<Label class="flex flex-col items-start gap-2">
|
||||
{$_('menu.temperature_units')}
|
||||
<RadioGroup.Root bind:value={options.temperatureUnits}>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="celsius" id="celsius" />
|
||||
<Label for="celsius">{$_('menu.celsius')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="fahrenheit" id="fahrenheit" />
|
||||
<Label for="fahrenheit">{$_('menu.fahrenheit')}</Label>
|
||||
</div>
|
||||
</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} />
|
||||
<Label for="manual-camera" class="flex flex-row items-center gap-1">
|
||||
<Video size="16" />
|
||||
{$_('embedding.manual_camera')}
|
||||
</Label>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{$_('embedding.manual_camera_description')}
|
||||
</p>
|
||||
<div class="flex flex-row flex-wrap items-center gap-6">
|
||||
<Label class="flex flex-col gap-1">
|
||||
<span>{$_('embedding.latitude')}</span>
|
||||
<span>{lat}</span>
|
||||
</Label>
|
||||
<Label class="flex flex-col gap-1">
|
||||
<span>{$_('embedding.longitude')}</span>
|
||||
<span>{lon}</span>
|
||||
</Label>
|
||||
<Label class="flex flex-col gap-1">
|
||||
<span>{$_('embedding.zoom')}</span>
|
||||
<span>{zoom}</span>
|
||||
</Label>
|
||||
<Label class="flex flex-col gap-1">
|
||||
<span>{$_('embedding.bearing')}</span>
|
||||
<span>{bearing}</span>
|
||||
</Label>
|
||||
<Label class="flex flex-col gap-1">
|
||||
<span>{$_('embedding.pitch')}</span>
|
||||
<span>{pitch}</span>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<Label>
|
||||
{$_('embedding.preview')}
|
||||
</Label>
|
||||
<div class="relative h-[600px]">
|
||||
<Embedding bind:options={iframeOptions} bind:hash useHash={false} />
|
||||
</div>
|
||||
<Label>
|
||||
{$_('embedding.code')}
|
||||
</Label>
|
||||
<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>
|
||||
</pre>
|
||||
</fieldset>
|
||||
</Card.Content>
|
||||
</fieldset>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
@@ -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,38 +284,41 @@
|
||||
{$_('menu.select_all')}
|
||||
<Shortcut key="A" ctrl={true} />
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
{/if}
|
||||
<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')}
|
||||
<Shortcut key="D" ctrl={true} /></ContextMenu.Item
|
||||
>
|
||||
{#if orientation === 'vertical'}
|
||||
<ContextMenu.Item on:click={dbUtils.duplicateSelection}>
|
||||
<Copy size="16" class="mr-1" />
|
||||
{$_('menu.duplicate')}
|
||||
<Shortcut key="D" ctrl={true} /></ContextMenu.Item
|
||||
<ContextMenu.Item on:click={copySelection}>
|
||||
<ClipboardCopy size="16" class="mr-1" />
|
||||
{$_('menu.copy')}
|
||||
<Shortcut key="C" ctrl={true} />
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item on:click={cutSelection}>
|
||||
<Scissors size="16" class="mr-1" />
|
||||
{$_('menu.cut')}
|
||||
<Shortcut key="X" ctrl={true} />
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item
|
||||
disabled={$copied === undefined ||
|
||||
$copied.length === 0 ||
|
||||
!allowedPastes[$copied[0].level].includes(item.level)}
|
||||
on:click={pasteSelection}
|
||||
>
|
||||
{#if orientation === 'vertical'}
|
||||
<ContextMenu.Item on:click={copySelection}>
|
||||
<ClipboardCopy size="16" class="mr-1" />
|
||||
{$_('menu.copy')}
|
||||
<Shortcut key="C" ctrl={true} />
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item on:click={cutSelection}>
|
||||
<Scissors size="16" class="mr-1" />
|
||||
{$_('menu.cut')}
|
||||
<Shortcut key="X" ctrl={true} />
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item
|
||||
disabled={$copied === undefined ||
|
||||
$copied.length === 0 ||
|
||||
!allowedPastes[$copied[0].level].includes(item.level)}
|
||||
on:click={pasteSelection}
|
||||
>
|
||||
<ClipboardPaste size="16" class="mr-1" />
|
||||
{$_('menu.paste')}
|
||||
<Shortcut key="V" ctrl={true} />
|
||||
</ContextMenu.Item>
|
||||
{/if}
|
||||
<ContextMenu.Separator />
|
||||
<ClipboardPaste size="16" class="mr-1" />
|
||||
{$_('menu.paste')}
|
||||
<Shortcut key="V" ctrl={true} />
|
||||
</ContextMenu.Item>
|
||||
{/if}
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Item on:click={dbUtils.deleteSelection}>
|
||||
{#if item instanceof ListFileItem}
|
||||
<FileX size="16" class="mr-1" />
|
||||
|
@@ -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: {
|
||||
|
@@ -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();
|
||||
}
|
||||
|
@@ -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)}° {$currentPopupWaypoint[0]
|
||||
.getLongitude()
|
||||
.toFixed(6)}°
|
||||
@@ -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>
|
||||
|
@@ -1,282 +1,435 @@
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as RadioGroup from '$lib/components/ui/radio-group';
|
||||
import { CirclePlus, CircleX, Minus, Pencil, Plus, Save, Trash2 } from 'lucide-svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { settings } from '$lib/db';
|
||||
import { defaultBasemap, extendBasemap, type CustomLayer } from '$lib/assets/layers';
|
||||
import { map } from '$lib/stores';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
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,
|
||||
Move,
|
||||
Map,
|
||||
Layers2
|
||||
} from 'lucide-svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { settings } from '$lib/db';
|
||||
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,
|
||||
selectedBasemapTree,
|
||||
selectedOverlayTree,
|
||||
currentBasemap,
|
||||
previousBasemap,
|
||||
currentOverlays,
|
||||
previousOverlays
|
||||
} = settings;
|
||||
const {
|
||||
customLayers,
|
||||
selectedBasemapTree,
|
||||
selectedOverlayTree,
|
||||
currentBasemap,
|
||||
previousBasemap,
|
||||
currentOverlays,
|
||||
previousOverlays,
|
||||
customBasemapOrder,
|
||||
customOverlayOrder
|
||||
} = settings;
|
||||
|
||||
let name: string = '';
|
||||
let tileUrls: string[] = [''];
|
||||
let maxZoom: number = 20;
|
||||
let layerType: 'basemap' | 'overlay' = 'basemap';
|
||||
let resourceType: 'raster' | 'vector' = 'raster';
|
||||
let name: string = '';
|
||||
let tileUrls: string[] = [''];
|
||||
let maxZoom: number = 20;
|
||||
let layerType: 'basemap' | 'overlay' = 'basemap';
|
||||
let resourceType: 'raster' | 'vector' = 'raster';
|
||||
|
||||
$: 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';
|
||||
}
|
||||
}
|
||||
let basemapContainer: HTMLElement;
|
||||
let overlayContainer: HTMLElement;
|
||||
|
||||
function createLayer() {
|
||||
if (typeof maxZoom === 'string') {
|
||||
maxZoom = parseInt(maxZoom);
|
||||
}
|
||||
let basemapSortable: Sortable;
|
||||
let overlaySortable: Sortable;
|
||||
|
||||
let layerId = selectedLayerId ?? getLayerId();
|
||||
let layer: CustomLayer = {
|
||||
id: layerId,
|
||||
name: name,
|
||||
tileUrls: tileUrls,
|
||||
maxZoom: maxZoom,
|
||||
layerType: layerType,
|
||||
resourceType: resourceType,
|
||||
value: ''
|
||||
};
|
||||
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'
|
||||
);
|
||||
}
|
||||
|
||||
if (resourceType === 'vector') {
|
||||
layer.value = tileUrls[0];
|
||||
} else {
|
||||
if (layerType === 'basemap') {
|
||||
layer.value = extendBasemap({
|
||||
version: 8,
|
||||
sources: {
|
||||
[layerId]: {
|
||||
type: 'raster',
|
||||
tiles: tileUrls,
|
||||
maxzoom: maxZoom
|
||||
}
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: layerId,
|
||||
type: 'raster',
|
||||
source: layerId
|
||||
}
|
||||
]
|
||||
});
|
||||
} else {
|
||||
layer.value = {
|
||||
type: 'raster',
|
||||
tiles: tileUrls,
|
||||
maxzoom: maxZoom
|
||||
};
|
||||
}
|
||||
}
|
||||
$customLayers[layerId] = layer;
|
||||
addLayer(layerId);
|
||||
selectedLayerId = undefined;
|
||||
setDataFromSelectedLayer();
|
||||
}
|
||||
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;
|
||||
}, {});
|
||||
}
|
||||
});
|
||||
|
||||
function getLayerId() {
|
||||
for (let id = 0; ; id++) {
|
||||
if (!$customLayers.hasOwnProperty(`custom-${id}`)) {
|
||||
return `custom-${id}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
basemapSortable.sort($customBasemapOrder);
|
||||
overlaySortable.sort($customOverlayOrder);
|
||||
});
|
||||
|
||||
function addLayer(layerId: string) {
|
||||
if (layerType === 'basemap') {
|
||||
selectedBasemapTree.update(($tree) => {
|
||||
if (!$tree.basemaps.hasOwnProperty('custom')) {
|
||||
$tree.basemaps['custom'] = {};
|
||||
}
|
||||
$tree.basemaps['custom'][layerId] = true;
|
||||
return $tree;
|
||||
});
|
||||
} else {
|
||||
selectedOverlayTree.update(($tree) => {
|
||||
if (!$tree.overlays.hasOwnProperty('custom')) {
|
||||
$tree.overlays['custom'] = {};
|
||||
}
|
||||
$tree.overlays['custom'][layerId] = true;
|
||||
return $tree;
|
||||
});
|
||||
}
|
||||
}
|
||||
onDestroy(() => {
|
||||
basemapSortable.destroy();
|
||||
overlaySortable.destroy();
|
||||
});
|
||||
|
||||
function tryDeleteLayer(node: any, id: string): any {
|
||||
if (node.hasOwnProperty(id)) {
|
||||
delete node[id];
|
||||
}
|
||||
return node;
|
||||
}
|
||||
$: if (tileUrls[0].length > 0) {
|
||||
if (
|
||||
tileUrls[0].includes('.json') ||
|
||||
(tileUrls[0].includes('api.mapbox.com/styles') && !tileUrls[0].includes('tiles'))
|
||||
) {
|
||||
resourceType = 'vector';
|
||||
} else {
|
||||
resourceType = 'raster';
|
||||
}
|
||||
}
|
||||
|
||||
function deleteLayer(layerId: string) {
|
||||
let layer = $customLayers[layerId];
|
||||
if (layer.layerType === 'basemap') {
|
||||
if (layerId === $currentBasemap) {
|
||||
$currentBasemap = defaultBasemap;
|
||||
}
|
||||
if (layerId === $previousBasemap) {
|
||||
$previousBasemap = defaultBasemap;
|
||||
}
|
||||
function createLayer() {
|
||||
if (selectedLayerId && $customLayers[selectedLayerId].layerType !== layerType) {
|
||||
deleteLayer(selectedLayerId);
|
||||
}
|
||||
|
||||
$selectedBasemapTree.basemaps['custom'] = tryDeleteLayer(
|
||||
$selectedBasemapTree.basemaps['custom'],
|
||||
layerId
|
||||
);
|
||||
if (Object.keys($selectedBasemapTree.basemaps['custom']).length === 0) {
|
||||
$selectedBasemapTree.basemaps = tryDeleteLayer($selectedBasemapTree.basemaps, 'custom');
|
||||
}
|
||||
} else {
|
||||
$currentOverlays = tryDeleteLayer($currentOverlays, layerId);
|
||||
$previousOverlays = tryDeleteLayer($previousOverlays, layerId);
|
||||
if (typeof maxZoom === 'string') {
|
||||
maxZoom = parseInt(maxZoom);
|
||||
}
|
||||
|
||||
$selectedOverlayTree.overlays['custom'] = tryDeleteLayer(
|
||||
$selectedOverlayTree.overlays['custom'],
|
||||
layerId
|
||||
);
|
||||
if (Object.keys($selectedOverlayTree.overlays['custom']).length === 0) {
|
||||
$selectedOverlayTree.overlays = tryDeleteLayer($selectedOverlayTree.overlays, 'custom');
|
||||
}
|
||||
let layerId = selectedLayerId ?? getLayerId();
|
||||
let layer: CustomLayer = {
|
||||
id: layerId,
|
||||
name: name,
|
||||
tileUrls: tileUrls.map((url) => decodeURI(url.trim())),
|
||||
maxZoom: maxZoom,
|
||||
layerType: layerType,
|
||||
resourceType: resourceType,
|
||||
value: ''
|
||||
};
|
||||
|
||||
if ($map) {
|
||||
if ($map.getLayer(layerId)) {
|
||||
$map.removeLayer(layerId);
|
||||
}
|
||||
if ($map.getSource(layerId)) {
|
||||
$map.removeSource(layerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
$customLayers = tryDeleteLayer($customLayers, layerId);
|
||||
}
|
||||
if (resourceType === 'vector') {
|
||||
layer.value = layer.tileUrls[0];
|
||||
} else {
|
||||
layer.value = {
|
||||
version: 8,
|
||||
sources: {
|
||||
[layerId]: {
|
||||
type: 'raster',
|
||||
tiles: layer.tileUrls,
|
||||
tileSize: 256,
|
||||
maxzoom: maxZoom
|
||||
}
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: layerId,
|
||||
type: 'raster',
|
||||
source: layerId
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
$customLayers[layerId] = layer;
|
||||
addLayer(layerId);
|
||||
selectedLayerId = undefined;
|
||||
setDataFromSelectedLayer();
|
||||
}
|
||||
|
||||
let selectedLayerId: string | undefined = undefined;
|
||||
function getLayerId() {
|
||||
for (let id = 0; ; id++) {
|
||||
if (!$customLayers.hasOwnProperty(`custom-${id}`)) {
|
||||
return `custom-${id}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setDataFromSelectedLayer() {
|
||||
if (selectedLayerId) {
|
||||
const layer = $customLayers[selectedLayerId];
|
||||
name = layer.name;
|
||||
tileUrls = layer.tileUrls;
|
||||
maxZoom = layer.maxZoom;
|
||||
layerType = layer.layerType;
|
||||
resourceType = layer.resourceType;
|
||||
} else {
|
||||
name = '';
|
||||
tileUrls = [''];
|
||||
maxZoom = 20;
|
||||
layerType = 'basemap';
|
||||
resourceType = 'raster';
|
||||
}
|
||||
}
|
||||
function addLayer(layerId: string) {
|
||||
if (layerType === 'basemap') {
|
||||
selectedBasemapTree.update(($tree) => {
|
||||
if (!$tree.basemaps.hasOwnProperty('custom')) {
|
||||
$tree.basemaps['custom'] = {};
|
||||
}
|
||||
$tree.basemaps['custom'][layerId] = true;
|
||||
return $tree;
|
||||
});
|
||||
|
||||
$: selectedLayerId, setDataFromSelectedLayer();
|
||||
if ($currentBasemap === layerId) {
|
||||
$customBasemapUpdate++;
|
||||
} else {
|
||||
$currentBasemap = layerId;
|
||||
}
|
||||
|
||||
if (!$customBasemapOrder.includes(layerId)) {
|
||||
$customBasemapOrder = [...$customBasemapOrder, layerId];
|
||||
}
|
||||
} else {
|
||||
selectedOverlayTree.update(($tree) => {
|
||||
if (!$tree.overlays.hasOwnProperty('custom')) {
|
||||
$tree.overlays['custom'] = {};
|
||||
}
|
||||
$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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function tryDeleteLayer(node: any, id: string): any {
|
||||
if (node.hasOwnProperty(id)) {
|
||||
delete node[id];
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function deleteLayer(layerId: string) {
|
||||
let layer = $customLayers[layerId];
|
||||
if (layer.layerType === 'basemap') {
|
||||
if (layerId === $currentBasemap) {
|
||||
$currentBasemap = defaultBasemap;
|
||||
}
|
||||
if (layerId === $previousBasemap) {
|
||||
$previousBasemap = defaultBasemap;
|
||||
}
|
||||
|
||||
$selectedBasemapTree.basemaps['custom'] = tryDeleteLayer(
|
||||
$selectedBasemapTree.basemaps['custom'],
|
||||
layerId
|
||||
);
|
||||
if (Object.keys($selectedBasemapTree.basemaps['custom']).length === 0) {
|
||||
$selectedBasemapTree.basemaps = tryDeleteLayer(
|
||||
$selectedBasemapTree.basemaps,
|
||||
'custom'
|
||||
);
|
||||
}
|
||||
$customBasemapOrder = $customBasemapOrder.filter((id) => id !== layerId);
|
||||
} else {
|
||||
$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'
|
||||
);
|
||||
}
|
||||
$customOverlayOrder = $customOverlayOrder.filter((id) => id !== 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
|
||||
}
|
||||
}
|
||||
}
|
||||
$customLayers = tryDeleteLayer($customLayers, layerId);
|
||||
}
|
||||
|
||||
let selectedLayerId: string | undefined = undefined;
|
||||
|
||||
function setDataFromSelectedLayer() {
|
||||
if (selectedLayerId) {
|
||||
const layer = $customLayers[selectedLayerId];
|
||||
name = layer.name;
|
||||
tileUrls = layer.tileUrls;
|
||||
maxZoom = layer.maxZoom;
|
||||
layerType = layer.layerType;
|
||||
resourceType = layer.resourceType;
|
||||
} else {
|
||||
name = '';
|
||||
tileUrls = [''];
|
||||
maxZoom = 20;
|
||||
layerType = 'basemap';
|
||||
resourceType = 'raster';
|
||||
}
|
||||
}
|
||||
|
||||
$: 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">
|
||||
<Pencil size="16" />
|
||||
</Button>
|
||||
<Button variant="outline" on:click={() => deleteLayer(id)} class="p-1 h-8">
|
||||
<Trash2 size="16" />
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<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-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>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header class="p-3">
|
||||
<Card.Title class="text-base">
|
||||
{#if selectedLayerId}
|
||||
{$_('layers.custom_layers.edit')}
|
||||
{:else}
|
||||
{$_('layers.custom_layers.new')}
|
||||
{/if}
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="p-3 pt-0">
|
||||
<fieldset class="flex flex-col gap-2">
|
||||
<Label for="name">{$_('menu.metadata.name')}</Label>
|
||||
<Input bind:value={name} id="name" class="h-8" />
|
||||
<Label for="url">{$_('layers.custom_layers.urls')}</Label>
|
||||
{#each tileUrls as url, i}
|
||||
<div class="flex flex-row gap-2">
|
||||
<Input
|
||||
bind:value={tileUrls[i]}
|
||||
id="url"
|
||||
class="h-8"
|
||||
placeholder={$_('layers.custom_layers.url_placeholder')}
|
||||
/>
|
||||
{#if tileUrls.length > 1}
|
||||
<Button
|
||||
on:click={() => (tileUrls = tileUrls.filter((_, index) => index !== i))}
|
||||
variant="outline"
|
||||
class="p-1 h-8"
|
||||
>
|
||||
<Minus size="16" />
|
||||
</Button>
|
||||
{/if}
|
||||
{#if i === tileUrls.length - 1}
|
||||
<Button
|
||||
on:click={() => (tileUrls = [...tileUrls, ''])}
|
||||
variant="outline"
|
||||
class="p-1 h-8"
|
||||
>
|
||||
<Plus size="16" />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/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" />
|
||||
{/if}
|
||||
<Label>{$_('layers.custom_layers.layer_type')}</Label>
|
||||
<RadioGroup.Root bind:value={layerType} class="flex flex-row">
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="basemap" id="basemap" />
|
||||
<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'} />
|
||||
<Label for="overlay">{$_('layers.custom_layers.overlay')}</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
{#if selectedLayerId}
|
||||
<div class="mt-2 flex flex-row gap-2">
|
||||
<Button variant="outline" on:click={createLayer} class="grow">
|
||||
<Save size="16" class="mr-1" />
|
||||
{$_('layers.custom_layers.update')}
|
||||
</Button>
|
||||
<Button variant="outline" on:click={() => (selectedLayerId = undefined)}>
|
||||
<CircleX size="16" />
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<Button variant="outline" class="mt-2" on:click={createLayer}>
|
||||
<CirclePlus size="16" class="mr-1" />
|
||||
{$_('layers.custom_layers.create')}
|
||||
</Button>
|
||||
{/if}
|
||||
</fieldset>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
<Card.Root>
|
||||
<Card.Header class="p-3">
|
||||
<Card.Title class="text-base">
|
||||
{#if selectedLayerId}
|
||||
{$_('layers.custom_layers.edit')}
|
||||
{:else}
|
||||
{$_('layers.custom_layers.new')}
|
||||
{/if}
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="p-3 pt-0">
|
||||
<fieldset class="flex flex-col gap-2">
|
||||
<Label for="name">{$_('menu.metadata.name')}</Label>
|
||||
<Input bind:value={name} id="name" class="h-8" />
|
||||
<Label for="url">{$_('layers.custom_layers.urls')}</Label>
|
||||
{#each tileUrls as url, i}
|
||||
<div class="flex flex-row gap-2">
|
||||
<Input
|
||||
bind:value={tileUrls[i]}
|
||||
id="url"
|
||||
class="h-8"
|
||||
placeholder={$_('layers.custom_layers.url_placeholder')}
|
||||
/>
|
||||
{#if tileUrls.length > 1}
|
||||
<Button
|
||||
on:click={() =>
|
||||
(tileUrls = tileUrls.filter((_, index) => index !== i))}
|
||||
variant="outline"
|
||||
class="p-1 h-8"
|
||||
>
|
||||
<Minus size="16" />
|
||||
</Button>
|
||||
{/if}
|
||||
{#if i === tileUrls.length - 1}
|
||||
<Button
|
||||
on:click={() => (tileUrls = [...tileUrls, ''])}
|
||||
variant="outline"
|
||||
class="p-1 h-8"
|
||||
>
|
||||
<Plus size="16" />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/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"
|
||||
/>
|
||||
{/if}
|
||||
<Label>{$_('layers.custom_layers.layer_type')}</Label>
|
||||
<RadioGroup.Root bind:value={layerType} class="flex flex-row">
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="basemap" id="basemap" />
|
||||
<Label for="basemap">{$_('layers.custom_layers.basemap')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="overlay" id="overlay" />
|
||||
<Label for="overlay">{$_('layers.custom_layers.overlay')}</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
{#if selectedLayerId}
|
||||
<div class="mt-2 flex flex-row gap-2">
|
||||
<Button variant="outline" on:click={createLayer} class="grow">
|
||||
<Save size="16" class="mr-1" />
|
||||
{$_('layers.custom_layers.update')}
|
||||
</Button>
|
||||
<Button variant="outline" on:click={() => (selectedLayerId = undefined)}>
|
||||
<CircleX size="16" />
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<Button variant="outline" class="mt-2" on:click={createLayer}>
|
||||
<CirclePlus size="16" class="mr-1" />
|
||||
{$_('layers.custom_layers.create')}
|
||||
</Button>
|
||||
{/if}
|
||||
</fieldset>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
@@ -1,198 +1,219 @@
|
||||
<script lang="ts">
|
||||
import CustomControl from '$lib/components/custom-control/CustomControl.svelte';
|
||||
import LayerTree from './LayerTree.svelte';
|
||||
import CustomControl from '$lib/components/custom-control/CustomControl.svelte';
|
||||
import LayerTree from './LayerTree.svelte';
|
||||
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||
|
||||
import { Layers } from 'lucide-svelte';
|
||||
import { Layers } from 'lucide-svelte';
|
||||
|
||||
import { basemaps, defaultBasemap, overlays } from '$lib/assets/layers';
|
||||
import { settings } from '$lib/db';
|
||||
import { map } from '$lib/stores';
|
||||
import { get, writable } from 'svelte/store';
|
||||
import { getLayers } from './utils';
|
||||
import { OverpassLayer } from './OverpassLayer';
|
||||
import OverpassPopup from './OverpassPopup.svelte';
|
||||
import { basemaps, defaultBasemap, overlays } from '$lib/assets/layers';
|
||||
import { settings } from '$lib/db';
|
||||
import { map } from '$lib/stores';
|
||||
import { get, writable } from 'svelte/store';
|
||||
import { customBasemapUpdate, getLayers } from './utils';
|
||||
import { OverpassLayer } from './OverpassLayer';
|
||||
import OverpassPopup from './OverpassPopup.svelte';
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let overpassLayer: OverpassLayer;
|
||||
let container: HTMLDivElement;
|
||||
let overpassLayer: OverpassLayer;
|
||||
|
||||
const {
|
||||
currentBasemap,
|
||||
previousBasemap,
|
||||
currentOverlays,
|
||||
currentOverpassQueries,
|
||||
selectedBasemapTree,
|
||||
selectedOverlayTree,
|
||||
selectedOverpassTree,
|
||||
customLayers,
|
||||
opacities
|
||||
} = settings;
|
||||
const {
|
||||
currentBasemap,
|
||||
previousBasemap,
|
||||
currentOverlays,
|
||||
currentOverpassQueries,
|
||||
selectedBasemapTree,
|
||||
selectedOverlayTree,
|
||||
selectedOverpassTree,
|
||||
customLayers,
|
||||
opacities
|
||||
} = settings;
|
||||
|
||||
$: if ($map) {
|
||||
// Set style depending on the current basemap
|
||||
let basemap = basemaps.hasOwnProperty($currentBasemap)
|
||||
? basemaps[$currentBasemap]
|
||||
: $customLayers[$currentBasemap]?.value ?? basemaps[defaultBasemap];
|
||||
$map.setStyle(basemap, {
|
||||
diff: false
|
||||
});
|
||||
}
|
||||
function setStyle() {
|
||||
if ($map) {
|
||||
let basemap = basemaps.hasOwnProperty($currentBasemap)
|
||||
? basemaps[$currentBasemap]
|
||||
: $customLayers[$currentBasemap]?.value ?? basemaps[defaultBasemap];
|
||||
$map.removeImport('basemap');
|
||||
if (typeof basemap === 'string') {
|
||||
$map.addImport({ id: 'basemap', url: basemap }, 'overlays');
|
||||
} else {
|
||||
$map.addImport(
|
||||
{
|
||||
id: 'basemap',
|
||||
data: basemap
|
||||
},
|
||||
'overlays'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$: 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]);
|
||||
}
|
||||
});
|
||||
}
|
||||
$: if ($map && ($currentBasemap || $customBasemapUpdate)) {
|
||||
setStyle();
|
||||
}
|
||||
|
||||
$: if ($map) {
|
||||
if (overpassLayer) {
|
||||
overpassLayer.remove();
|
||||
}
|
||||
overpassLayer = new OverpassLayer($map);
|
||||
overpassLayer.add();
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
let selectedBasemap = writable(get(currentBasemap));
|
||||
selectedBasemap.subscribe((value) => {
|
||||
// Updates coming from radio buttons
|
||||
if (value !== get(currentBasemap)) {
|
||||
previousBasemap.set(get(currentBasemap));
|
||||
currentBasemap.set(value);
|
||||
}
|
||||
});
|
||||
currentBasemap.subscribe((value) => {
|
||||
// Updates coming from the database, or from the user swapping basemaps
|
||||
selectedBasemap.set(value);
|
||||
});
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let addOverlayLayer: { [key: string]: () => void } = {};
|
||||
function addOverlayLayerForId(id: string) {
|
||||
return () => {
|
||||
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 ($map && $currentOverlays) {
|
||||
updateOverlays();
|
||||
}
|
||||
|
||||
let open = false;
|
||||
function openLayerControl() {
|
||||
open = true;
|
||||
}
|
||||
function closeLayerControl() {
|
||||
open = false;
|
||||
}
|
||||
let cancelEvents = false;
|
||||
$: if ($map) {
|
||||
if (overpassLayer) {
|
||||
overpassLayer.remove();
|
||||
}
|
||||
overpassLayer = new OverpassLayer($map);
|
||||
overpassLayer.add();
|
||||
$map.on('style.import.load', updateOverlays);
|
||||
}
|
||||
|
||||
let selectedBasemap = writable(get(currentBasemap));
|
||||
selectedBasemap.subscribe((value) => {
|
||||
// Updates coming from radio buttons
|
||||
if (value !== get(currentBasemap)) {
|
||||
previousBasemap.set(get(currentBasemap));
|
||||
currentBasemap.set(value);
|
||||
}
|
||||
});
|
||||
currentBasemap.subscribe((value) => {
|
||||
// Updates coming from the database, or from the user swapping basemaps
|
||||
selectedBasemap.set(value);
|
||||
});
|
||||
|
||||
function removeOverlayLayer(id: string) {
|
||||
if ($map) {
|
||||
let overlay = $customLayers.hasOwnProperty(id) ? $customLayers[id].value : overlays[id];
|
||||
if (overlay.layers) {
|
||||
$map.removeImport(id);
|
||||
} else {
|
||||
$map.removeLayer(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let open = false;
|
||||
function openLayerControl() {
|
||||
open = true;
|
||||
}
|
||||
function closeLayerControl() {
|
||||
open = false;
|
||||
}
|
||||
let cancelEvents = false;
|
||||
</script>
|
||||
|
||||
<CustomControl class="group min-w-[29px] min-h-[29px] overflow-hidden">
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
bind:this={container}
|
||||
class="h-full w-full"
|
||||
on:mouseenter={openLayerControl}
|
||||
on:mouseleave={closeLayerControl}
|
||||
on:pointerenter={() => {
|
||||
if (!open) {
|
||||
cancelEvents = true;
|
||||
openLayerControl();
|
||||
setTimeout(() => {
|
||||
cancelEvents = false;
|
||||
}, 500);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="flex flex-row justify-center items-center delay-100 transition-[opacity] duration-0 {open
|
||||
? 'opacity-0 w-0 h-0 delay-0'
|
||||
: 'w-[29px] h-[29px]'}"
|
||||
>
|
||||
<Layers size="20" />
|
||||
</div>
|
||||
<div
|
||||
class="transition-[grid-template-rows grid-template-cols] grid grid-rows-[0fr] grid-cols-[0fr] duration-150 h-full {open
|
||||
? 'grid-rows-[1fr] grid-cols-[1fr]'
|
||||
: ''} {cancelEvents ? 'pointer-events-none' : ''}"
|
||||
>
|
||||
<ScrollArea>
|
||||
<div class="h-fit">
|
||||
<div class="p-2">
|
||||
<LayerTree
|
||||
layerTree={$selectedBasemapTree}
|
||||
name="basemaps"
|
||||
bind:selected={$selectedBasemap}
|
||||
/>
|
||||
</div>
|
||||
<Separator class="w-full" />
|
||||
<div class="p-2">
|
||||
{#if $currentOverlays}
|
||||
<LayerTree
|
||||
layerTree={$selectedOverlayTree}
|
||||
name="overlays"
|
||||
multiple={true}
|
||||
bind:checked={$currentOverlays}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<Separator class="w-full" />
|
||||
<div class="p-2">
|
||||
{#if $currentOverpassQueries}
|
||||
<LayerTree
|
||||
layerTree={$selectedOverpassTree}
|
||||
name="overpass"
|
||||
multiple={true}
|
||||
bind:checked={$currentOverpassQueries}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
bind:this={container}
|
||||
class="h-full w-full"
|
||||
on:mouseenter={openLayerControl}
|
||||
on:mouseleave={closeLayerControl}
|
||||
on:pointerenter={() => {
|
||||
if (!open) {
|
||||
cancelEvents = true;
|
||||
openLayerControl();
|
||||
setTimeout(() => {
|
||||
cancelEvents = false;
|
||||
}, 500);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="flex flex-row justify-center items-center delay-100 transition-[opacity] duration-0 {open
|
||||
? 'opacity-0 w-0 h-0 delay-0'
|
||||
: 'w-[29px] h-[29px]'}"
|
||||
>
|
||||
<Layers size="20" />
|
||||
</div>
|
||||
<div
|
||||
class="transition-[grid-template-rows grid-template-cols] grid grid-rows-[0fr] grid-cols-[0fr] duration-150 h-full {open
|
||||
? 'grid-rows-[1fr] grid-cols-[1fr]'
|
||||
: ''} {cancelEvents ? 'pointer-events-none' : ''}"
|
||||
>
|
||||
<ScrollArea>
|
||||
<div class="h-fit">
|
||||
<div class="p-2">
|
||||
<LayerTree
|
||||
layerTree={$selectedBasemapTree}
|
||||
name="basemaps"
|
||||
bind:selected={$selectedBasemap}
|
||||
/>
|
||||
</div>
|
||||
<Separator class="w-full" />
|
||||
<div class="p-2">
|
||||
{#if $currentOverlays}
|
||||
<LayerTree
|
||||
layerTree={$selectedOverlayTree}
|
||||
name="overlays"
|
||||
multiple={true}
|
||||
bind:checked={$currentOverlays}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<Separator class="w-full" />
|
||||
<div class="p-2">
|
||||
{#if $currentOverpassQueries}
|
||||
<LayerTree
|
||||
layerTree={$selectedOverpassTree}
|
||||
name="overpass"
|
||||
multiple={true}
|
||||
bind:checked={$currentOverpassQueries}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</CustomControl>
|
||||
|
||||
<OverpassPopup />
|
||||
|
||||
<svelte:window
|
||||
on:click={(e) => {
|
||||
if (open && !cancelEvents && !container.contains(e.target)) {
|
||||
closeLayerControl();
|
||||
}
|
||||
}}
|
||||
on:click={(e) => {
|
||||
if (open && !cancelEvents && !container.contains(e.target)) {
|
||||
closeLayerControl();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
@@ -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>
|
||||
|
@@ -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,20 +108,27 @@ 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());
|
||||
|
||||
if (this.map.getLayer('overpass')) {
|
||||
this.map.removeLayer('overpass');
|
||||
}
|
||||
try {
|
||||
if (this.map.getLayer('overpass')) {
|
||||
this.map.removeLayer('overpass');
|
||||
}
|
||||
|
||||
if (this.map.getSource('overpass')) {
|
||||
this.map.removeSource('overpass');
|
||||
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);
|
||||
});
|
||||
|
||||
|
@@ -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
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
@@ -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);
|
@@ -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';
|
||||
|
@@ -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}
|
||||
|
@@ -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}
|
||||
|
@@ -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}
|
||||
|
@@ -164,7 +164,7 @@
|
||||
{$_('toolbar.reduce.button')}
|
||||
</Button>
|
||||
|
||||
<Help>
|
||||
<Help link="./help/toolbar/minify">
|
||||
{#if validSelection}
|
||||
{$_('toolbar.reduce.help')}
|
||||
{:else}
|
||||
|
@@ -1,342 +1,396 @@
|
||||
<script lang="ts">
|
||||
import DatePicker from '$lib/components/ui/date-picker/DatePicker.svelte';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import TimePicker from '$lib/components/ui/time-picker/TimePicker.svelte';
|
||||
import { dbUtils, settings } from '$lib/db';
|
||||
import { gpxStatistics } from '$lib/stores';
|
||||
import {
|
||||
distancePerHourToSecondsPerDistance,
|
||||
getConvertedVelocity,
|
||||
kilometersToMiles
|
||||
} from '$lib/units';
|
||||
import { CalendarDate, type DateValue } from '@internationalized/date';
|
||||
import { CalendarClock, CirclePlay, CircleStop, CircleX, Timer, Zap } from 'lucide-svelte';
|
||||
import { tick } from 'svelte';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import { selection } from '$lib/components/file-list/Selection';
|
||||
import {
|
||||
ListFileItem,
|
||||
ListRootItem,
|
||||
ListTrackItem,
|
||||
ListTrackSegmentItem
|
||||
} from '$lib/components/file-list/FileList';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import DatePicker from '$lib/components/ui/date-picker/DatePicker.svelte';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import TimePicker from '$lib/components/ui/time-picker/TimePicker.svelte';
|
||||
import { dbUtils, settings } from '$lib/db';
|
||||
import { gpxStatistics } from '$lib/stores';
|
||||
import {
|
||||
distancePerHourToSecondsPerDistance,
|
||||
getConvertedVelocity,
|
||||
milesToKilometers,
|
||||
nauticalMilesToKilometers
|
||||
} from '$lib/units';
|
||||
import { CalendarDate, type DateValue } from '@internationalized/date';
|
||||
import { CalendarClock, CirclePlay, CircleStop, CircleX, Timer, Zap } from 'lucide-svelte';
|
||||
import { tick } from 'svelte';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import { selection } from '$lib/components/file-list/Selection';
|
||||
import {
|
||||
ListFileItem,
|
||||
ListRootItem,
|
||||
ListTrackItem,
|
||||
ListTrackSegmentItem
|
||||
} from '$lib/components/file-list/FileList';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
|
||||
let startDate: DateValue | undefined = undefined;
|
||||
let startTime: string | undefined = undefined;
|
||||
let endDate: DateValue | undefined = undefined;
|
||||
let endTime: string | undefined = undefined;
|
||||
let movingTime: number | undefined = undefined;
|
||||
let speed: number | undefined = undefined;
|
||||
let startDate: DateValue | undefined = undefined;
|
||||
let startTime: string | undefined = undefined;
|
||||
let endDate: DateValue | undefined = undefined;
|
||||
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());
|
||||
}
|
||||
function toCalendarDate(date: Date): CalendarDate {
|
||||
return new CalendarDate(date.getFullYear(), date.getMonth() + 1, date.getDate());
|
||||
}
|
||||
|
||||
const { velocityUnits, distanceUnits } = settings;
|
||||
const { velocityUnits, distanceUnits } = settings;
|
||||
|
||||
function setSpeed(value: number) {
|
||||
let speedValue = getConvertedVelocity(value);
|
||||
if ($velocityUnits === 'speed') {
|
||||
speedValue = parseFloat(speedValue.toFixed(2));
|
||||
}
|
||||
speed = speedValue;
|
||||
}
|
||||
function setSpeed(value: number) {
|
||||
let speedValue = getConvertedVelocity(value);
|
||||
if ($velocityUnits === 'speed') {
|
||||
speedValue = parseFloat(speedValue.toFixed(2));
|
||||
}
|
||||
speed = speedValue;
|
||||
}
|
||||
|
||||
function setGPXData() {
|
||||
if ($gpxStatistics.global.time.start) {
|
||||
startDate = toCalendarDate($gpxStatistics.global.time.start);
|
||||
startTime = $gpxStatistics.global.time.start.toLocaleTimeString();
|
||||
} else {
|
||||
startDate = undefined;
|
||||
startTime = undefined;
|
||||
}
|
||||
if ($gpxStatistics.global.time.end) {
|
||||
endDate = toCalendarDate($gpxStatistics.global.time.end);
|
||||
endTime = $gpxStatistics.global.time.end.toLocaleTimeString();
|
||||
} else {
|
||||
endDate = undefined;
|
||||
endTime = undefined;
|
||||
}
|
||||
if ($gpxStatistics.global.time.moving) {
|
||||
movingTime = $gpxStatistics.global.time.moving;
|
||||
} else {
|
||||
movingTime = undefined;
|
||||
}
|
||||
if ($gpxStatistics.global.speed.moving) {
|
||||
setSpeed($gpxStatistics.global.speed.moving);
|
||||
} else {
|
||||
speed = undefined;
|
||||
}
|
||||
}
|
||||
function setGPXData() {
|
||||
if ($gpxStatistics.global.time.start) {
|
||||
startDate = toCalendarDate($gpxStatistics.global.time.start);
|
||||
startTime = $gpxStatistics.global.time.start.toLocaleTimeString();
|
||||
} else {
|
||||
startDate = undefined;
|
||||
startTime = undefined;
|
||||
}
|
||||
if ($gpxStatistics.global.time.end) {
|
||||
endDate = toCalendarDate($gpxStatistics.global.time.end);
|
||||
endTime = $gpxStatistics.global.time.end.toLocaleTimeString();
|
||||
} else {
|
||||
endDate = undefined;
|
||||
endTime = undefined;
|
||||
}
|
||||
if ($gpxStatistics.global.time.moving) {
|
||||
movingTime = $gpxStatistics.global.time.moving;
|
||||
} else {
|
||||
movingTime = undefined;
|
||||
}
|
||||
if ($gpxStatistics.global.speed.moving) {
|
||||
setSpeed($gpxStatistics.global.speed.moving);
|
||||
} else {
|
||||
speed = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
$: if ($gpxStatistics && $velocityUnits && $distanceUnits) {
|
||||
setGPXData();
|
||||
}
|
||||
$: if ($gpxStatistics && $velocityUnits && $distanceUnits) {
|
||||
setGPXData();
|
||||
}
|
||||
|
||||
function getDate(date: DateValue, time: string): Date {
|
||||
if (date === undefined) {
|
||||
return new Date();
|
||||
}
|
||||
let [hours, minutes, seconds] = time.split(':').map((x) => parseInt(x));
|
||||
return new Date(date.year, date.month - 1, date.day, hours, minutes, seconds);
|
||||
}
|
||||
function getDate(date: DateValue, time: string): Date {
|
||||
if (date === undefined) {
|
||||
return new Date();
|
||||
}
|
||||
let [hours, minutes, seconds] = time.split(':').map((x) => parseInt(x));
|
||||
return new Date(date.year, date.month - 1, date.day, hours, minutes, seconds);
|
||||
}
|
||||
|
||||
function updateEnd() {
|
||||
if (startDate && movingTime !== undefined) {
|
||||
if (startTime === undefined) {
|
||||
startTime = '00:00:00';
|
||||
}
|
||||
let start = getDate(startDate, startTime);
|
||||
let ratio =
|
||||
$gpxStatistics.global.time.moving > 0
|
||||
? $gpxStatistics.global.time.total / $gpxStatistics.global.time.moving
|
||||
: 1;
|
||||
let end = new Date(start.getTime() + ratio * movingTime * 1000);
|
||||
endDate = toCalendarDate(end);
|
||||
endTime = end.toLocaleTimeString();
|
||||
}
|
||||
}
|
||||
function updateEnd() {
|
||||
if (startDate && movingTime !== undefined) {
|
||||
if (startTime === undefined) {
|
||||
startTime = '00:00:00';
|
||||
}
|
||||
let start = getDate(startDate, startTime);
|
||||
let ratio =
|
||||
$gpxStatistics.global.time.moving > 0
|
||||
? $gpxStatistics.global.time.total / $gpxStatistics.global.time.moving
|
||||
: 1;
|
||||
let end = new Date(start.getTime() + ratio * movingTime * 1000);
|
||||
endDate = toCalendarDate(end);
|
||||
endTime = end.toLocaleTimeString();
|
||||
}
|
||||
}
|
||||
|
||||
function updateStart() {
|
||||
if (endDate && movingTime !== undefined) {
|
||||
if (endTime === undefined) {
|
||||
endTime = '00:00:00';
|
||||
}
|
||||
let end = getDate(endDate, endTime);
|
||||
let ratio =
|
||||
$gpxStatistics.global.time.moving > 0
|
||||
? $gpxStatistics.global.time.total / $gpxStatistics.global.time.moving
|
||||
: 1;
|
||||
let start = new Date(end.getTime() - ratio * movingTime * 1000);
|
||||
startDate = toCalendarDate(start);
|
||||
startTime = start.toLocaleTimeString();
|
||||
}
|
||||
}
|
||||
function updateStart() {
|
||||
if (endDate && movingTime !== undefined) {
|
||||
if (endTime === undefined) {
|
||||
endTime = '00:00:00';
|
||||
}
|
||||
let end = getDate(endDate, endTime);
|
||||
let ratio =
|
||||
$gpxStatistics.global.time.moving > 0
|
||||
? $gpxStatistics.global.time.total / $gpxStatistics.global.time.moving
|
||||
: 1;
|
||||
let start = new Date(end.getTime() - ratio * movingTime * 1000);
|
||||
startDate = toCalendarDate(start);
|
||||
startTime = start.toLocaleTimeString();
|
||||
}
|
||||
}
|
||||
|
||||
function getSpeed() {
|
||||
if (speed === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
function getSpeed() {
|
||||
if (speed === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let speedValue = speed;
|
||||
if ($velocityUnits === 'pace') {
|
||||
speedValue = distancePerHourToSecondsPerDistance(speed);
|
||||
}
|
||||
if ($distanceUnits === 'imperial') {
|
||||
speedValue = kilometersToMiles(speedValue);
|
||||
}
|
||||
return speedValue;
|
||||
}
|
||||
let speedValue = speed;
|
||||
if ($velocityUnits === 'pace') {
|
||||
speedValue = distancePerHourToSecondsPerDistance(speed);
|
||||
}
|
||||
if ($distanceUnits === 'imperial') {
|
||||
speedValue = milesToKilometers(speedValue);
|
||||
} else if ($distanceUnits === 'nautical') {
|
||||
speedValue = nauticalMilesToKilometers(speedValue);
|
||||
}
|
||||
return speedValue;
|
||||
}
|
||||
|
||||
function updateDataFromSpeed() {
|
||||
let speedValue = getSpeed();
|
||||
if (speedValue === undefined) {
|
||||
return;
|
||||
}
|
||||
function updateDataFromSpeed() {
|
||||
let speedValue = getSpeed();
|
||||
if (speedValue === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
let distance =
|
||||
$gpxStatistics.global.distance.moving > 0
|
||||
? $gpxStatistics.global.distance.moving
|
||||
: $gpxStatistics.global.distance.total;
|
||||
movingTime = (distance / speedValue) * 3600;
|
||||
let distance =
|
||||
$gpxStatistics.global.distance.moving > 0
|
||||
? $gpxStatistics.global.distance.moving
|
||||
: $gpxStatistics.global.distance.total;
|
||||
movingTime = (distance / speedValue) * 3600;
|
||||
|
||||
updateEnd();
|
||||
}
|
||||
updateEnd();
|
||||
}
|
||||
|
||||
function updateDataFromTotalTime() {
|
||||
if (movingTime === undefined) {
|
||||
return;
|
||||
}
|
||||
setSpeed($gpxStatistics.global.distance.moving / (movingTime / 3600));
|
||||
updateEnd();
|
||||
}
|
||||
function updateDataFromTotalTime() {
|
||||
if (movingTime === undefined) {
|
||||
return;
|
||||
}
|
||||
let distance =
|
||||
$gpxStatistics.global.distance.moving > 0
|
||||
? $gpxStatistics.global.distance.moving
|
||||
: $gpxStatistics.global.distance.total;
|
||||
setSpeed(distance / (movingTime / 3600));
|
||||
updateEnd();
|
||||
}
|
||||
|
||||
$: canUpdate =
|
||||
$selection.size === 1 && $selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']);
|
||||
$: canUpdate =
|
||||
$selection.size === 1 && $selection.hasAnyChildren(new ListRootItem(), true, ['waypoints']);
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3 w-full max-w-80 {$$props.class ?? ''}">
|
||||
<fieldset class="flex flex-col gap-2">
|
||||
<div class="flex flex-row gap-2 justify-center">
|
||||
<div class="flex flex-col gap-2 grow">
|
||||
<Label for="speed" class="flex flex-row">
|
||||
<Zap size="16" class="mr-1" />
|
||||
{#if $velocityUnits === 'speed'}
|
||||
{$_('quantities.speed')}
|
||||
{:else}
|
||||
{$_('quantities.pace')}
|
||||
{/if}
|
||||
</Label>
|
||||
<div class="flex flex-row gap-1 items-center">
|
||||
{#if $velocityUnits === 'speed'}
|
||||
<Input
|
||||
id="speed"
|
||||
type="number"
|
||||
step={0.01}
|
||||
min={0.01}
|
||||
disabled={!canUpdate}
|
||||
bind:value={speed}
|
||||
on:change={updateDataFromSpeed}
|
||||
/>
|
||||
<span class="text-sm shrink-0">
|
||||
{#if $distanceUnits === 'imperial'}
|
||||
{$_('units.miles_per_hour')}
|
||||
{:else}
|
||||
{$_('units.kilometers_per_hour')}
|
||||
{/if}
|
||||
</span>
|
||||
{:else}
|
||||
<TimePicker
|
||||
bind:value={speed}
|
||||
showHours={false}
|
||||
disabled={!canUpdate}
|
||||
on:change={updateDataFromSpeed}
|
||||
/>
|
||||
<span class="text-sm shrink-0">
|
||||
{#if $distanceUnits === 'imperial'}
|
||||
{$_('units.minutes_per_mile')}
|
||||
{:else}
|
||||
{$_('units.minutes_per_kilometer')}
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 grow">
|
||||
<Label for="duration" class="flex flex-row">
|
||||
<Timer size="16" class="mr-1" />
|
||||
{$_('toolbar.time.total_time')}
|
||||
</Label>
|
||||
<TimePicker
|
||||
bind:value={movingTime}
|
||||
disabled={!canUpdate}
|
||||
on:change={updateDataFromTotalTime}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Label class="flex flex-row">
|
||||
<CirclePlay size="16" class="mr-1" />
|
||||
{$_('toolbar.time.start')}
|
||||
</Label>
|
||||
<div class="flex flex-row gap-2">
|
||||
<DatePicker
|
||||
bind:value={startDate}
|
||||
disabled={!canUpdate}
|
||||
locale={get(locale) ?? 'en'}
|
||||
placeholder={$_('toolbar.time.pick_date')}
|
||||
class="w-fit grow"
|
||||
onValueChange={async () => {
|
||||
await tick();
|
||||
updateEnd();
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
type="time"
|
||||
step={1}
|
||||
disabled={!canUpdate}
|
||||
bind:value={startTime}
|
||||
class="w-fit"
|
||||
on:input={updateEnd}
|
||||
/>
|
||||
</div>
|
||||
<Label class="flex flex-row">
|
||||
<CircleStop size="16" class="mr-1" />
|
||||
{$_('toolbar.time.end')}
|
||||
</Label>
|
||||
<div class="flex flex-row gap-2">
|
||||
<DatePicker
|
||||
bind:value={endDate}
|
||||
disabled={!canUpdate}
|
||||
locale={get(locale) ?? 'en'}
|
||||
placeholder={$_('toolbar.time.pick_date')}
|
||||
class="w-fit grow"
|
||||
onValueChange={async () => {
|
||||
await tick();
|
||||
updateStart();
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
type="time"
|
||||
step={1}
|
||||
disabled={!canUpdate}
|
||||
bind:value={endTime}
|
||||
class="w-fit"
|
||||
on:change={updateStart}
|
||||
/>
|
||||
</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} />
|
||||
<Label for="artificial-time">
|
||||
{$_('toolbar.time.artificial')}
|
||||
</Label>
|
||||
</div>
|
||||
{/if}
|
||||
</fieldset>
|
||||
<div class="flex flex-row gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!canUpdate}
|
||||
class="grow"
|
||||
on:click={() => {
|
||||
let effectiveSpeed = getSpeed();
|
||||
if (startDate === undefined || startTime === undefined || effectiveSpeed === undefined) {
|
||||
return;
|
||||
}
|
||||
<fieldset class="flex flex-col gap-2">
|
||||
<div class="flex flex-row gap-2 justify-center">
|
||||
<div class="flex flex-col gap-2 grow">
|
||||
<Label for="speed" class="flex flex-row">
|
||||
<Zap size="16" class="mr-1" />
|
||||
{#if $velocityUnits === 'speed'}
|
||||
{$_('quantities.speed')}
|
||||
{:else}
|
||||
{$_('quantities.pace')}
|
||||
{/if}
|
||||
</Label>
|
||||
<div class="flex flex-row gap-1 items-center">
|
||||
{#if $velocityUnits === 'speed'}
|
||||
<Input
|
||||
id="speed"
|
||||
type="number"
|
||||
step={0.01}
|
||||
min={0.01}
|
||||
disabled={!canUpdate}
|
||||
bind:value={speed}
|
||||
on:change={updateDataFromSpeed}
|
||||
/>
|
||||
<span class="text-sm shrink-0">
|
||||
{#if $distanceUnits === 'imperial'}
|
||||
{$_('units.miles_per_hour')}
|
||||
{:else if $distanceUnits === 'metric'}
|
||||
{$_('units.kilometers_per_hour')}
|
||||
{:else if $distanceUnits === 'nautical'}
|
||||
{$_('units.knots')}
|
||||
{/if}
|
||||
</span>
|
||||
{:else}
|
||||
<TimePicker
|
||||
bind:value={speed}
|
||||
showHours={false}
|
||||
disabled={!canUpdate}
|
||||
onChange={updateDataFromSpeed}
|
||||
/>
|
||||
<span class="text-sm shrink-0">
|
||||
{#if $distanceUnits === 'imperial'}
|
||||
{$_('units.minutes_per_mile')}
|
||||
{:else if $distanceUnits === 'metric'}
|
||||
{$_('units.minutes_per_kilometer')}
|
||||
{:else if $distanceUnits === 'nautical'}
|
||||
{$_('units.minutes_per_nautical_mile')}
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 grow">
|
||||
<Label for="duration" class="flex flex-row">
|
||||
<Timer size="16" class="mr-1" />
|
||||
{$_('toolbar.time.total_time')}
|
||||
</Label>
|
||||
<TimePicker
|
||||
bind:value={movingTime}
|
||||
disabled={!canUpdate}
|
||||
onChange={updateDataFromTotalTime}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Label class="flex flex-row">
|
||||
<CirclePlay size="16" class="mr-1" />
|
||||
{$_('toolbar.time.start')}
|
||||
</Label>
|
||||
<div class="flex flex-row gap-2">
|
||||
<DatePicker
|
||||
bind:value={startDate}
|
||||
disabled={!canUpdate}
|
||||
locale={get(locale) ?? 'en'}
|
||||
placeholder={$_('toolbar.time.pick_date')}
|
||||
class="w-fit grow"
|
||||
onValueChange={async () => {
|
||||
await tick();
|
||||
updateEnd();
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="time"
|
||||
step={1}
|
||||
disabled={!canUpdate}
|
||||
bind:value={startTime}
|
||||
class="w-fit"
|
||||
on:change={updateEnd}
|
||||
/>
|
||||
</div>
|
||||
<Label class="flex flex-row">
|
||||
<CircleStop size="16" class="mr-1" />
|
||||
{$_('toolbar.time.end')}
|
||||
</Label>
|
||||
<div class="flex flex-row gap-2">
|
||||
<DatePicker
|
||||
bind:value={endDate}
|
||||
disabled={!canUpdate}
|
||||
locale={get(locale) ?? 'en'}
|
||||
placeholder={$_('toolbar.time.pick_date')}
|
||||
class="w-fit grow"
|
||||
onValueChange={async () => {
|
||||
await tick();
|
||||
updateStart();
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="time"
|
||||
step={1}
|
||||
disabled={!canUpdate}
|
||||
bind:value={endTime}
|
||||
class="w-fit"
|
||||
on:change={updateStart}
|
||||
/>
|
||||
</div>
|
||||
{#if $gpxStatistics.global.time.moving === 0 || $gpxStatistics.global.time.moving === undefined}
|
||||
<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>
|
||||
</div>
|
||||
{/if}
|
||||
</fieldset>
|
||||
<div class="flex flex-row gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!canUpdate}
|
||||
class="grow"
|
||||
on:click={() => {
|
||||
let effectiveSpeed = getSpeed();
|
||||
if (
|
||||
startDate === undefined ||
|
||||
startTime === undefined ||
|
||||
effectiveSpeed === undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Math.abs(effectiveSpeed - $gpxStatistics.global.speed.moving) < 0.01) {
|
||||
effectiveSpeed = $gpxStatistics.global.speed.moving;
|
||||
}
|
||||
if (Math.abs(effectiveSpeed - $gpxStatistics.global.speed.moving) < 0.01) {
|
||||
effectiveSpeed = $gpxStatistics.global.speed.moving;
|
||||
}
|
||||
|
||||
let ratio = 1;
|
||||
if (
|
||||
$gpxStatistics.global.speed.moving > 0 &&
|
||||
$gpxStatistics.global.speed.moving !== effectiveSpeed
|
||||
) {
|
||||
ratio = $gpxStatistics.global.speed.moving / effectiveSpeed;
|
||||
}
|
||||
let ratio = 1;
|
||||
if (
|
||||
$gpxStatistics.global.speed.moving > 0 &&
|
||||
$gpxStatistics.global.speed.moving !== effectiveSpeed
|
||||
) {
|
||||
ratio = $gpxStatistics.global.speed.moving / effectiveSpeed;
|
||||
}
|
||||
|
||||
let item = $selection.getSelected()[0];
|
||||
let fileId = item.getFileId();
|
||||
dbUtils.applyToFile(fileId, (file) => {
|
||||
if (item instanceof ListFileItem) {
|
||||
file.changeTimestamps(getDate(startDate, startTime), effectiveSpeed, ratio);
|
||||
} else if (item instanceof ListTrackItem) {
|
||||
file.changeTimestamps(
|
||||
getDate(startDate, startTime),
|
||||
effectiveSpeed,
|
||||
ratio,
|
||||
item.getTrackIndex()
|
||||
);
|
||||
} else if (item instanceof ListTrackSegmentItem) {
|
||||
file.changeTimestamps(
|
||||
getDate(startDate, startTime),
|
||||
effectiveSpeed,
|
||||
ratio,
|
||||
item.getTrackIndex(),
|
||||
item.getSegmentIndex()
|
||||
);
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<CalendarClock size="16" class="mr-1" />
|
||||
{$_('toolbar.time.update')}
|
||||
</Button>
|
||||
<Button variant="outline" on:click={setGPXData}>
|
||||
<CircleX size="16" />
|
||||
</Button>
|
||||
</div>
|
||||
<Help>
|
||||
{#if canUpdate}
|
||||
{$_('toolbar.time.help')}
|
||||
{:else}
|
||||
{$_('toolbar.time.help_invalid_selection')}
|
||||
{/if}
|
||||
</Help>
|
||||
let item = $selection.getSelected()[0];
|
||||
let fileId = item.getFileId();
|
||||
dbUtils.applyToFile(fileId, (file) => {
|
||||
if (item instanceof ListFileItem) {
|
||||
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,
|
||||
ratio,
|
||||
item.getTrackIndex(),
|
||||
item.getSegmentIndex()
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<CalendarClock size="16" class="mr-1" />
|
||||
{$_('toolbar.time.update')}
|
||||
</Button>
|
||||
<Button variant="outline" on:click={setGPXData}>
|
||||
<CircleX size="16" />
|
||||
</Button>
|
||||
</div>
|
||||
<Help link="./help/toolbar/time">
|
||||
{#if canUpdate}
|
||||
{$_('toolbar.time.help')}
|
||||
{:else}
|
||||
{$_('toolbar.time.help_invalid_selection')}
|
||||
{/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>
|
||||
|
@@ -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}
|
||||
|
@@ -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
|
||||
|
@@ -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}`);
|
||||
|
@@ -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;
|
||||
minAnchor = {
|
||||
point,
|
||||
segment,
|
||||
trackIndex,
|
||||
segmentIndex,
|
||||
};
|
||||
}
|
||||
let details: any = {};
|
||||
let closest = getClosestLinePoint(segment.trkpt, this.temporaryAnchor.point, details);
|
||||
if (details.distance < minDetails.distance) {
|
||||
minDetails = details;
|
||||
minAnchor = {
|
||||
point: closest,
|
||||
segment,
|
||||
trackIndex,
|
||||
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
|
||||
|
@@ -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;
|
||||
|
@@ -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}
|
@@ -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;
|
||||
};
|
@@ -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>
|
||||
|
||||
|
@@ -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) {
|
||||
store.set(initial);
|
||||
} else if (value !== undefined) {
|
||||
if (value === undefined) {
|
||||
if (first) {
|
||||
if (!initialize) {
|
||||
store.set(initial);
|
||||
}
|
||||
} 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;
|
||||
});
|
||||
|
40
website/src/lib/docs/ca/faq.mdx
Normal file
40
website/src/lib/docs/ca/faq.mdx
Normal 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>.
|
82
website/src/lib/docs/ca/files-and-stats.mdx
Normal file
82
website/src/lib/docs/ca/files-and-stats.mdx
Normal 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.
|
38
website/src/lib/docs/ca/getting-started.mdx
Normal file
38
website/src/lib/docs/ca/getting-started.mdx
Normal 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.
|
34
website/src/lib/docs/ca/gpx.mdx
Normal file
34
website/src/lib/docs/ca/gpx.mdx
Normal 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**.
|
13
website/src/lib/docs/ca/home/funding.mdx
Normal file
13
website/src/lib/docs/ca/home/funding.mdx
Normal 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! ❤️
|
5
website/src/lib/docs/ca/home/mapbox.mdx
Normal file
5
website/src/lib/docs/ca/home/mapbox.mdx
Normal 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.
|
12
website/src/lib/docs/ca/home/translation.mdx
Normal file
12
website/src/lib/docs/ca/home/translation.mdx
Normal 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!
|
27
website/src/lib/docs/ca/integration.mdx
Normal file
27
website/src/lib/docs/ca/integration.mdx
Normal 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 />
|
67
website/src/lib/docs/ca/map-controls.mdx
Normal file
67
website/src/lib/docs/ca/map-controls.mdx
Normal 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.
|
17
website/src/lib/docs/ca/menu.mdx
Normal file
17
website/src/lib/docs/ca/menu.mdx
Normal 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>
|
74
website/src/lib/docs/ca/menu/edit.mdx
Normal file
74
website/src/lib/docs/ca/menu/edit.mdx
Normal 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.
|
52
website/src/lib/docs/ca/menu/file.mdx
Normal file
52
website/src/lib/docs/ca/menu/file.mdx
Normal 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>
|
50
website/src/lib/docs/ca/menu/settings.mdx
Normal file
50
website/src/lib/docs/ca/menu/settings.mdx
Normal 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).
|
48
website/src/lib/docs/ca/menu/view.mdx
Normal file
48
website/src/lib/docs/ca/menu/view.mdx
Normal 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>
|
32
website/src/lib/docs/ca/toolbar.mdx
Normal file
32
website/src/lib/docs/ca/toolbar.mdx
Normal 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.
|
18
website/src/lib/docs/ca/toolbar/clean.mdx
Normal file
18
website/src/lib/docs/ca/toolbar/clean.mdx
Normal 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>
|
26
website/src/lib/docs/ca/toolbar/extract.mdx
Normal file
26
website/src/lib/docs/ca/toolbar/extract.mdx
Normal 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>
|
20
website/src/lib/docs/ca/toolbar/merge.mdx
Normal file
20
website/src/lib/docs/ca/toolbar/merge.mdx
Normal 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>
|
26
website/src/lib/docs/ca/toolbar/minify.mdx
Normal file
26
website/src/lib/docs/ca/toolbar/minify.mdx
Normal 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>
|
27
website/src/lib/docs/ca/toolbar/poi.mdx
Normal file
27
website/src/lib/docs/ca/toolbar/poi.mdx
Normal 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.
|
85
website/src/lib/docs/ca/toolbar/routing.mdx
Normal file
85
website/src/lib/docs/ca/toolbar/routing.mdx
Normal 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.
|
33
website/src/lib/docs/ca/toolbar/scissors.mdx
Normal file
33
website/src/lib/docs/ca/toolbar/scissors.mdx
Normal 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." />
|
27
website/src/lib/docs/ca/toolbar/time.mdx
Normal file
27
website/src/lib/docs/ca/toolbar/time.mdx
Normal 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>
|
40
website/src/lib/docs/cs/faq.mdx
Normal file
40
website/src/lib/docs/cs/faq.mdx
Normal 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>.
|
82
website/src/lib/docs/cs/files-and-stats.mdx
Normal file
82
website/src/lib/docs/cs/files-and-stats.mdx
Normal 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.
|
38
website/src/lib/docs/cs/getting-started.mdx
Normal file
38
website/src/lib/docs/cs/getting-started.mdx
Normal 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.
|
34
website/src/lib/docs/cs/gpx.mdx
Normal file
34
website/src/lib/docs/cs/gpx.mdx
Normal 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**.
|
13
website/src/lib/docs/cs/home/funding.mdx
Normal file
13
website/src/lib/docs/cs/home/funding.mdx
Normal 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! ❤️
|
5
website/src/lib/docs/cs/home/mapbox.mdx
Normal file
5
website/src/lib/docs/cs/home/mapbox.mdx
Normal 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.
|
12
website/src/lib/docs/cs/home/translation.mdx
Normal file
12
website/src/lib/docs/cs/home/translation.mdx
Normal 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!
|
27
website/src/lib/docs/cs/integration.mdx
Normal file
27
website/src/lib/docs/cs/integration.mdx
Normal 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 />
|
67
website/src/lib/docs/cs/map-controls.mdx
Normal file
67
website/src/lib/docs/cs/map-controls.mdx
Normal 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.
|
17
website/src/lib/docs/cs/menu.mdx
Normal file
17
website/src/lib/docs/cs/menu.mdx
Normal 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>
|
74
website/src/lib/docs/cs/menu/edit.mdx
Normal file
74
website/src/lib/docs/cs/menu/edit.mdx
Normal 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.
|
52
website/src/lib/docs/cs/menu/file.mdx
Normal file
52
website/src/lib/docs/cs/menu/file.mdx
Normal 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>
|
50
website/src/lib/docs/cs/menu/settings.mdx
Normal file
50
website/src/lib/docs/cs/menu/settings.mdx
Normal 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).
|
48
website/src/lib/docs/cs/menu/view.mdx
Normal file
48
website/src/lib/docs/cs/menu/view.mdx
Normal 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
Reference in New Issue
Block a user