mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2025-10-15 20:08:19 +00:00
Compare commits
161 Commits
layer-hove
...
drop-to-de
Author | SHA1 | Date | |
---|---|---|---|
![]() |
d18f77bd57 | ||
![]() |
d5022c3ce2 | ||
![]() |
db881cbaf1 | ||
![]() |
4cacafa381 | ||
![]() |
c681029288 | ||
![]() |
f7d0bc1250 | ||
![]() |
ce974d7791 | ||
![]() |
01bf1274d9 | ||
![]() |
01a29226e5 | ||
![]() |
c0ac148a97 | ||
![]() |
c6e4796cdb | ||
![]() |
eba6989606 | ||
![]() |
eed13abeb0 | ||
![]() |
c36636652b | ||
![]() |
294ff5bedf | ||
![]() |
58415af7da | ||
![]() |
369c2a5fb6 | ||
![]() |
9eb716e36c | ||
![]() |
4c56468970 | ||
![]() |
9a2541b6f3 | ||
![]() |
2be5c837cb | ||
![]() |
43803717f4 | ||
![]() |
0d4376ee6f | ||
![]() |
7a72e3d44e | ||
![]() |
b8b74cc7de | ||
![]() |
ea3d10fcc3 | ||
![]() |
45bfac4f88 | ||
![]() |
74d37f1d45 | ||
![]() |
195671acb6 | ||
![]() |
2e1ead31ea | ||
![]() |
3af91213fe | ||
![]() |
6585f05ce3 | ||
![]() |
bcc29480c7 | ||
![]() |
c02d96e90f | ||
![]() |
56e4522da7 | ||
![]() |
5592cf47e0 | ||
![]() |
764c5030b9 | ||
![]() |
d4460f95dd | ||
![]() |
1483460ec6 | ||
![]() |
1a10ecc44b | ||
![]() |
f77793b7fe | ||
![]() |
a48da3fcf0 | ||
![]() |
9d13e9bcdc | ||
![]() |
484aeedbb1 | ||
![]() |
534b1ca8db | ||
![]() |
4d16efe62f | ||
![]() |
d3c11f6153 | ||
![]() |
2c62abd3eb | ||
![]() |
a735852898 | ||
![]() |
f94edf3e3a | ||
![]() |
930b4b84ed | ||
![]() |
553bc2e0a3 | ||
![]() |
aa50f1f2b0 | ||
![]() |
a27de23fa4 | ||
![]() |
deedd8c6c2 | ||
![]() |
5c4181498d | ||
![]() |
29a78e8af3 | ||
![]() |
4ebf2b6fa9 | ||
![]() |
1b741c3b2f | ||
![]() |
81484789b5 | ||
![]() |
c63e5cfb6b | ||
![]() |
8e37a308c3 | ||
![]() |
0baa956160 | ||
![]() |
b638863df3 | ||
![]() |
ec7629aea7 | ||
![]() |
3c7f78ae38 | ||
![]() |
4ca749d1cc | ||
![]() |
0883bfed03 | ||
![]() |
130c12bb73 | ||
![]() |
f513aa28ab | ||
![]() |
5e1244cc82 | ||
![]() |
4c17c3ddfe | ||
![]() |
5236fc5191 | ||
![]() |
1d443f0626 | ||
![]() |
c83b32e6ae | ||
![]() |
7adf660b76 | ||
![]() |
9a0d54c684 | ||
![]() |
3e57bdc7c8 | ||
![]() |
3cc4d569f1 | ||
![]() |
dc76c71ae2 | ||
![]() |
1190a471fb | ||
![]() |
84b1a42e30 | ||
![]() |
08ad9b4186 | ||
![]() |
b8128aaf86 | ||
![]() |
fb00220cc8 | ||
![]() |
723a0138b6 | ||
![]() |
10e328f2a3 | ||
![]() |
a1383a7e97 | ||
![]() |
56e229f000 | ||
![]() |
8511a18de1 | ||
![]() |
4b0e49d171 | ||
![]() |
14e9d3319d | ||
![]() |
39e6532c26 | ||
![]() |
b343123ca6 | ||
![]() |
3246742437 | ||
![]() |
3ce5391658 | ||
![]() |
71c88b15c6 | ||
![]() |
25a3df5756 | ||
![]() |
9d5391805d | ||
![]() |
7d801be682 | ||
![]() |
f846b03e74 | ||
![]() |
e48789bb75 | ||
![]() |
30cc709627 | ||
![]() |
9c85a014da | ||
![]() |
f55a3c0224 | ||
![]() |
8985623639 | ||
![]() |
9ba07ce1ed | ||
![]() |
2e50dea71d | ||
![]() |
0757efcb79 | ||
![]() |
5be02d1c36 | ||
![]() |
2612eb2e91 | ||
![]() |
2996f047d3 | ||
![]() |
96836228db | ||
![]() |
a3dc17d780 | ||
![]() |
e733c96c5a | ||
![]() |
1de5e9443e | ||
![]() |
481bc3b8a1 | ||
![]() |
ce5b0d87a9 | ||
![]() |
efa40edc80 | ||
![]() |
8497044473 | ||
![]() |
41ed9c06c1 | ||
![]() |
75dc8512e7 | ||
![]() |
979cdd1bac | ||
![]() |
056e7a3980 | ||
![]() |
47df6d8f80 | ||
![]() |
3cbfaba5e7 | ||
![]() |
24181b932a | ||
![]() |
666693f374 | ||
![]() |
0cb781176e | ||
![]() |
33f3b6cc32 | ||
![]() |
a1b5fe6352 | ||
![]() |
920e7901f4 | ||
![]() |
a23fea3d98 | ||
![]() |
a751ada6c5 | ||
![]() |
1cf1ce762e | ||
![]() |
833f3e58da | ||
![]() |
b9ca55c798 | ||
![]() |
766ebe0275 | ||
![]() |
d939ef2f60 | ||
![]() |
c1faea787a | ||
![]() |
e42dd6e144 | ||
![]() |
e10e2412c4 | ||
![]() |
b5fd8ea09b | ||
![]() |
1a4ae96782 | ||
![]() |
14ed58aaab | ||
![]() |
779c700d13 | ||
![]() |
31a2aa2fee | ||
![]() |
9d403c861f | ||
![]() |
49582dcddd | ||
![]() |
fa30739fd0 | ||
![]() |
3bc9ac4639 | ||
![]() |
84b3d29e2e | ||
![]() |
9327870d54 | ||
![]() |
f36194b336 | ||
![]() |
f34b23253e | ||
![]() |
cfa40238e4 | ||
![]() |
66b57e0013 | ||
![]() |
879b65953f | ||
![]() |
e800b2ebef | ||
![]() |
22e9c76a5b | ||
![]() |
d81d189cdf |
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
|
||||
|
||||
@@ -72,6 +72,8 @@ This project has been made possible thanks to the following open source projects
|
||||
- [Mapbox GL JS](https://github.com/mapbox/mapbox-gl-js) — beautiful and fast interactive maps
|
||||
- [brouter](https://github.com/abrensch/brouter) — routing engine
|
||||
- [OpenStreetMap](https://www.openstreetmap.org) — map data used by Mapbox and brouter
|
||||
- Search:
|
||||
- [DocSearch](https://github.com/algolia/docsearch) — search engine for the documentation
|
||||
|
||||
## License
|
||||
|
||||
|
72
gpx/package-lock.json
generated
72
gpx/package-lock.json
generated
@@ -7,15 +7,16 @@
|
||||
"": {
|
||||
"name": "gpx",
|
||||
"version": "1.0.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"fast-xml-parser": "^4.4.0",
|
||||
"fast-xml-parser": "^4.5.0",
|
||||
"immer": "^10.1.1",
|
||||
"ts-node": "^10.9.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/geojson": "^7946.0.14",
|
||||
"@types/node": "^20.14.6",
|
||||
"typescript": "^5.4.5"
|
||||
"@types/node": "^20.16.10",
|
||||
"typescript": "^5.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@cspotcode/source-map-support": {
|
||||
@@ -29,15 +30,6 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
|
||||
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.0.3",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
@@ -47,9 +39,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.4.15",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz",
|
||||
"integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg=="
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
|
||||
"integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
|
||||
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.0.3",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tsconfig/node10": {
|
||||
"version": "1.0.11",
|
||||
@@ -78,17 +79,17 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.14.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.6.tgz",
|
||||
"integrity": "sha512-JbA0XIJPL1IiNnU7PFxDXyfAwcwVVrOoqyzzyQTyMeVhBzkJVMSkC1LlVsRQ2lpqiY4n6Bb9oCS6lzDKVQxbZw==",
|
||||
"version": "20.16.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.10.tgz",
|
||||
"integrity": "sha512-vQUKgWTjEIRFCvK6CyriPH3MZYiYlNy0fKiEYHWbcoWLEgs4opurGGKlebrTLqdSMIbXImH6XExNiIyNUv3WpA==",
|
||||
"dependencies": {
|
||||
"undici-types": "~5.26.4"
|
||||
"undici-types": "~6.19.2"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.11.3",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz",
|
||||
"integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==",
|
||||
"version": "8.12.1",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz",
|
||||
"integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -97,9 +98,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/acorn-walk": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz",
|
||||
"integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==",
|
||||
"version": "8.3.4",
|
||||
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
|
||||
"integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
|
||||
"dependencies": {
|
||||
"acorn": "^8.11.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
@@ -123,9 +127,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.4.0.tgz",
|
||||
"integrity": "sha512-kLY3jFlwIYwBNDojclKsNAC12sfD6NwW74QB2CoNGPvtVxjliYehVunB3HYyNi+n4Tt1dAcgwYvmKF/Z18flqg==",
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.0.tgz",
|
||||
"integrity": "sha512-/PlTQCI96+fZMAOLMZK4CWG1ItCbfZ/0jx7UIJFChPNrx7tcEgerUgWbeieCM9MfHInUDyK8DWYZ+YrywDJuTg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -205,9 +209,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.4.5",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
|
||||
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
|
||||
"version": "5.6.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.2.tgz",
|
||||
"integrity": "sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -217,9 +221,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "5.26.5",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="
|
||||
"version": "6.19.8",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
|
||||
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="
|
||||
},
|
||||
"node_modules/v8-compile-cache-lib": {
|
||||
"version": "3.0.1",
|
||||
|
@@ -11,16 +11,17 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"fast-xml-parser": "^4.4.0",
|
||||
"fast-xml-parser": "^4.5.0",
|
||||
"immer": "^10.1.1",
|
||||
"ts-node": "^10.9.2"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/geojson": "^7946.0.14",
|
||||
"@types/node": "^20.14.6",
|
||||
"typescript": "^5.4.5"
|
||||
"@types/node": "^20.16.10",
|
||||
"typescript": "^5.6.2"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"postinstall": "npm run build"
|
||||
}
|
||||
}
|
290
gpx/src/gpx.ts
290
gpx/src/gpx.ts
@@ -21,6 +21,7 @@ export abstract class GPXTreeElement<T extends GPXTreeElement<any>> {
|
||||
abstract getEndTimestamp(): Date | undefined;
|
||||
abstract getStatistics(): GPXStatistics;
|
||||
abstract getSegments(): TrackSegment[];
|
||||
abstract getTrackPoints(): TrackPoint[];
|
||||
|
||||
abstract toGeoJSON(): GeoJSON.Feature | GeoJSON.Feature[] | GeoJSON.FeatureCollection | GeoJSON.FeatureCollection[];
|
||||
|
||||
@@ -66,6 +67,10 @@ abstract class GPXTreeNode<T extends GPXTreeElement<any>> extends GPXTreeElement
|
||||
return this.children.flatMap((child) => child.getSegments());
|
||||
}
|
||||
|
||||
getTrackPoints(): TrackPoint[] {
|
||||
return this.children.flatMap((child) => child.getTrackPoints());
|
||||
}
|
||||
|
||||
// Producers
|
||||
_reverse(originalNextTimestamp?: Date, newPreviousTimestamp?: Date) {
|
||||
let og = getOriginal(this);
|
||||
@@ -99,7 +104,7 @@ abstract class GPXTreeLeaf extends GPXTreeElement<GPXTreeLeaf> {
|
||||
}
|
||||
|
||||
// A class that represents a GPX file
|
||||
export class GPXFile extends GPXTreeNode<Track>{
|
||||
export class GPXFile extends GPXTreeNode<Track> {
|
||||
[immerable] = true;
|
||||
|
||||
attributes: GPXFileAttributes;
|
||||
@@ -112,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) {
|
||||
@@ -350,6 +363,36 @@ 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(elevations: number[], trackIndices?: number[], segmentIndices?: number[], waypointIndices?: number[]) {
|
||||
let index = 0;
|
||||
this.trk.forEach((track, trackIndex) => {
|
||||
if (trackIndices === undefined || trackIndices.includes(trackIndex)) {
|
||||
track.trkseg.forEach((segment, segmentIndex) => {
|
||||
if (segmentIndices === undefined || segmentIndices.includes(segmentIndex)) {
|
||||
segment.trkpt.forEach((point) => {
|
||||
point.ele = elevations[index++];
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
this.wpt.forEach((waypoint, waypointIndex) => {
|
||||
if (waypointIndices === undefined || waypointIndices.includes(waypointIndex)) {
|
||||
waypoint.ele = elevations[index++];
|
||||
}
|
||||
});
|
||||
elevations.splice(0, index);
|
||||
}
|
||||
|
||||
setStyle(style: LineStyleExtension) {
|
||||
this.trk.forEach((track) => {
|
||||
track.setStyle(style);
|
||||
@@ -422,8 +465,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();
|
||||
@@ -456,8 +499,8 @@ 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),
|
||||
});
|
||||
}
|
||||
@@ -501,8 +544,8 @@ export class Track extends GPXTreeNode<TrackSegment> {
|
||||
src: this.src,
|
||||
link: this.link,
|
||||
type: this.type,
|
||||
trkseg: this.trkseg.map((seg) => seg.toTrackSegmentType(exclude)),
|
||||
extensions: this.extensions,
|
||||
trkseg: this.trkseg.map((seg) => seg.toTrackSegmentType(exclude)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -581,6 +624,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 = {};
|
||||
@@ -699,6 +753,11 @@ export class TrackSegment extends GPXTreeLeaf {
|
||||
|
||||
// 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);
|
||||
@@ -709,17 +768,20 @@ export class TrackSegment extends GPXTreeLeaf {
|
||||
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["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["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++;
|
||||
}
|
||||
}
|
||||
|
||||
if (i > 0 && points[i - 1].extensions && points[i - 1].extensions["gpxtpx:TrackPointExtension"] && points[i - 1].extensions["gpxtpx:TrackPointExtension"]["gpxtpx:Extensions"] && points[i - 1].extensions["gpxtpx:TrackPointExtension"]["gpxtpx:Extensions"].surface) {
|
||||
let surface = points[i - 1].extensions["gpxtpx:TrackPointExtension"]["gpxtpx:Extensions"].surface;
|
||||
if (statistics.global.surface[surface] === undefined) {
|
||||
statistics.global.surface[surface] = 0;
|
||||
}
|
||||
statistics.global.surface[surface] += dist;
|
||||
}
|
||||
}
|
||||
|
||||
[statistics.local.slope.segment, statistics.local.slope.length] = this._computeSlopeSegments(statistics);
|
||||
@@ -753,29 +815,7 @@ export class TrackSegment extends GPXTreeLeaf {
|
||||
}
|
||||
|
||||
_computeSlopeSegments(statistics: GPXStatistics): [number[], number[]] {
|
||||
// x-coordinates are given by: statistics.local.distance.total[point._data.index] * 1000
|
||||
// y-coordinates are given by: point.ele
|
||||
// Compute the distance between point3 and the line defined by point1 and point2
|
||||
function elevationDistance(point1: TrackPoint, point2: TrackPoint, point3: TrackPoint): number {
|
||||
if (point1.ele === undefined || point2.ele === undefined || point3.ele === undefined) {
|
||||
return 0;
|
||||
}
|
||||
let x1 = statistics.local.distance.total[point1._data.index] * 1000;
|
||||
let x2 = statistics.local.distance.total[point2._data.index] * 1000;
|
||||
let x3 = statistics.local.distance.total[point3._data.index] * 1000;
|
||||
let y1 = point1.ele;
|
||||
let y2 = point2.ele;
|
||||
let y3 = point3.ele;
|
||||
|
||||
let dist = Math.sqrt(Math.pow(y2 - y1, 2) + Math.pow(x2 - x1, 2));
|
||||
if (dist === 0) {
|
||||
return Math.sqrt(Math.pow(x3 - x1, 2) + Math.pow(y3 - y1, 2));
|
||||
}
|
||||
|
||||
return Math.abs((y2 - y1) * x3 - (x2 - x1) * y3 + x2 * y1 - y2 * x1) / dist;
|
||||
}
|
||||
|
||||
let simplified = ramerDouglasPeucker(this.trkpt, 20, elevationDistance);
|
||||
let simplified = ramerDouglasPeucker(this.trkpt, 20, getElevationDistanceFunction(statistics));
|
||||
|
||||
let slope = [];
|
||||
let length = [];
|
||||
@@ -784,7 +824,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);
|
||||
@@ -821,6 +861,10 @@ export class TrackSegment extends GPXTreeLeaf {
|
||||
return [this];
|
||||
}
|
||||
|
||||
getTrackPoints(): TrackPoint[] {
|
||||
return this.trkpt;
|
||||
}
|
||||
|
||||
toGeoJSON(): GeoJSON.Feature {
|
||||
return {
|
||||
type: "Feature",
|
||||
@@ -851,22 +895,30 @@ export class TrackSegment extends GPXTreeLeaf {
|
||||
let trkpt = og.trkpt.slice();
|
||||
|
||||
if (speed !== undefined || (trkpt.length > 0 && trkpt[0].time !== undefined)) {
|
||||
// Must handle timestamps (either segment has timestamps or the new points will have timestamps)
|
||||
if (start > 0 && trkpt[0].time === undefined) {
|
||||
// Add timestamps to points before [start, end] because they are missing
|
||||
trkpt.splice(0, 0, ...withTimestamps(trkpt.splice(0, start), speed, undefined, startTime));
|
||||
}
|
||||
if (points.length > 0) {
|
||||
// Adapt timestamps of the new points
|
||||
let last = start > 0 ? trkpt[start - 1] : undefined;
|
||||
if (points[0].time === undefined || (points.length > 1 && points[1].time === undefined)) {
|
||||
// Add timestamps to the new points because they are missing
|
||||
points = withTimestamps(points, speed, last, startTime);
|
||||
} else if (last !== undefined && points[0].time < last.time) {
|
||||
// Adapt timestamps of the new points because they are too early
|
||||
points = withShiftedAndCompressedTimestamps(points, speed, 1, last);
|
||||
}
|
||||
}
|
||||
if (end < trkpt.length - 1) {
|
||||
// Adapt timestamps of points after [start, end]
|
||||
let last = points.length > 0 ? points[points.length - 1] : start > 0 ? trkpt[start - 1] : undefined;
|
||||
if (trkpt[end + 1].time === undefined) {
|
||||
// Add timestamps to points after [start, end] because they are missing
|
||||
trkpt.splice(end + 1, 0, ...withTimestamps(trkpt.splice(end + 1), speed, last, startTime));
|
||||
} else if (last !== undefined && trkpt[end + 1].time < last.time) {
|
||||
// Adapt timestamps of points after [start, end] because they are too early
|
||||
trkpt.splice(end + 1, 0, ...withShiftedAndCompressedTimestamps(trkpt.splice(end + 1), speed, 1, last));
|
||||
}
|
||||
}
|
||||
@@ -944,6 +996,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;
|
||||
}
|
||||
@@ -984,6 +1044,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;
|
||||
}
|
||||
@@ -992,10 +1056,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;
|
||||
}
|
||||
@@ -1032,15 +1092,15 @@ export class TrackPoint {
|
||||
"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["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["gpxpx:PowerExtension"] && this.extensions["gpxpx:PowerExtension"]["gpxpx:PowerInWatts"] && !exclude.includes('power')) {
|
||||
trkpt.extensions["gpxpx:PowerExtension"]["gpxpx:PowerInWatts"] = this.extensions["gpxpx:PowerExtension"]["gpxpx:PowerInWatts"];
|
||||
}
|
||||
@@ -1108,20 +1168,31 @@ export class Waypoint {
|
||||
}
|
||||
|
||||
toWaypointType(exclude: string[] = []): WaypointType {
|
||||
let wpt: WaypointType = {
|
||||
attributes: this.attributes,
|
||||
ele: this.ele,
|
||||
name: this.name,
|
||||
cmt: this.cmt,
|
||||
desc: this.desc,
|
||||
link: this.link,
|
||||
sym: this.sym,
|
||||
type: this.type,
|
||||
};
|
||||
if (!exclude.includes('time')) {
|
||||
wpt = { ...wpt, time: this.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,
|
||||
};
|
||||
}
|
||||
return wpt;
|
||||
}
|
||||
|
||||
clone(): Waypoint {
|
||||
@@ -1168,6 +1239,10 @@ export class GPXStatistics {
|
||||
southWest: Coordinates,
|
||||
northEast: Coordinates,
|
||||
},
|
||||
atemp: {
|
||||
avg: number,
|
||||
count: number,
|
||||
},
|
||||
hr: {
|
||||
avg: number,
|
||||
count: number,
|
||||
@@ -1176,14 +1251,11 @@ export class GPXStatistics {
|
||||
avg: number,
|
||||
count: number,
|
||||
},
|
||||
atemp: {
|
||||
avg: number,
|
||||
count: number,
|
||||
},
|
||||
power: {
|
||||
avg: number,
|
||||
count: number,
|
||||
}
|
||||
},
|
||||
surface: Record<string, number>,
|
||||
};
|
||||
local: {
|
||||
points: TrackPoint[],
|
||||
@@ -1238,6 +1310,10 @@ export class GPXStatistics {
|
||||
lon: -180,
|
||||
},
|
||||
},
|
||||
atemp: {
|
||||
avg: 0,
|
||||
count: 0,
|
||||
},
|
||||
hr: {
|
||||
avg: 0,
|
||||
count: 0,
|
||||
@@ -1246,14 +1322,11 @@ export class GPXStatistics {
|
||||
avg: 0,
|
||||
count: 0,
|
||||
},
|
||||
atemp: {
|
||||
avg: 0,
|
||||
count: 0,
|
||||
},
|
||||
power: {
|
||||
avg: 0,
|
||||
count: 0,
|
||||
}
|
||||
},
|
||||
surface: {},
|
||||
};
|
||||
this.local = {
|
||||
points: [],
|
||||
@@ -1315,17 +1388,34 @@ export class GPXStatistics {
|
||||
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.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.power.avg = (this.global.power.count * this.global.power.avg + other.global.power.count * other.global.power.avg) / Math.max(1, this.global.power.count + other.global.power.count);
|
||||
this.global.power.count += other.global.power.count;
|
||||
Object.keys(other.global.surface).forEach((surface) => {
|
||||
if (this.global.surface[surface] === undefined) {
|
||||
this.global.surface[surface] = 0;
|
||||
}
|
||||
this.global.surface[surface] += other.global.surface[surface];
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -1350,9 +1440,9 @@ 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.atemp = this.global.atemp;
|
||||
statistics.global.power = this.global.power;
|
||||
|
||||
return statistics;
|
||||
@@ -1360,7 +1450,13 @@ export class GPXStatistics {
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -1369,6 +1465,30 @@ export function distance(coord1: Coordinates, coord2: Coordinates): number {
|
||||
return maxMeters;
|
||||
}
|
||||
|
||||
export function getElevationDistanceFunction(statistics: GPXStatistics) {
|
||||
// x-coordinates are given by: statistics.local.distance.total[point._data.index] * 1000
|
||||
// y-coordinates are given by: point.ele
|
||||
// Compute the distance between point3 and the line defined by point1 and point2
|
||||
return (point1: TrackPoint, point2: TrackPoint, point3: TrackPoint) => {
|
||||
if (point1.ele === undefined || point2.ele === undefined || point3.ele === undefined) {
|
||||
return 0;
|
||||
}
|
||||
let x1 = statistics.local.distance.total[point1._data.index] * 1000;
|
||||
let x2 = statistics.local.distance.total[point2._data.index] * 1000;
|
||||
let x3 = statistics.local.distance.total[point3._data.index] * 1000;
|
||||
let y1 = point1.ele;
|
||||
let y2 = point2.ele;
|
||||
let y3 = point3.ele;
|
||||
|
||||
let dist = Math.sqrt(Math.pow(y2 - y1, 2) + Math.pow(x2 - x1, 2));
|
||||
if (dist === 0) {
|
||||
return Math.sqrt(Math.pow(x3 - x1, 2) + Math.pow(y3 - y1, 2));
|
||||
}
|
||||
|
||||
return Math.abs((y2 - y1) * x3 - (x2 - x1) * y3 + x2 * y1 - y2 * x1) / dist;
|
||||
}
|
||||
}
|
||||
|
||||
function distanceWindowSmoothing(points: TrackPoint[], distanceWindow: number, accumulate: (index: number) => number, compute: (accumulated: number, start: number, end: number) => number, remove?: (index: number) => number): number[] {
|
||||
let result = [];
|
||||
|
||||
@@ -1412,9 +1532,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;
|
||||
});
|
||||
}
|
||||
@@ -1445,8 +1595,8 @@ function convertRouteToTrack(route: RouteType): Track {
|
||||
src: route.src,
|
||||
link: route.link,
|
||||
type: route.type,
|
||||
trkseg: [],
|
||||
extensions: route.extensions,
|
||||
trkseg: [],
|
||||
});
|
||||
|
||||
if (route.rtept) {
|
||||
@@ -1462,6 +1612,8 @@ function convertRouteToTrack(route: RouteType): Track {
|
||||
} else {
|
||||
segment.trkpt.push(new TrackPoint({
|
||||
attributes: rpt.attributes,
|
||||
ele: rpt.ele,
|
||||
time: rpt.time,
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
@@ -34,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);
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ export function parseGPX(gpxData: string): GPXFile {
|
||||
return new GPXFile(parsed);
|
||||
}
|
||||
|
||||
export function buildGPX(file: GPXFile, exclude: string[]): string {
|
||||
export function buildGPX(file: GPXFile, exclude: string[] = []): string {
|
||||
const gpx = file.toGPXFileType(exclude);
|
||||
|
||||
const builder = new XMLBuilder({
|
||||
@@ -87,14 +87,6 @@ export function buildGPX(file: GPXFile, exclude: string[]): 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;
|
||||
@@ -107,6 +99,20 @@ export function buildGPX(file: GPXFile, exclude: string[]): string {
|
||||
encoding: "UTF-8",
|
||||
}
|
||||
},
|
||||
gpx
|
||||
gpx: removeEmptyElements(gpx)
|
||||
});
|
||||
}
|
||||
|
||||
function removeEmptyElements(obj: GPXFileType): GPXFileType {
|
||||
for (const key in obj) {
|
||||
if (obj[key] === null || obj[key] === undefined || obj[key] === '' || (Array.isArray(obj[key]) && obj[key].length === 0)) {
|
||||
delete obj[key];
|
||||
} else if (typeof obj[key] === 'object' && !(obj[key] instanceof Date)) {
|
||||
removeEmptyElements(obj[key]);
|
||||
if (Object.keys(obj[key]).length === 0) {
|
||||
delete obj[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
return obj;
|
||||
}
|
@@ -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;
|
||||
};
|
||||
|
253
gpx/test-data/with_routes.gpx
Normal file
253
gpx/test-data/with_routes.gpx
Normal file
@@ -0,0 +1,253 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns="http://www.topografix.com/GPX/1/1" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd http://www.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd"
|
||||
xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v1"
|
||||
xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3"
|
||||
xmlns:gpx_style="http://www.topografix.com/GPX/gpx_style/0/2" version="1.1" creator="https://gpx.studio">
|
||||
<metadata>
|
||||
<name>with_routes</name>
|
||||
<author>
|
||||
<name>gpx.studio</name>
|
||||
<link href="https://gpx.studio"></link>
|
||||
</author>
|
||||
</metadata>
|
||||
<rte>
|
||||
<name>route 1</name>
|
||||
<type>Cycling</type>
|
||||
<rtept lat="50.790867" lon="4.404968">
|
||||
<ele>109.0</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.790714" lon="4.405036">
|
||||
<ele>110.8</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.790336" lon="4.405259">
|
||||
<ele>110.3</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.790165" lon="4.405331">
|
||||
<ele>110.0</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.790008" lon="4.405359">
|
||||
<ele>110.3</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.789818" lon="4.405359">
|
||||
<ele>109.3</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.789409" lon="4.40534">
|
||||
<ele>107.0</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.789105" lon="4.405411">
|
||||
<ele>106.0</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.788799" lon="4.405527">
|
||||
<ele>108.5</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.788645" lon="4.405606">
|
||||
<ele>109.8</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.7885" lon="4.405711">
|
||||
<ele>110.8</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.78822" lon="4.405959">
|
||||
<ele>112.0</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.787956" lon="4.406092">
|
||||
<ele>112.8</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.787814" lon="4.406143">
|
||||
<ele>113.5</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.787674" lon="4.406177">
|
||||
<ele>114.3</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.787451" lon="4.406199">
|
||||
<ele>115.3</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.787297" lon="4.406177">
|
||||
<ele>114.8</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.78716" lon="4.406098">
|
||||
<ele>114.3</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.787045" lon="4.405984">
|
||||
<ele>114.3</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.786683" lon="4.405653">
|
||||
<ele>114.5</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.786538" lon="4.405543">
|
||||
<ele>115.0</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.78635" lon="4.405441">
|
||||
<ele>115.8</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.786275" lon="4.40542">
|
||||
<ele>115.8</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.786182" lon="4.405435">
|
||||
<ele>116.0</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.786121" lon="4.405475">
|
||||
<ele>115.8</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.786042" lon="4.405558">
|
||||
<ele>115.5</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.785821" lon="4.405925">
|
||||
<ele>114.5</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.785672" lon="4.406119">
|
||||
<ele>112.5</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.785516" lon="4.406256">
|
||||
<ele>110.8</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.785384" lon="4.406364">
|
||||
<ele>109.0</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.785126" lon="4.406475">
|
||||
<ele>106.3</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.784697" lon="4.406537">
|
||||
<ele>104.3</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.784591" lon="4.40657">
|
||||
<ele>104.0</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.784507" lon="4.406612">
|
||||
<ele>103.8</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.784435" lon="4.40669">
|
||||
<ele>103.3</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.784209" lon="4.407148">
|
||||
<ele>103.5</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.784162" lon="4.407257">
|
||||
<ele>103.8</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.784077" lon="4.407372">
|
||||
<ele>104.8</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.784006" lon="4.407435">
|
||||
<ele>105.8</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.783924" lon="4.407471">
|
||||
<ele>106.8</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.783837" lon="4.407486">
|
||||
<ele>107.8</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.783771" lon="4.407472">
|
||||
<ele>108.5</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.783697" lon="4.407428">
|
||||
<ele>109.3</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.783626" lon="4.407363">
|
||||
<ele>110.0</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.783548" lon="4.407274">
|
||||
<ele>110.5</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.783458" lon="4.407134">
|
||||
<ele>110.8</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.783123" lon="4.406435">
|
||||
<ele>111.8</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.782982" lon="4.406168">
|
||||
<ele>112.8</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.782871" lon="4.406044">
|
||||
<ele>113.3</ele>
|
||||
</rtept>
|
||||
</rte>
|
||||
<rte>
|
||||
<name>route 2</name>
|
||||
<type>Cycling</type>
|
||||
<rtept lat="50.782212" lon="4.406377">
|
||||
<ele>115.5</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.782175" lon="4.406413">
|
||||
<ele>115.8</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.781749" lon="4.407018">
|
||||
<ele>118.5</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.781654" lon="4.407316">
|
||||
<ele>119.5</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.781563" lon="4.407764">
|
||||
<ele>121.3</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.781487" lon="4.407984">
|
||||
<ele>122.0</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.781422" lon="4.408216">
|
||||
<ele>122.8</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.781395" lon="4.408508">
|
||||
<ele>123.5</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.781399" lon="4.409114">
|
||||
<ele>126.3</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.781367" lon="4.409428">
|
||||
<ele>128.0</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.781286" lon="4.409607">
|
||||
<ele>129.0</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.78116" lon="4.409789">
|
||||
<ele>130.0</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.780804" lon="4.409993">
|
||||
<ele>130.8</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.780389" lon="4.410334">
|
||||
<ele>131.8</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.780232" lon="4.410563">
|
||||
<ele>132.3</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.780094" lon="4.410827">
|
||||
<ele>132.8</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.779723" lon="4.411582">
|
||||
<ele>135.8</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.779591" lon="4.411791">
|
||||
<ele>135.5</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.779125" lon="4.412435">
|
||||
<ele>132.5</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.778676" lon="4.412979">
|
||||
<ele>134.0</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.778194" lon="4.413466">
|
||||
<ele>136.8</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.777427" lon="4.414302">
|
||||
<ele>137.5</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.777165" lon="4.414736">
|
||||
<ele>137.3</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.776927" lon="4.415201">
|
||||
<ele>137.5</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.776778" lon="4.415613">
|
||||
<ele>137.3</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.776553" lon="4.416425">
|
||||
<ele>134.8</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.776326" lon="4.417304">
|
||||
<ele>132.3</ele>
|
||||
</rtept>
|
||||
<rtept lat="50.776129" lon="4.418383">
|
||||
<ele>129.5</ele>
|
||||
</rtept>
|
||||
</rte>
|
||||
</gpx>
|
4725
website/package-lock.json
generated
4725
website/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -13,61 +13,67 @@
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^3.2.2",
|
||||
"@sveltejs/adapter-static": "^3.0.2",
|
||||
"@sveltejs/enhanced-img": "^0.3.0",
|
||||
"@sveltejs/kit": "^2.5.17",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.1.1",
|
||||
"@types/eslint": "^8.56.10",
|
||||
"@sveltejs/adapter-auto": "^3.2.5",
|
||||
"@sveltejs/adapter-static": "^3.0.5",
|
||||
"@sveltejs/enhanced-img": "^0.3.8",
|
||||
"@sveltejs/kit": "^2.6.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.1.2",
|
||||
"@types/eslint": "^8.56.12",
|
||||
"@types/events": "^3.0.3",
|
||||
"@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/mapbox__tilebelt": "^1.0.4",
|
||||
"@types/mapbox-gl": "^3.4.0",
|
||||
"@types/node": "^20.16.10",
|
||||
"@types/png.js": "^0.2.3",
|
||||
"@types/sanitize-html": "^2.13.0",
|
||||
"@types/sortablejs": "^1.15.8",
|
||||
"@typescript-eslint/eslint-plugin": "^7.13.1",
|
||||
"@typescript-eslint/parser": "^7.13.1",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^8.57.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-svelte": "^2.40.0",
|
||||
"eslint-plugin-svelte": "^2.44.1",
|
||||
"events": "^3.3.0",
|
||||
"glob": "^10.4.3",
|
||||
"glob": "^10.4.5",
|
||||
"mdsvex": "^0.11.2",
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "^3.3.2",
|
||||
"prettier-plugin-svelte": "^3.2.4",
|
||||
"svelte": "^4.2.18",
|
||||
"svelte-check": "^3.8.1",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"tslib": "^2.6.3",
|
||||
"tsx": "^4.15.7",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.3.1"
|
||||
"postcss": "^8.4.47",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-plugin-svelte": "^3.2.7",
|
||||
"svelte": "^4.2.19",
|
||||
"svelte-check": "^3.8.6",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"tslib": "^2.7.0",
|
||||
"tsx": "^4.19.1",
|
||||
"typescript": "^5.6.2",
|
||||
"vite": "^5.4.8",
|
||||
"vite-plugin-node-polyfills": "^0.22.0"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@internationalized/date": "^3.5.4",
|
||||
"@mapbox/mapbox-gl-geocoder": "^5.0.2",
|
||||
"@docsearch/js": "^3.6.2",
|
||||
"@internationalized/date": "^3.5.5",
|
||||
"@mapbox/mapbox-gl-geocoder": "^5.0.3",
|
||||
"@mapbox/sphericalmercator": "^1.2.0",
|
||||
"@mapbox/tilebelt": "^1.0.2",
|
||||
"@types/mapbox__sphericalmercator": "^1.2.3",
|
||||
"bits-ui": "^0.21.12",
|
||||
"chart.js": "^4.4.3",
|
||||
"bits-ui": "^0.21.15",
|
||||
"chart.js": "^4.4.4",
|
||||
"chartjs-plugin-zoom": "^2.0.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dexie": "^4.0.7",
|
||||
"dexie": "^4.0.8",
|
||||
"gpx": "file:../gpx",
|
||||
"immer": "^10.1.1",
|
||||
"lucide-static": "^0.427.0",
|
||||
"lucide-svelte": "^0.427.0",
|
||||
"mapbox-gl": "^3.4.0",
|
||||
"mapbox-gl": "^3.7.0",
|
||||
"mapillary-js": "^4.1.2",
|
||||
"mode-watcher": "^0.3.1",
|
||||
"png.js": "^0.2.1",
|
||||
"sanitize-html": "^2.13.0",
|
||||
"sortablejs": "^1.15.2",
|
||||
"sortablejs": "^1.15.3",
|
||||
"svelte-i18n": "^4.0.0",
|
||||
"svelte-sonner": "^0.3.24",
|
||||
"tailwind-merge": "^2.3.0",
|
||||
"svelte-sonner": "^0.3.28",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tailwind-variants": "^0.2.1"
|
||||
}
|
||||
}
|
||||
|
@@ -1,10 +1,10 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
|
@@ -8,7 +8,7 @@
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--muted-foreground: 215.4 16.3% 45%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
@@ -33,6 +33,8 @@
|
||||
|
||||
--support: 220 15 130;
|
||||
|
||||
--link: 0 110 180;
|
||||
|
||||
--ring: 222.2 84% 4.9%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
@@ -68,6 +70,8 @@
|
||||
|
||||
--support: 255 110 190;
|
||||
|
||||
--link: 80 190 255;
|
||||
|
||||
--ring: hsl(212.7,26.8%,83.9);
|
||||
}
|
||||
}
|
||||
|
68
website/src/hooks.server.js
Normal file
68
website/src/hooks.server.js
Normal file
@@ -0,0 +1,68 @@
|
||||
import { base } from '$app/paths';
|
||||
import { languages } from '$lib/languages';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
|
||||
export async function handle({ event, resolve }) {
|
||||
const language = event.params.language ?? 'en';
|
||||
const strings = await import(`./locales/${language}.json`);
|
||||
|
||||
const path = event.url.pathname;
|
||||
const page = event.route.id?.replace('/[[language]]', '').split('/')[1] ?? 'home';
|
||||
|
||||
let title = strings.metadata[`${page}_title`];
|
||||
const description = strings.metadata[`description`];
|
||||
|
||||
if (page === 'help' && event.params.guide) {
|
||||
const [guide, subguide] = event.params.guide.split('/');
|
||||
const guideModule = subguide
|
||||
? await import(`./lib/docs/${language}/${guide}/${subguide}.mdx`)
|
||||
: await import(`./lib/docs/${language}/${guide}.mdx`);
|
||||
title = `${title} | ${guideModule.metadata.title}`;
|
||||
}
|
||||
|
||||
const htmlTag = `<html lang="${language}" translate="no">`;
|
||||
|
||||
let headTag = `<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)) {
|
||||
headTag += ` <link rel="alternate" hreflang="${lang}" href="https://gpx.studio${getURLForLanguage(lang, path)}" />
|
||||
`;
|
||||
}
|
||||
|
||||
const stringsHTML = page === 'app' ? stringsToHTML(strings) : '';
|
||||
|
||||
const response = await resolve(event, {
|
||||
transformPageChunk: ({ html }) => html.replace('<html>', htmlTag).replace('<head>', headTag).replace('</body>', `<div class="fixed -z-10 text-transparent">${stringsHTML}</div></body>`)
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
function stringsToHTML(dictionary, strings = new Set(), root = true) {
|
||||
Object.values(dictionary).forEach((value) => {
|
||||
if (typeof value === 'object') {
|
||||
stringsToHTML(value, strings, false);
|
||||
} else {
|
||||
strings.add(value);
|
||||
}
|
||||
});
|
||||
if (root) {
|
||||
return Array.from(strings).map((string) => `<p>${string}</p>`).join('');
|
||||
}
|
||||
}
|
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
File diff suppressed because one or more lines are too long
Binary file not shown.
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 2.2 MiB |
@@ -1,9 +1,9 @@
|
||||
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
|
||||
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 AnySourceData, type Style } from 'mapbox-gl';
|
||||
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',
|
||||
@@ -15,7 +15,7 @@ export const basemaps: { [key: string]: string | Style; } = {
|
||||
type: 'raster',
|
||||
tiles: ['https://a.tile.openstreetmap.org/{z}/{x}/{y}.png', 'https://b.tile.openstreetmap.org/{z}/{x}/{y}.png', 'https://c.tile.openstreetmap.org/{z}/{x}/{y}.png'],
|
||||
tileSize: 256,
|
||||
maxzoom: 18,
|
||||
maxzoom: 19,
|
||||
attribution: 'Map tiles by <a target="_top" rel="noopener" href="https://tile.openstreetmap.org/">OpenStreetMap tile servers</a>, under the <a target="_top" rel="noopener" href="https://operations.osmfoundation.org/policies/tiles/">tile usage policy</a>. Data by <a target="_top" rel="noopener" href="http://openstreetmap.org">OpenStreetMap</a>'
|
||||
}
|
||||
},
|
||||
@@ -66,7 +66,7 @@ export const basemaps: { [key: string]: string | Style; } = {
|
||||
type: 'raster',
|
||||
tiles: ['https://a.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png', 'https://b.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png', 'https://c.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png'],
|
||||
tileSize: 256,
|
||||
maxzoom: 17,
|
||||
maxzoom: 18,
|
||||
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>'
|
||||
}
|
||||
},
|
||||
@@ -167,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: {
|
||||
@@ -204,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: {
|
||||
@@ -271,144 +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>'
|
||||
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',
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -459,6 +639,7 @@ export const basemapTree: LayerTreeType = {
|
||||
},
|
||||
sweden: {
|
||||
swedenTopo: true,
|
||||
swedenSatellite: true,
|
||||
},
|
||||
switzerland: {
|
||||
swisstopoRaster: true,
|
||||
@@ -479,9 +660,6 @@ export const basemapTree: LayerTreeType = {
|
||||
export const overlayTree: LayerTreeType = {
|
||||
overlays: {
|
||||
world: {
|
||||
cyclOSM: {
|
||||
cyclOSMlite: true,
|
||||
},
|
||||
waymarked_trails: {
|
||||
waymarkedTrailsHiking: true,
|
||||
waymarkedTrailsCycling: true,
|
||||
@@ -489,7 +667,9 @@ export const overlayTree: LayerTreeType = {
|
||||
waymarkedTrailsSkating: true,
|
||||
waymarkedTrailsHorseRiding: true,
|
||||
waymarkedTrailsWinter: true,
|
||||
}
|
||||
},
|
||||
cyclOSMlite: true,
|
||||
bikerouterGravel: true,
|
||||
},
|
||||
countries: {
|
||||
france: {
|
||||
@@ -563,9 +743,6 @@ export const defaultBasemap = 'mapboxOutdoors';
|
||||
export const defaultOverlays = {
|
||||
overlays: {
|
||||
world: {
|
||||
cyclOSM: {
|
||||
cyclOSMlite: false,
|
||||
},
|
||||
waymarked_trails: {
|
||||
waymarkedTrailsHiking: false,
|
||||
waymarkedTrailsCycling: false,
|
||||
@@ -573,7 +750,9 @@ export const defaultOverlays = {
|
||||
waymarkedTrailsSkating: false,
|
||||
waymarkedTrailsHorseRiding: false,
|
||||
waymarkedTrailsWinter: false,
|
||||
}
|
||||
},
|
||||
cyclOSMlite: false,
|
||||
bikerouterGravel: false,
|
||||
},
|
||||
countries: {
|
||||
france: {
|
||||
@@ -679,6 +858,7 @@ export const defaultBasemapTree: LayerTreeType = {
|
||||
},
|
||||
sweden: {
|
||||
swedenTopo: false,
|
||||
swedenSatellite: false,
|
||||
},
|
||||
switzerland: {
|
||||
swisstopoRaster: false,
|
||||
@@ -699,9 +879,6 @@ export const defaultBasemapTree: LayerTreeType = {
|
||||
export const defaultOverlayTree: LayerTreeType = {
|
||||
overlays: {
|
||||
world: {
|
||||
cyclOSM: {
|
||||
cyclOSMlite: false,
|
||||
},
|
||||
waymarked_trails: {
|
||||
waymarkedTrailsHiking: true,
|
||||
waymarkedTrailsCycling: true,
|
||||
@@ -709,7 +886,9 @@ export const defaultOverlayTree: LayerTreeType = {
|
||||
waymarkedTrailsSkating: false,
|
||||
waymarkedTrailsHorseRiding: false,
|
||||
waymarkedTrailsWinter: false,
|
||||
}
|
||||
},
|
||||
cyclOSMlite: false,
|
||||
bikerouterGravel: false,
|
||||
},
|
||||
countries: {
|
||||
france: {
|
||||
|
60
website/src/lib/components/AlgoliaDocSearch.svelte
Normal file
60
website/src/lib/components/AlgoliaDocSearch.svelte
Normal file
@@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
import docsearch from '@docsearch/js';
|
||||
import '@docsearch/css';
|
||||
import { onMount } from 'svelte';
|
||||
import { _, locale, waitLocale } from 'svelte-i18n';
|
||||
|
||||
let mounted = false;
|
||||
|
||||
function initDocsearch() {
|
||||
docsearch({
|
||||
appId: '21XLD94PE3',
|
||||
apiKey: 'd2c1ed6cb0ed12adb2bd84eb2a38494d',
|
||||
indexName: 'gpx',
|
||||
container: '#docsearch',
|
||||
searchParameters: {
|
||||
facetFilters: ['lang:' + ($locale ?? 'en')]
|
||||
},
|
||||
placeholder: $_('docs.search.search'),
|
||||
disableUserPersonalization: true,
|
||||
translations: {
|
||||
button: {
|
||||
buttonText: $_('docs.search.search'),
|
||||
buttonAriaLabel: $_('docs.search.search')
|
||||
},
|
||||
modal: {
|
||||
searchBox: {
|
||||
resetButtonTitle: $_('docs.search.clear'),
|
||||
resetButtonAriaLabel: $_('docs.search.clear'),
|
||||
cancelButtonText: $_('docs.search.cancel'),
|
||||
cancelButtonAriaLabel: $_('docs.search.cancel'),
|
||||
searchInputLabel: $_('docs.search.search')
|
||||
},
|
||||
footer: {
|
||||
selectText: $_('docs.search.to_select'),
|
||||
navigateText: $_('docs.search.to_navigate'),
|
||||
closeText: $_('docs.search.to_close')
|
||||
},
|
||||
noResultsScreen: {
|
||||
noResultsText: $_('docs.search.no_results'),
|
||||
suggestedQueryText: $_('docs.search.no_results_suggestion')
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
mounted = true;
|
||||
});
|
||||
|
||||
$: if (mounted && $locale) {
|
||||
waitLocale().then(initDocsearch);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="preconnect" href="https://21XLD94PE3-dsn.algolia.net" crossorigin />
|
||||
</svelte:head>
|
||||
|
||||
<div id="docsearch" {...$$restProps}></div>
|
26
website/src/lib/components/ButtonWithTooltip.svelte
Normal file
26
website/src/lib/components/ButtonWithTooltip.svelte
Normal file
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
||||
|
||||
export let variant:
|
||||
| 'default'
|
||||
| 'secondary'
|
||||
| 'link'
|
||||
| 'destructive'
|
||||
| 'outline'
|
||||
| 'ghost'
|
||||
| undefined = 'default';
|
||||
export let label: string;
|
||||
export let side: 'top' | 'right' | 'bottom' | 'left' = 'top';
|
||||
</script>
|
||||
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild let:builder>
|
||||
<Button builders={[builder]} {variant} {...$$restProps}>
|
||||
<slot />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content {side}>
|
||||
<span>{label}</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
@@ -104,7 +104,8 @@
|
||||
line: {
|
||||
pointRadius: 0,
|
||||
tension: 0.4,
|
||||
borderWidth: 2
|
||||
borderWidth: 2,
|
||||
cubicInterpolationMode: 'monotone'
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
@@ -624,16 +625,14 @@
|
||||
type="single"
|
||||
bind:value={elevationFill}
|
||||
>
|
||||
<ToggleGroup.Item class="p-0 w-5 h-5" value="slope">
|
||||
<Tooltip side="left">
|
||||
<TriangleRight slot="data" size="15" />
|
||||
<span slot="tooltip">{$_('chart.show_slope')}</span>
|
||||
<ToggleGroup.Item class="p-0 w-5 h-5" value="slope" aria-label={$_('chart.show_slope')}>
|
||||
<Tooltip side="left" label={$_('chart.show_slope')}>
|
||||
<TriangleRight size="15" />
|
||||
</Tooltip>
|
||||
</ToggleGroup.Item>
|
||||
<ToggleGroup.Item class="p-0 w-5 h-5" value="surface">
|
||||
<Tooltip side="left">
|
||||
<BrickWall slot="data" size="15" />
|
||||
<span slot="tooltip">{$_('chart.show_surface')}</span>
|
||||
<ToggleGroup.Item class="p-0 w-5 h-5" value="surface" aria-label={$_('chart.show_surface')}>
|
||||
<Tooltip side="left" label={$_('chart.show_surface')}>
|
||||
<BrickWall size="15" />
|
||||
</Tooltip>
|
||||
</ToggleGroup.Item>
|
||||
</ToggleGroup.Root>
|
||||
@@ -644,36 +643,40 @@
|
||||
type="multiple"
|
||||
bind:value={additionalDatasets}
|
||||
>
|
||||
<ToggleGroup.Item class="p-0 w-5 h-5" value="speed">
|
||||
<Tooltip side="left">
|
||||
<Zap slot="data" size="15" />
|
||||
<span slot="tooltip"
|
||||
>{$velocityUnits === 'speed' ? $_('chart.show_speed') : $_('chart.show_pace')}</span
|
||||
>
|
||||
<ToggleGroup.Item
|
||||
class="p-0 w-5 h-5"
|
||||
value="speed"
|
||||
aria-label={$velocityUnits === 'speed' ? $_('chart.show_speed') : $_('chart.show_pace')}
|
||||
>
|
||||
<Tooltip
|
||||
side="left"
|
||||
label={$velocityUnits === 'speed' ? $_('chart.show_speed') : $_('chart.show_pace')}
|
||||
>
|
||||
<Zap size="15" />
|
||||
</Tooltip>
|
||||
</ToggleGroup.Item>
|
||||
<ToggleGroup.Item class="p-0 w-5 h-5" value="hr">
|
||||
<Tooltip side="left">
|
||||
<HeartPulse slot="data" size="15" />
|
||||
<span slot="tooltip">{$_('chart.show_heartrate')}</span>
|
||||
<ToggleGroup.Item class="p-0 w-5 h-5" value="hr" aria-label={$_('chart.show_heartrate')}>
|
||||
<Tooltip side="left" label={$_('chart.show_heartrate')}>
|
||||
<HeartPulse size="15" />
|
||||
</Tooltip>
|
||||
</ToggleGroup.Item>
|
||||
<ToggleGroup.Item class="p-0 w-5 h-5" value="cad">
|
||||
<Tooltip side="left">
|
||||
<Orbit slot="data" size="15" />
|
||||
<span slot="tooltip">{$_('chart.show_cadence')}</span>
|
||||
<ToggleGroup.Item class="p-0 w-5 h-5" value="cad" aria-label={$_('chart.show_cadence')}>
|
||||
<Tooltip side="left" label={$_('chart.show_cadence')}>
|
||||
<Orbit size="15" />
|
||||
</Tooltip>
|
||||
</ToggleGroup.Item>
|
||||
<ToggleGroup.Item class="p-0 w-5 h-5" value="atemp">
|
||||
<Tooltip side="left">
|
||||
<Thermometer slot="data" size="15" />
|
||||
<span slot="tooltip">{$_('chart.show_temperature')}</span>
|
||||
<ToggleGroup.Item
|
||||
class="p-0 w-5 h-5"
|
||||
value="atemp"
|
||||
aria-label={$_('chart.show_temperature')}
|
||||
>
|
||||
<Tooltip side="left" label={$_('chart.show_temperature')}>
|
||||
<Thermometer size="15" />
|
||||
</Tooltip>
|
||||
</ToggleGroup.Item>
|
||||
<ToggleGroup.Item class="p-0 w-5 h-5" value="power">
|
||||
<Tooltip side="left">
|
||||
<SquareActivity slot="data" size="15" />
|
||||
<span slot="tooltip">{$_('chart.show_power')}</span>
|
||||
<ToggleGroup.Item class="p-0 w-5 h-5" value="power" aria-label={$_('chart.show_power')}>
|
||||
<Tooltip side="left" label={$_('chart.show_power')}>
|
||||
<SquareActivity size="15" />
|
||||
</Tooltip>
|
||||
</ToggleGroup.Item>
|
||||
</ToggleGroup.Root>
|
||||
|
@@ -63,6 +63,7 @@
|
||||
}
|
||||
|
||||
hide.time = statistics.global.time.total === 0;
|
||||
hide.surface = !Object.keys(statistics.global.surface).some((key) => key !== 'unknown');
|
||||
hide.hr = statistics.global.hr.count === 0;
|
||||
hide.cad = statistics.global.cad.count === 0;
|
||||
hide.atemp = statistics.global.atemp.count === 0;
|
||||
@@ -86,10 +87,10 @@
|
||||
class="fixed left-[50%] top-[50%] z-50 w-fit max-w-full translate-x-[-50%] translate-y-[-50%] flex flex-col items-center gap-3 border bg-background p-3 shadow-lg rounded-md"
|
||||
>
|
||||
<div
|
||||
class="w-full flex flex-row items-center justify-center gap-4 border rounded-md p-2 bg-accent"
|
||||
class="w-full flex flex-row items-center justify-center gap-4 border rounded-md p-2 bg-secondary"
|
||||
>
|
||||
<span>⚠️</span>
|
||||
<span class="max-w-96 text-sm">
|
||||
<span class="max-w-[80%] text-sm">
|
||||
{$_('menu.support_message')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -119,7 +120,11 @@
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="w-full max-w-xl flex flex-col items-center gap-2">
|
||||
<div
|
||||
class="w-full max-w-xl flex flex-col items-center gap-2 {Object.values(hide).some((v) => !v)
|
||||
? ''
|
||||
: 'hidden'}"
|
||||
>
|
||||
<div class="w-full flex flex-row items-center gap-3">
|
||||
<div class="grow">
|
||||
<Separator />
|
||||
@@ -139,7 +144,7 @@
|
||||
{$_('quantities.time')}
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-1.5">
|
||||
<div class="flex flex-row items-center gap-1.5 {hide.surface ? 'hidden' : ''}">
|
||||
<Checkbox id="export-surface" bind:checked={exportOptions.surface} />
|
||||
<Label for="export-surface" class="flex flex-row items-center gap-1">
|
||||
<BrickWall size="16" />
|
||||
|
@@ -11,7 +11,7 @@
|
||||
<div class="mx-6 border-t">
|
||||
<div class="mx-12 py-10 flex flex-row flex-wrap justify-between gap-x-10 gap-y-6">
|
||||
<div class="grow flex flex-col items-start">
|
||||
<Logo class="h-8" />
|
||||
<Logo class="h-8" width="153" />
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 text-muted-foreground"
|
||||
@@ -52,6 +52,15 @@
|
||||
</div>
|
||||
<div class="flex flex-col items-start gap-1" id="contact">
|
||||
<span class="font-semibold">{$_('homepage.contact')}</span>
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 text-muted-foreground"
|
||||
href="https://www.reddit.com/r/gpxstudio/"
|
||||
target="_blank"
|
||||
>
|
||||
<Logo company="reddit" class="h-4 mr-1 fill-muted-foreground" />
|
||||
{$_('homepage.reddit')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
class="h-6 px-0 text-muted-foreground"
|
||||
|
@@ -36,48 +36,46 @@
|
||||
? 'flex-col justify-center'
|
||||
: 'flex-row w-full justify-between'} gap-4 p-0"
|
||||
>
|
||||
<Tooltip>
|
||||
<span slot="data" class="flex flex-row items-center">
|
||||
<Tooltip label={$_('quantities.distance')}>
|
||||
<span class="flex flex-row items-center">
|
||||
<Ruler size="18" class="mr-1" />
|
||||
<WithUnits value={statistics.global.distance.total} type="distance" />
|
||||
</span>
|
||||
<span slot="tooltip">{$_('quantities.distance')}</span>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<span slot="data" class="flex flex-row items-center">
|
||||
<Tooltip label={$_('quantities.elevation_gain_loss')}>
|
||||
<span class="flex flex-row items-center">
|
||||
<MoveUpRight size="18" class="mr-1" />
|
||||
<WithUnits value={statistics.global.elevation.gain} type="elevation" />
|
||||
<MoveDownRight size="18" class="mx-1" />
|
||||
<WithUnits value={statistics.global.elevation.loss} type="elevation" />
|
||||
</span>
|
||||
<span slot="tooltip">{$_('quantities.elevation')}</span>
|
||||
</Tooltip>
|
||||
{#if panelSize > 120 || orientation === 'horizontal'}
|
||||
<Tooltip class={orientation === 'horizontal' ? 'hidden xs:block' : ''}>
|
||||
<span slot="data" class="flex flex-row items-center">
|
||||
<Tooltip
|
||||
class={orientation === 'horizontal' ? 'hidden xs:block' : ''}
|
||||
label="{$velocityUnits === 'speed' ? $_('quantities.speed') : $_('quantities.pace')} ({$_(
|
||||
'quantities.moving'
|
||||
)} / {$_('quantities.total')})"
|
||||
>
|
||||
<span class="flex flex-row items-center">
|
||||
<Zap size="18" class="mr-1" />
|
||||
<WithUnits value={statistics.global.speed.moving} type="speed" showUnits={false} />
|
||||
<span class="mx-1">/</span>
|
||||
<WithUnits value={statistics.global.speed.total} type="speed" />
|
||||
</span>
|
||||
<span slot="tooltip"
|
||||
>{$velocityUnits === 'speed' ? $_('quantities.speed') : $_('quantities.pace')} ({$_(
|
||||
'quantities.moving'
|
||||
)} / {$_('quantities.total')})</span
|
||||
>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
{#if panelSize > 160 || orientation === 'horizontal'}
|
||||
<Tooltip class={orientation === 'horizontal' ? 'hidden md:block' : ''}>
|
||||
<span slot="data" class="flex flex-row items-center">
|
||||
<Tooltip
|
||||
class={orientation === 'horizontal' ? 'hidden md:block' : ''}
|
||||
label="{$_('quantities.time')} ({$_('quantities.moving')} / {$_('quantities.total')})"
|
||||
>
|
||||
<span class="flex flex-row items-center">
|
||||
<Timer size="18" class="mr-1" />
|
||||
<WithUnits value={statistics.global.time.moving} type="time" />
|
||||
<span class="mx-1">/</span>
|
||||
<WithUnits value={statistics.global.time.total} type="time" />
|
||||
</span>
|
||||
<span slot="tooltip"
|
||||
>{$_('quantities.time')} ({$_('quantities.moving')} / {$_('quantities.total')})</span
|
||||
>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
|
@@ -1,70 +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';
|
||||
|
||||
let location: string;
|
||||
let title: string;
|
||||
|
||||
$: if ($page.route.id) {
|
||||
location = $page.route.id;
|
||||
Object.keys($page.params).forEach((param) => {
|
||||
if (param !== 'language') {
|
||||
location = location.replace(`[${param}]`, $page.params[param]);
|
||||
location = location.replace(`[...${param}]`, $page.params[param]);
|
||||
}
|
||||
});
|
||||
title = location.replace('/[...language]', '').split('/')[1] ?? '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.${title}_title`)}</title>
|
||||
<meta name="description" content={$_('metadata.description')} />
|
||||
<meta property="og:title" content="gpx.studio — {$_(`metadata.${title}_title`)}" />
|
||||
<meta property="og:description" content={$_('metadata.description')} />
|
||||
<meta name="twitter:title" content="gpx.studio — {$_(`metadata.${title}_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.replace('/[...language]', '')}"
|
||||
/>
|
||||
{#each Object.keys(languages) as lang}
|
||||
<link
|
||||
rel="alternate"
|
||||
hreflang={lang}
|
||||
href="https://gpx.studio{base}{location.replace('[...language]', lang)}"
|
||||
/>
|
||||
{/each}
|
||||
</svelte:head>
|
@@ -5,16 +5,14 @@
|
||||
export let link: string | undefined = undefined;
|
||||
</script>
|
||||
|
||||
<div class="text-sm bg-muted rounded border flex flex-row items-center p-2 {$$props.class || ''}">
|
||||
<div
|
||||
class="text-sm bg-secondary rounded border flex flex-row items-center p-2 {$$props.class || ''}"
|
||||
>
|
||||
<CircleHelp size="16" class="w-4 mr-2 shrink-0 grow-0" />
|
||||
<div>
|
||||
<slot />
|
||||
{#if link}
|
||||
<a
|
||||
href={link}
|
||||
target="_blank"
|
||||
class="text-sm text-blue-500 dark:text-blue-300 hover:underline"
|
||||
>
|
||||
<a href={link} target="_blank" class="text-sm text-link hover:underline">
|
||||
{$_('menu.more')}
|
||||
</a>
|
||||
{/if}
|
||||
|
@@ -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';
|
||||
@@ -19,24 +20,32 @@
|
||||
</script>
|
||||
|
||||
<Select.Root bind:selected>
|
||||
<Select.Trigger class="w-[180px] {$$props.class ?? ''}">
|
||||
<Select.Trigger class="w-[180px] {$$props.class ?? ''}" aria-label={$_('menu.language')}>
|
||||
<Languages size="16" />
|
||||
<Select.Value class="ml-2 mr-auto" />
|
||||
</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>
|
||||
|
@@ -60,4 +60,14 @@
|
||||
d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z"
|
||||
/></svg
|
||||
>
|
||||
{:else if company === 'reddit'}
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="fill-foreground {$$restProps.class ?? ''}"
|
||||
><title>Reddit</title><path
|
||||
d="M12 0C5.373 0 0 5.373 0 12c0 3.314 1.343 6.314 3.515 8.485l-2.286 2.286C.775 23.225 1.097 24 1.738 24H12c6.627 0 12-5.373 12-12S18.627 0 12 0Zm4.388 3.199c1.104 0 1.999.895 1.999 1.999 0 1.105-.895 2-1.999 2-.946 0-1.739-.657-1.947-1.539v.002c-1.147.162-2.032 1.15-2.032 2.341v.007c1.776.067 3.4.567 4.686 1.363.473-.363 1.064-.58 1.707-.58 1.547 0 2.802 1.254 2.802 2.802 0 1.117-.655 2.081-1.601 2.531-.088 3.256-3.637 5.876-7.997 5.876-4.361 0-7.905-2.617-7.998-5.87-.954-.447-1.614-1.415-1.614-2.538 0-1.548 1.255-2.802 2.803-2.802.645 0 1.239.218 1.712.585 1.275-.79 2.881-1.291 4.64-1.365v-.01c0-1.663 1.263-3.034 2.88-3.207.188-.911.993-1.595 1.959-1.595Zm-8.085 8.376c-.784 0-1.459.78-1.506 1.797-.047 1.016.64 1.429 1.426 1.429.786 0 1.371-.369 1.418-1.385.047-1.017-.553-1.841-1.338-1.841Zm7.406 0c-.786 0-1.385.824-1.338 1.841.047 1.017.634 1.385 1.418 1.385.785 0 1.473-.413 1.426-1.429-.046-1.017-.721-1.797-1.506-1.797Zm-3.703 4.013c-.974 0-1.907.048-2.77.135-.147.015-.241.168-.183.305.483 1.154 1.622 1.964 2.953 1.964 1.33 0 2.47-.81 2.953-1.964.057-.137-.037-.29-.184-.305-.863-.087-1.795-.135-2.769-.135Z"
|
||||
/></svg
|
||||
>
|
||||
{/if}
|
||||
|
@@ -52,7 +52,37 @@
|
||||
|
||||
let newMap = new mapboxgl.Map({
|
||||
container: 'map',
|
||||
style: { version: 8, sources: {}, layers: [] },
|
||||
style: {
|
||||
version: 8,
|
||||
sources: {},
|
||||
layers: [],
|
||||
imports: [
|
||||
{
|
||||
id: 'glyphs-and-sprite', // make Mapbox glyphs and sprite available to other styles
|
||||
url: '',
|
||||
data: {
|
||||
version: 8,
|
||||
sources: {},
|
||||
layers: [],
|
||||
glyphs: 'mapbox://fonts/mapbox/{fontstack}/{range}.pbf',
|
||||
sprite: `https://api.mapbox.com/styles/v1/mapbox/outdoors-v12/sprite?access_token=${PUBLIC_MAPBOX_TOKEN}`
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'basemap',
|
||||
url: ''
|
||||
},
|
||||
{
|
||||
id: 'overlays',
|
||||
url: '',
|
||||
data: {
|
||||
version: 8,
|
||||
sources: {},
|
||||
layers: []
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
zoom: 0,
|
||||
hash: hash,
|
||||
language,
|
||||
@@ -62,6 +92,7 @@
|
||||
});
|
||||
newMap.on('load', () => {
|
||||
$map = newMap; // only set the store after the map has loaded
|
||||
window._map = newMap; // entry point for extensions
|
||||
scaleControl.setUnit($distanceUnits);
|
||||
});
|
||||
|
||||
@@ -78,15 +109,42 @@
|
||||
);
|
||||
|
||||
if (geocoder) {
|
||||
newMap.addControl(
|
||||
new MapboxGeocoder({
|
||||
accessToken: mapboxgl.accessToken,
|
||||
mapboxgl: mapboxgl,
|
||||
collapsed: true,
|
||||
flyTo: fitBoundsOptions,
|
||||
language
|
||||
})
|
||||
);
|
||||
let geocoder = new MapboxGeocoder({
|
||||
mapboxgl: mapboxgl,
|
||||
enableEventLogging: false,
|
||||
collapsed: true,
|
||||
flyTo: fitBoundsOptions,
|
||||
language,
|
||||
localGeocoder: () => [],
|
||||
localGeocoderOnly: true,
|
||||
externalGeocoder: (query: string) =>
|
||||
fetch(
|
||||
`https://nominatim.openstreetmap.org/search?format=json&q=${query}&limit=5&accept-language=${language}`
|
||||
)
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
return data.map((result: any) => {
|
||||
return {
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [result.lon, result.lat]
|
||||
},
|
||||
place_name: result.display_name
|
||||
};
|
||||
});
|
||||
})
|
||||
});
|
||||
let onKeyDown = geocoder._onKeyDown;
|
||||
geocoder._onKeyDown = (e: KeyboardEvent) => {
|
||||
// Trigger search on Enter key only
|
||||
if (e.key === 'Enter') {
|
||||
onKeyDown.apply(geocoder, [{ target: geocoder._inputEl }]);
|
||||
} else if (geocoder._typeahead.data.length > 0) {
|
||||
geocoder._typeahead.clear();
|
||||
}
|
||||
};
|
||||
newMap.addControl(geocoder);
|
||||
}
|
||||
|
||||
if (geolocate) {
|
||||
@@ -111,10 +169,12 @@
|
||||
tileSize: 512,
|
||||
maxzoom: 14
|
||||
});
|
||||
newMap.setTerrain({
|
||||
source: 'mapbox-dem',
|
||||
exaggeration: newMap.getPitch() > 0 ? 1 : 0
|
||||
});
|
||||
if (newMap.getPitch() > 0) {
|
||||
newMap.setTerrain({
|
||||
source: 'mapbox-dem',
|
||||
exaggeration: 1
|
||||
});
|
||||
}
|
||||
newMap.setFog({
|
||||
color: 'rgb(186, 210, 235)',
|
||||
'high-color': 'rgb(36, 92, 223)',
|
||||
@@ -128,18 +188,7 @@
|
||||
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.setTerrain(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
@@ -21,7 +21,7 @@
|
||||
Thermometer,
|
||||
Sun,
|
||||
Moon,
|
||||
Layers3,
|
||||
Layers,
|
||||
GalleryVertical,
|
||||
Languages,
|
||||
Settings,
|
||||
@@ -41,7 +41,8 @@
|
||||
FileStack,
|
||||
FileX,
|
||||
BookOpenText,
|
||||
ChartArea
|
||||
ChartArea,
|
||||
Maximize
|
||||
} from 'lucide-svelte';
|
||||
|
||||
import {
|
||||
@@ -54,7 +55,8 @@
|
||||
editMetadata,
|
||||
editStyle,
|
||||
exportState,
|
||||
ExportState
|
||||
ExportState,
|
||||
centerMapOnSelection
|
||||
} from '$lib/stores';
|
||||
import {
|
||||
copied,
|
||||
@@ -126,13 +128,13 @@
|
||||
<div
|
||||
class="w-fit flex flex-row items-center justify-center p-1 bg-background rounded-b-md md:rounded-md pointer-events-auto shadow-md"
|
||||
>
|
||||
<a href="./" target="_blank">
|
||||
<Logo class="h-5 mt-0.5 mx-2 md:hidden" iconOnly={true} />
|
||||
<Logo class="h-5 mt-0.5 mx-2 hidden md:block" />
|
||||
<a href="./" target="_blank" class="shrink-0">
|
||||
<Logo class="h-5 mt-0.5 mx-2 md:hidden" iconOnly={true} width="16" />
|
||||
<Logo class="h-5 mt-0.5 mx-2 hidden md:block" width="96" />
|
||||
</a>
|
||||
<Menubar.Root class="border-none h-fit p-0">
|
||||
<Menubar.Menu>
|
||||
<Menubar.Trigger>
|
||||
<Menubar.Trigger aria-label={$_('gpx.file')}>
|
||||
<File size="18" class="md:hidden" />
|
||||
<span class="hidden md:block">{$_('gpx.file')}</span>
|
||||
</Menubar.Trigger>
|
||||
@@ -185,7 +187,7 @@
|
||||
</Menubar.Content>
|
||||
</Menubar.Menu>
|
||||
<Menubar.Menu>
|
||||
<Menubar.Trigger>
|
||||
<Menubar.Trigger aria-label={$_('menu.edit')}>
|
||||
<FilePen size="18" class="md:hidden" />
|
||||
<span class="hidden md:block">{$_('menu.edit')}</span>
|
||||
</Menubar.Trigger>
|
||||
@@ -241,12 +243,47 @@
|
||||
{/if}
|
||||
<Shortcut key="H" ctrl={true} />
|
||||
</Menubar.Item>
|
||||
{#if $verticalFileView}
|
||||
{#if $selection.getSelected().some((item) => item instanceof ListFileItem)}
|
||||
<Menubar.Separator />
|
||||
<Menubar.Item
|
||||
on:click={() => dbUtils.addNewTrack($selection.getSelected()[0].getFileId())}
|
||||
disabled={$selection.size !== 1}
|
||||
>
|
||||
<Plus size="16" class="mr-1" />
|
||||
{$_('menu.new_track')}
|
||||
</Menubar.Item>
|
||||
{:else if $selection.getSelected().some((item) => item instanceof ListTrackItem)}
|
||||
<Menubar.Separator />
|
||||
<Menubar.Item
|
||||
on:click={() => {
|
||||
let item = $selection.getSelected()[0];
|
||||
dbUtils.addNewSegment(item.getFileId(), item.getTrackIndex());
|
||||
}}
|
||||
disabled={$selection.size !== 1}
|
||||
>
|
||||
<Plus size="16" class="mr-1" />
|
||||
{$_('menu.new_segment')}
|
||||
</Menubar.Item>
|
||||
{/if}
|
||||
{/if}
|
||||
<Menubar.Separator />
|
||||
<Menubar.Item on:click={selectAll} disabled={$fileObservers.size == 0}>
|
||||
<FileStack size="16" class="mr-1" />
|
||||
{$_('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}>
|
||||
@@ -280,7 +317,7 @@
|
||||
</Menubar.Content>
|
||||
</Menubar.Menu>
|
||||
<Menubar.Menu>
|
||||
<Menubar.Trigger>
|
||||
<Menubar.Trigger aria-label={$_('menu.view')}>
|
||||
<View size="18" class="md:hidden" />
|
||||
<span class="hidden md:block">{$_('menu.view')}</span>
|
||||
</Menubar.Trigger>
|
||||
@@ -318,14 +355,14 @@
|
||||
</Menubar.Content>
|
||||
</Menubar.Menu>
|
||||
<Menubar.Menu>
|
||||
<Menubar.Trigger>
|
||||
<Menubar.Trigger aria-label={$_('menu.settings')}>
|
||||
<Settings size="18" class="md:hidden" />
|
||||
<span class="hidden md:block">
|
||||
{$_('menu.settings')}
|
||||
</span>
|
||||
</Menubar.Trigger>
|
||||
<Menubar.Content class="border-none"
|
||||
><Menubar.Sub>
|
||||
<Menubar.Content class="border-none">
|
||||
<Menubar.Sub>
|
||||
<Menubar.SubTrigger>
|
||||
<Ruler size="16" class="mr-1" />{$_('menu.distance_units')}
|
||||
</Menubar.SubTrigger>
|
||||
@@ -333,13 +370,14 @@
|
||||
<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>
|
||||
<Menubar.Sub>
|
||||
<Menubar.SubTrigger
|
||||
><Zap size="16" class="mr-1" />{$_('menu.velocity_units')}</Menubar.SubTrigger
|
||||
>
|
||||
<Menubar.SubTrigger>
|
||||
<Zap size="16" class="mr-1" />{$_('menu.velocity_units')}
|
||||
</Menubar.SubTrigger>
|
||||
<Menubar.SubContent>
|
||||
<Menubar.RadioGroup bind:value={$velocityUnits}>
|
||||
<Menubar.RadioItem value="speed">{$_('quantities.speed')}</Menubar.RadioItem>
|
||||
@@ -367,7 +405,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}
|
||||
@@ -409,7 +447,7 @@
|
||||
</Menubar.SubContent>
|
||||
</Menubar.Sub>
|
||||
<Menubar.Item on:click={() => (layerSettingsOpen = true)}>
|
||||
<Layers3 size="16" class="mr-1" />
|
||||
<Layers size="16" class="mr-1" />
|
||||
{$_('menu.layers')}
|
||||
</Menubar.Item>
|
||||
</Menubar.Content>
|
||||
@@ -421,6 +459,7 @@
|
||||
href="./help"
|
||||
target="_blank"
|
||||
class="cursor-default h-fit rounded-sm px-3 py-0.5"
|
||||
aria-label={$_('menu.help')}
|
||||
>
|
||||
<BookOpenText size="18" class="md:hidden" />
|
||||
<span class="hidden md:block">
|
||||
@@ -432,6 +471,7 @@
|
||||
href="https://ko-fi.com/gpxstudio"
|
||||
target="_blank"
|
||||
class="cursor-default h-fit rounded-sm font-bold text-support hover:text-support px-3 py-0.5"
|
||||
aria-label={$_('menu.donate')}
|
||||
>
|
||||
<HeartHandshake size="18" class="md:hidden" />
|
||||
<span class="hidden md:flex flex-row items-center">
|
||||
@@ -498,13 +538,16 @@
|
||||
} else {
|
||||
dbUtils.undo();
|
||||
}
|
||||
} else if ((e.key === 'Backspace' || e.key === 'Delete') && (e.metaKey || e.ctrlKey)) {
|
||||
if (e.shiftKey) {
|
||||
dbUtils.deleteAllFiles();
|
||||
} else {
|
||||
dbUtils.deleteSelection();
|
||||
}
|
||||
e.preventDefault();
|
||||
} else if ((e.key === 'Backspace' || e.key === 'Delete') && (e.metaKey || e.ctrlKey)) {
|
||||
if (!targetInput) {
|
||||
if (e.shiftKey) {
|
||||
dbUtils.deleteAllFiles();
|
||||
} else {
|
||||
dbUtils.deleteSelection();
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
} else if (e.key === 'a' && (e.metaKey || e.ctrlKey)) {
|
||||
if (!targetInput) {
|
||||
selectAll();
|
||||
@@ -533,6 +576,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();
|
||||
|
@@ -15,6 +15,7 @@
|
||||
on:click={() => {
|
||||
setMode(selectedMode === 'light' ? 'dark' : 'light');
|
||||
}}
|
||||
aria-label={$_('menu.mode')}
|
||||
>
|
||||
{#if selectedMode === 'light'}
|
||||
<Sun {size} />
|
||||
|
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import Logo from '$lib/components/Logo.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import AlgoliaDocSearch from '$lib/components/AlgoliaDocSearch.svelte';
|
||||
import ModeSwitch from '$lib/components/ModeSwitch.svelte';
|
||||
import { BookOpenText, Home, Map } from 'lucide-svelte';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
@@ -10,8 +11,8 @@
|
||||
<nav class="w-full sticky top-0 bg-background z-50">
|
||||
<div class="mx-6 py-2 flex flex-row items-center border-b gap-4 sm:gap-8">
|
||||
<a href={getURLForLanguage($locale, '/')} class="shrink-0 translate-y-0.5">
|
||||
<Logo class="h-8 sm:hidden" iconOnly={true} />
|
||||
<Logo class="h-8 hidden sm:block" />
|
||||
<Logo class="h-8 sm:hidden" iconOnly={true} width="26" />
|
||||
<Logo class="h-8 hidden sm:block" width="153" />
|
||||
</a>
|
||||
<Button variant="link" class="text-base px-0" href={getURLForLanguage($locale, '/')}>
|
||||
<Home size="18" class="mr-1.5" />
|
||||
@@ -25,6 +26,7 @@
|
||||
<BookOpenText size="18" class="mr-1.5" />
|
||||
{$_('menu.help')}
|
||||
</Button>
|
||||
<ModeSwitch class="ml-auto" />
|
||||
<AlgoliaDocSearch class="ml-auto" />
|
||||
<ModeSwitch class="hidden xs:block" />
|
||||
</div>
|
||||
</nav>
|
||||
|
@@ -1,26 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { isMac, isSafari } from '$lib/utils';
|
||||
import { onMount } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
export let key: string;
|
||||
export let key: string | undefined = undefined;
|
||||
export let shift: boolean = false;
|
||||
export let ctrl: boolean = false;
|
||||
export let click: boolean = false;
|
||||
|
||||
let isMac = false;
|
||||
let isSafari = false;
|
||||
let mac = false;
|
||||
let safari = false;
|
||||
|
||||
onMount(() => {
|
||||
isMac = navigator.userAgent.toUpperCase().indexOf('MAC') >= 0;
|
||||
isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
||||
mac = isMac();
|
||||
safari = isSafari();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="ml-auto pl-2 text-xs tracking-widest text-muted-foreground flex flex-row gap-0 items-baseline"
|
||||
{...$$props}
|
||||
>
|
||||
<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>
|
||||
{#if shift}
|
||||
<span>⇧</span>
|
||||
{/if}
|
||||
{#if ctrl}
|
||||
<span>{mac && !safari ? '⌘' : $_('menu.ctrl') + '+'}</span>
|
||||
{/if}
|
||||
{#if key}
|
||||
<span class={key === '+' ? 'font-medium text-sm/4' : ''}>{key}</span>
|
||||
{/if}
|
||||
{#if click}
|
||||
<span>{$_('menu.click')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
@@ -1,14 +1,18 @@
|
||||
<script lang="ts">
|
||||
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
||||
|
||||
export let label: string;
|
||||
export let side: 'top' | 'right' | 'bottom' | 'left' = 'top';
|
||||
</script>
|
||||
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger {...$$restProps}>
|
||||
<slot name="data" />
|
||||
<Tooltip.Trigger {...$$restProps} aria-label={label}>
|
||||
<slot />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content {side}>
|
||||
<slot name="tooltip" />
|
||||
<div class="flex flex-row items-center">
|
||||
<span>{label}</span>
|
||||
<slot name="extra" />
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
|
@@ -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,42 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
import { base } from '$app/paths';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
export let path: string;
|
||||
export let titleOnly: boolean = false;
|
||||
|
||||
let module = undefined;
|
||||
let metadata: Record<string, any> = {};
|
||||
|
||||
const modules = import.meta.glob('/src/lib/docs/**/*.mdx');
|
||||
|
||||
function loadModule(path: string) {
|
||||
modules[path]?.().then((mod) => {
|
||||
module = mod.default;
|
||||
metadata = mod.metadata;
|
||||
});
|
||||
}
|
||||
|
||||
$: if ($locale) {
|
||||
if (modules.hasOwnProperty(`/src/lib/docs/${$locale}/${path}`)) {
|
||||
loadModule(`/src/lib/docs/${$locale}/${path}`);
|
||||
} else if (browser) {
|
||||
goto(`${base}/404`);
|
||||
}
|
||||
}
|
||||
export let module;
|
||||
</script>
|
||||
|
||||
{#if module !== undefined}
|
||||
{#if titleOnly}
|
||||
{metadata.title}
|
||||
{:else}
|
||||
<div class="markdown flex flex-col gap-3">
|
||||
<svelte:component this={module} />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
<div class="markdown flex flex-col gap-3">
|
||||
<svelte:component this={module} />
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
:global(.markdown) {
|
||||
@@ -64,24 +34,24 @@
|
||||
@apply pt-1.5;
|
||||
}
|
||||
|
||||
:global(.markdown p > button) {
|
||||
:global(.markdown p > button, .markdown li > button) {
|
||||
@apply border;
|
||||
@apply rounded-md;
|
||||
@apply px-1;
|
||||
}
|
||||
|
||||
:global(.markdown > a) {
|
||||
@apply text-blue-500;
|
||||
@apply text-link;
|
||||
@apply hover:underline;
|
||||
}
|
||||
|
||||
:global(.markdown p > a) {
|
||||
@apply text-blue-500;
|
||||
@apply text-link;
|
||||
@apply hover:underline;
|
||||
}
|
||||
|
||||
:global(.markdown li > a) {
|
||||
@apply text-blue-500;
|
||||
@apply text-link;
|
||||
@apply hover:underline;
|
||||
}
|
||||
|
@@ -1,11 +1,25 @@
|
||||
<script lang="ts">
|
||||
export let src;
|
||||
export let src: 'getting-started/interface' | 'tools/routing' | 'tools/split';
|
||||
export let alt: string;
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col items-center py-6 w-full">
|
||||
<div class="rounded-md overflow-clip shadow-xl mx-auto">
|
||||
<enhanced:img {src} {alt} class="w-full max-w-3xl" />
|
||||
<div class="rounded-md overflow-hidden overflow-clip shadow-xl mx-auto">
|
||||
{#if src === 'getting-started/interface'}
|
||||
<enhanced:img
|
||||
src="/src/lib/assets/img/docs/getting-started/interface.png"
|
||||
{alt}
|
||||
class="w-full max-w-3xl"
|
||||
/>
|
||||
{:else if src === 'tools/routing'}
|
||||
<enhanced:img
|
||||
src="/src/lib/assets/img/docs/tools/routing.png"
|
||||
{alt}
|
||||
class="w-full max-w-3xl"
|
||||
/>
|
||||
{:else if src === 'tools/split'}
|
||||
<enhanced:img src="/src/lib/assets/img/docs/tools/split.png" {alt} class="w-full max-w-3xl" />
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-center text-sm text-muted-foreground mt-2">{alt}</p>
|
||||
</div>
|
||||
|
@@ -3,8 +3,8 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="bg-accent border-l-8 {type === 'note'
|
||||
? 'border-blue-500'
|
||||
class="bg-secondary border-l-8 {type === 'note'
|
||||
? 'border-link'
|
||||
: 'border-destructive'} p-2 text-sm rounded-md"
|
||||
>
|
||||
<slot />
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
<style lang="postcss">
|
||||
div :global(a) {
|
||||
@apply text-blue-500;
|
||||
@apply text-link;
|
||||
@apply hover:underline;
|
||||
}
|
||||
</style>
|
||||
|
@@ -1,14 +1,15 @@
|
||||
import { File, FilePen, View, type Icon, Settings, Pencil, MapPin, Scissors, CalendarClock, Group, Ungroup, Filter, SquareDashedMousePointer } from "lucide-svelte";
|
||||
import { File, FilePen, View, type Icon, Settings, Pencil, MapPin, Scissors, CalendarClock, Group, Ungroup, Filter, SquareDashedMousePointer, MountainSnow } from "lucide-svelte";
|
||||
import type { ComponentType } from "svelte";
|
||||
|
||||
export const guides: Record<string, string[]> = {
|
||||
'getting-started': [],
|
||||
menu: ['file', 'edit', 'view', 'settings'],
|
||||
'files-and-stats': [],
|
||||
toolbar: ['routing', 'poi', 'scissors', 'time', 'merge', 'extract', 'minify', 'clean'],
|
||||
toolbar: ['routing', 'poi', 'scissors', 'time', 'merge', 'extract', 'elevation', 'minify', 'clean'],
|
||||
'map-controls': [],
|
||||
'gpx': [],
|
||||
'integration': [],
|
||||
'faq': [],
|
||||
};
|
||||
|
||||
export const guideIcons: Record<string, string | ComponentType<Icon>> = {
|
||||
@@ -26,11 +27,13 @@ export const guideIcons: Record<string, string | ComponentType<Icon>> = {
|
||||
"time": CalendarClock,
|
||||
"merge": Group,
|
||||
"extract": Ungroup,
|
||||
"elevation": MountainSnow,
|
||||
"minify": Filter,
|
||||
"clean": SquareDashedMousePointer,
|
||||
"map-controls": "🗺",
|
||||
"gpx": "💾",
|
||||
"integration": "{ 👩💻 }",
|
||||
"faq": "🔮",
|
||||
};
|
||||
|
||||
export function getPreviousGuide(currentGuide: string): string | undefined {
|
||||
|
@@ -20,8 +20,13 @@
|
||||
import type { GPXFile } from 'gpx';
|
||||
import { selection } from '$lib/components/file-list/Selection';
|
||||
import { ListFileItem } from '$lib/components/file-list/FileList';
|
||||
import { allowedEmbeddingBasemaps, type EmbeddingOptions } from './Embedding';
|
||||
import {
|
||||
allowedEmbeddingBasemaps,
|
||||
getFilesFromEmbeddingOptions,
|
||||
type EmbeddingOptions
|
||||
} from './Embedding';
|
||||
import { mode, setMode } from 'mode-watcher';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
$embedding = true;
|
||||
|
||||
@@ -55,7 +60,7 @@
|
||||
});
|
||||
|
||||
let downloads: Promise<GPXFile | null>[] = [];
|
||||
options.files.forEach((url) => {
|
||||
getFilesFromEmbeddingOptions(options).forEach((url) => {
|
||||
downloads.push(
|
||||
fetch(url)
|
||||
.then((response) => response.blob())
|
||||
@@ -176,7 +181,7 @@
|
||||
prevSettings.theme = $mode ?? 'system';
|
||||
});
|
||||
|
||||
$: if (options) {
|
||||
$: if (browser && options) {
|
||||
applyOptions();
|
||||
}
|
||||
|
||||
@@ -224,7 +229,7 @@
|
||||
geolocate={false}
|
||||
hash={useHash}
|
||||
/>
|
||||
<OpenIn bind:files={options.files} />
|
||||
<OpenIn bind:files={options.files} bind:ids={options.ids} />
|
||||
<LayerControl />
|
||||
<GPXLayers />
|
||||
{#if $fileObservers.size > 1}
|
||||
|
@@ -1,31 +1,34 @@
|
||||
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[];
|
||||
ids: 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',
|
||||
theme: 'system' | 'light' | 'dark',
|
||||
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: [],
|
||||
ids: [],
|
||||
basemap: 'mapboxOutdoors',
|
||||
elevation: {
|
||||
show: true,
|
||||
@@ -36,21 +39,24 @@ export const defaultEmbeddingOptions = {
|
||||
hr: false,
|
||||
cad: false,
|
||||
temp: false,
|
||||
power: false,
|
||||
power: false
|
||||
},
|
||||
distanceMarkers: false,
|
||||
directionMarkers: false,
|
||||
distanceUnits: 'metric',
|
||||
velocityUnits: 'speed',
|
||||
temperatureUnits: 'celsius',
|
||||
theme: 'system',
|
||||
theme: 'system'
|
||||
};
|
||||
|
||||
export function getDefaultEmbeddingOptions(): EmbeddingOptions {
|
||||
return JSON.parse(JSON.stringify(defaultEmbeddingOptions));
|
||||
}
|
||||
|
||||
export function getMergedEmbeddingOptions(options: any, defaultOptions: any = defaultEmbeddingOptions): EmbeddingOptions {
|
||||
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])) {
|
||||
@@ -62,10 +68,17 @@ export function getMergedEmbeddingOptions(options: any, defaultOptions: any = de
|
||||
return mergedOptions;
|
||||
}
|
||||
|
||||
export function getCleanedEmbeddingOptions(options: any, defaultOptions: any = defaultEmbeddingOptions): any {
|
||||
export function getCleanedEmbeddingOptions(
|
||||
options: any,
|
||||
defaultOptions: any = defaultEmbeddingOptions
|
||||
): any {
|
||||
const cleanedOptions = JSON.parse(JSON.stringify(options));
|
||||
for (const key in cleanedOptions) {
|
||||
if (typeof cleanedOptions[key] === 'object' && cleanedOptions[key] !== null && !Array.isArray(cleanedOptions[key])) {
|
||||
if (
|
||||
typeof cleanedOptions[key] === 'object' &&
|
||||
cleanedOptions[key] !== null &&
|
||||
!Array.isArray(cleanedOptions[key])
|
||||
) {
|
||||
cleanedOptions[key] = getCleanedEmbeddingOptions(cleanedOptions[key], defaultOptions[key]);
|
||||
if (Object.keys(cleanedOptions[key]).length === 0) {
|
||||
delete cleanedOptions[key];
|
||||
@@ -77,4 +90,59 @@ export function getCleanedEmbeddingOptions(options: any, defaultOptions: any = d
|
||||
return cleanedOptions;
|
||||
}
|
||||
|
||||
export const allowedEmbeddingBasemaps = Object.keys(basemaps).filter(basemap => !['ordnanceSurvey'].includes(basemap));
|
||||
export const allowedEmbeddingBasemaps = Object.keys(basemaps).filter(
|
||||
(basemap) => !['ordnanceSurvey'].includes(basemap)
|
||||
);
|
||||
|
||||
export function getFilesFromEmbeddingOptions(options: EmbeddingOptions): string[] {
|
||||
return options.files.concat(options.ids.map((id) => getURLForGoogleDriveFile(id)));
|
||||
}
|
||||
|
||||
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: [],
|
||||
ids: [],
|
||||
};
|
||||
if (options.has('state')) {
|
||||
let state = JSON.parse(options.get('state')!);
|
||||
if (state.ids) {
|
||||
newOptions.ids.push(...state.ids);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
@@ -34,13 +34,21 @@
|
||||
];
|
||||
|
||||
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 driveIds = '';
|
||||
$: {
|
||||
let ids = driveIds.split(',');
|
||||
ids = ids.filter((id) => id.length > 0);
|
||||
if (JSON.stringify(ids) !== JSON.stringify(options.ids)) {
|
||||
options.ids = ids;
|
||||
}
|
||||
}
|
||||
|
||||
let manualCamera = false;
|
||||
|
||||
@@ -84,7 +92,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Root id="embedding-playground">
|
||||
<Card.Header>
|
||||
<Card.Title>{$_('embedding.title')}</Card.Title>
|
||||
</Card.Header>
|
||||
@@ -94,6 +102,8 @@
|
||||
<Input id="token" type="text" class="h-8" bind:value={options.token} />
|
||||
<Label for="file_urls">{$_('embedding.file_urls')}</Label>
|
||||
<Input id="file_urls" type="text" class="h-8" bind:value={files} />
|
||||
<Label for="drive_ids">{$_('embedding.drive_ids')}</Label>
|
||||
<Input id="drive_ids" type="text" class="h-8" bind:value={driveIds} />
|
||||
<Label for="basemap">{$_('embedding.basemap')}</Label>
|
||||
<Select.Root
|
||||
selected={{ value: options.basemap, label: $_(`layers.label.${options.basemap}`) }}
|
||||
@@ -214,6 +224,10 @@
|
||||
<RadioGroup.Item value="imperial" id="imperial" />
|
||||
<Label for="imperial">{$_('menu.imperial')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="nautical" id="nautical" />
|
||||
<Label for="nautical">{$_('menu.nautical')}</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</Label>
|
||||
<Label class="flex flex-col items-start gap-2">
|
||||
|
@@ -1,18 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import Logo from '$lib/components/Logo.svelte';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import Logo from '$lib/components/Logo.svelte';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
|
||||
export let files: string[];
|
||||
export let files: string[];
|
||||
export let ids: string[];
|
||||
</script>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="absolute top-0 flex-wrap h-fit bg-background font-semibold rounded-md py-1 px-2 gap-1.5 xs:text-base mt-2.5 ml-2.5 mr-12"
|
||||
href="{getURLForLanguage($locale, '/app')}?files={encodeURIComponent(JSON.stringify(files))}"
|
||||
target="_blank"
|
||||
variant="ghost"
|
||||
class="absolute top-0 flex-wrap h-fit bg-background font-semibold rounded-md py-1 px-2 gap-1.5 xs:text-base mt-2.5 ml-2.5 mr-12"
|
||||
href="{getURLForLanguage($locale, '/app')}?{files.length > 0
|
||||
? `files=${encodeURIComponent(JSON.stringify(files))}`
|
||||
: ''}{files.length > 0 && ids.length > 0 ? '&' : ''}{ids.length > 0
|
||||
? `ids=${encodeURIComponent(JSON.stringify(ids))}`
|
||||
: ''}"
|
||||
target="_blank"
|
||||
>
|
||||
{$_('menu.open_in')}
|
||||
<Logo class="h-[18px] xs:h-5 translate-y-[1px]" />
|
||||
{$_('menu.open_in')}
|
||||
<Logo class="h-[18px] xs:h-5 translate-y-[1px]" />
|
||||
</Button>
|
||||
|
@@ -5,10 +5,17 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { GPXFile, Track, Waypoint, type AnyGPXTreeElement, type GPXTreeElement } from 'gpx';
|
||||
import {
|
||||
buildGPX,
|
||||
GPXFile,
|
||||
Track,
|
||||
Waypoint,
|
||||
type AnyGPXTreeElement,
|
||||
type GPXTreeElement
|
||||
} from 'gpx';
|
||||
import { afterUpdate, getContext, onDestroy, onMount } from 'svelte';
|
||||
import Sortable from 'sortablejs/Sortable';
|
||||
import { getFileIds, settings, type GPXFileWithStatistics } from '$lib/db';
|
||||
import { getFile, getFileIds, settings, type GPXFileWithStatistics } from '$lib/db';
|
||||
import { get, writable, type Readable, type Writable } from 'svelte/store';
|
||||
import FileListNodeStore from './FileListNodeStore.svelte';
|
||||
import FileListNode from './FileListNode.svelte';
|
||||
@@ -22,6 +29,7 @@
|
||||
type ListItem
|
||||
} from './FileList';
|
||||
import { selection } from './Selection';
|
||||
import { isMac } from '$lib/utils';
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
export let node:
|
||||
@@ -154,7 +162,7 @@
|
||||
direction: orientation,
|
||||
forceAutoScrollFallback: true,
|
||||
multiDrag: true,
|
||||
multiDragKey: 'Meta',
|
||||
multiDragKey: isMac() ? 'Meta' : 'Ctrl',
|
||||
avoidImplicitDeselect: true,
|
||||
onSelect: updateToSelection,
|
||||
onDeselect: updateToSelection,
|
||||
@@ -223,6 +231,22 @@
|
||||
|
||||
moveItems(fromItem, toItem, fromItems, toItems);
|
||||
}
|
||||
},
|
||||
setData: function (dataTransfer: DataTransfer, dragEl: HTMLElement) {
|
||||
if (sortableLevel === ListLevel.FILE) {
|
||||
const fileId = dragEl.getAttribute('data-id');
|
||||
const file = fileId ? getFile(fileId) : null;
|
||||
|
||||
if (file) {
|
||||
const data = buildGPX(file);
|
||||
dataTransfer.setData(
|
||||
'DownloadURL',
|
||||
`application/gpx+xml:${file.metadata.name}.gpx:data:text/octet-stream;charset=utf-8,${encodeURIComponent(data)}`
|
||||
);
|
||||
dataTransfer.dropEffect = 'copy';
|
||||
dataTransfer.effectAllowed = 'copy';
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Object.defineProperty(sortable, '_item', {
|
||||
|
@@ -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,
|
||||
@@ -239,10 +248,7 @@
|
||||
{#if item instanceof ListFileItem}
|
||||
<ContextMenu.Item
|
||||
disabled={!singleSelection}
|
||||
on:click={() =>
|
||||
dbUtils.applyToFile(item.getFileId(), (file) =>
|
||||
file.replaceTracks(file.trk.length, file.trk.length, [new Track()])
|
||||
)}
|
||||
on:click={() => dbUtils.addNewTrack(item.getFileId())}
|
||||
>
|
||||
<Plus size="16" class="mr-1" />
|
||||
{$_('menu.new_track')}
|
||||
@@ -251,17 +257,7 @@
|
||||
{:else if item instanceof ListTrackItem}
|
||||
<ContextMenu.Item
|
||||
disabled={!singleSelection}
|
||||
on:click={() => {
|
||||
let trackIndex = item.getTrackIndex();
|
||||
dbUtils.applyToFile(item.getFileId(), (file) =>
|
||||
file.replaceTrackSegments(
|
||||
trackIndex,
|
||||
file.trk[trackIndex].trkseg.length,
|
||||
file.trk[trackIndex].trkseg.length,
|
||||
[new TrackSegment()]
|
||||
)
|
||||
);
|
||||
}}
|
||||
on:click={() => dbUtils.addNewSegment(item.getFileId(), item.getTrackIndex())}
|
||||
>
|
||||
<Plus size="16" class="mr-1" />
|
||||
{$_('menu.new_segment')}
|
||||
@@ -275,38 +271,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,62 +1,65 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Textarea } from '$lib/components/ui/textarea';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import { dbUtils } from '$lib/db';
|
||||
import { Save } from 'lucide-svelte';
|
||||
import { ListFileItem, ListTrackItem, type ListItem } from './FileList';
|
||||
import { GPXTreeElement, Track, type AnyGPXTreeElement, Waypoint, GPXFile } from 'gpx';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { editMetadata } from '$lib/stores';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Textarea } from '$lib/components/ui/textarea';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import { dbUtils } from '$lib/db';
|
||||
import { Save } from 'lucide-svelte';
|
||||
import { ListFileItem, ListTrackItem, type ListItem } from './FileList';
|
||||
import { GPXTreeElement, Track, type AnyGPXTreeElement, Waypoint, GPXFile } from 'gpx';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { editMetadata } from '$lib/stores';
|
||||
|
||||
export let node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint;
|
||||
export let item: ListItem;
|
||||
export let open = false;
|
||||
export let node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint;
|
||||
export let item: ListItem;
|
||||
export let open = false;
|
||||
|
||||
let name: string =
|
||||
node instanceof GPXFile
|
||||
? node.metadata.name ?? ''
|
||||
: node instanceof Track
|
||||
? node.name ?? ''
|
||||
: '';
|
||||
let description: string =
|
||||
node instanceof GPXFile
|
||||
? node.metadata.desc ?? ''
|
||||
: node instanceof Track
|
||||
? node.desc ?? ''
|
||||
: '';
|
||||
let name: string =
|
||||
node instanceof GPXFile
|
||||
? node.metadata.name ?? ''
|
||||
: node instanceof Track
|
||||
? node.name ?? ''
|
||||
: '';
|
||||
let description: string =
|
||||
node instanceof GPXFile
|
||||
? node.metadata.desc ?? ''
|
||||
: node instanceof Track
|
||||
? node.desc ?? ''
|
||||
: '';
|
||||
|
||||
$: if (!open) {
|
||||
$editMetadata = false;
|
||||
}
|
||||
$: if (!open) {
|
||||
$editMetadata = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popover.Root bind:open>
|
||||
<Popover.Trigger />
|
||||
<Popover.Content side="top" sideOffset={22} alignOffset={30} class="flex flex-col gap-3">
|
||||
<Label for="name">{$_('menu.metadata.name')}</Label>
|
||||
<Input bind:value={name} id="name" class="font-semibold h-8" />
|
||||
<Label for="description">{$_('menu.metadata.description')}</Label>
|
||||
<Textarea bind:value={description} id="description" />
|
||||
<Button
|
||||
variant="outline"
|
||||
on:click={() => {
|
||||
dbUtils.applyToFile(item.getFileId(), (file) => {
|
||||
if (item instanceof ListFileItem && node instanceof GPXFile) {
|
||||
file.metadata.name = name;
|
||||
file.metadata.desc = description;
|
||||
} else if (item instanceof ListTrackItem && node instanceof Track) {
|
||||
file.trk[item.getTrackIndex()].name = name;
|
||||
file.trk[item.getTrackIndex()].desc = description;
|
||||
}
|
||||
});
|
||||
open = false;
|
||||
}}
|
||||
>
|
||||
<Save size="16" class="mr-1" />
|
||||
{$_('menu.metadata.save')}
|
||||
</Button>
|
||||
</Popover.Content>
|
||||
<Popover.Trigger />
|
||||
<Popover.Content side="top" sideOffset={22} alignOffset={30} class="flex flex-col gap-3">
|
||||
<Label for="name">{$_('menu.metadata.name')}</Label>
|
||||
<Input bind:value={name} id="name" class="font-semibold h-8" />
|
||||
<Label for="description">{$_('menu.metadata.description')}</Label>
|
||||
<Textarea bind:value={description} id="description" />
|
||||
<Button
|
||||
variant="outline"
|
||||
on:click={() => {
|
||||
dbUtils.applyToFile(item.getFileId(), (file) => {
|
||||
if (item instanceof ListFileItem && node instanceof GPXFile) {
|
||||
file.metadata.name = name;
|
||||
file.metadata.desc = description;
|
||||
if (file.trk.length === 1) {
|
||||
file.trk[0].name = name;
|
||||
}
|
||||
} else if (item instanceof ListTrackItem && node instanceof Track) {
|
||||
file.trk[item.getTrackIndex()].name = name;
|
||||
file.trk[item.getTrackIndex()].desc = description;
|
||||
}
|
||||
});
|
||||
open = false;
|
||||
}}
|
||||
>
|
||||
<Save size="16" class="mr-1" />
|
||||
{$_('menu.metadata.save')}
|
||||
</Button>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
|
@@ -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: {
|
||||
|
@@ -7,7 +7,6 @@ import { addSelectItem, selectItem, selection } from "$lib/components/file-list/
|
||||
import { ListTrackSegmentItem, ListWaypointItem, ListWaypointsItem, ListTrackItem, ListFileItem, ListRootItem } from "$lib/components/file-list/FileList";
|
||||
import type { Waypoint } from "gpx";
|
||||
import { getElevation, resetCursor, setGrabbingCursor, setPointerCursor, setScissorsCursor } from "$lib/utils";
|
||||
import { font } from "$lib/assets/layers";
|
||||
import { selectedWaypoint } from "$lib/components/toolbar/tools/Waypoint.svelte";
|
||||
import { MapPin, Square } from "lucide-static";
|
||||
import { getSymbolKey, symbols } from "$lib/assets/symbols";
|
||||
@@ -66,7 +65,7 @@ function getMarkerForSymbol(symbol: string | undefined, layerColor: string) {
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
const { directionMarkers, verticalFileView, currentBasemap, defaultOpacity, defaultWeight } = settings;
|
||||
const { directionMarkers, verticalFileView, defaultOpacity, defaultWeight } = settings;
|
||||
|
||||
export class GPXLayer {
|
||||
map: mapboxgl.Map;
|
||||
@@ -82,6 +81,7 @@ export class GPXLayer {
|
||||
layerOnMouseEnterBinded: (e: any) => void = this.layerOnMouseEnter.bind(this);
|
||||
layerOnMouseLeaveBinded: () => void = this.layerOnMouseLeave.bind(this);
|
||||
layerOnClickBinded: (e: any) => void = this.layerOnClick.bind(this);
|
||||
layerOnContextMenuBinded: (e: any) => void = this.layerOnContextMenu.bind(this);
|
||||
maybeHideWaypointPopupBinded: (e: any) => void = this.maybeHideWaypointPopup.bind(this);
|
||||
|
||||
constructor(map: mapboxgl.Map, fileId: string, file: Readable<GPXFileWithStatistics | undefined>) {
|
||||
@@ -112,7 +112,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() {
|
||||
@@ -154,6 +154,7 @@ export class GPXLayer {
|
||||
});
|
||||
|
||||
this.map.on('click', this.fileId, this.layerOnClickBinded);
|
||||
this.map.on('contextmenu', this.fileId, this.layerOnContextMenuBinded);
|
||||
this.map.on('mouseenter', this.fileId, this.layerOnMouseEnterBinded);
|
||||
this.map.on('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
|
||||
}
|
||||
@@ -170,7 +171,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,
|
||||
},
|
||||
@@ -262,14 +263,16 @@ export class GPXLayer {
|
||||
marker.on('dragend', (e) => {
|
||||
resetCursor();
|
||||
marker.getElement().style.cursor = '';
|
||||
dbUtils.applyToFile(this.fileId, (file) => {
|
||||
let latLng = marker.getLngLat();
|
||||
let wpt = file.wpt[marker._waypoint._data.index];
|
||||
wpt.setCoordinates({
|
||||
lat: latLng.lat,
|
||||
lon: latLng.lng
|
||||
getElevation([marker._waypoint]).then((ele) => {
|
||||
dbUtils.applyToFile(this.fileId, (file) => {
|
||||
let latLng = marker.getLngLat();
|
||||
let wpt = file.wpt[marker._waypoint._data.index];
|
||||
wpt.setCoordinates({
|
||||
lat: latLng.lat,
|
||||
lon: latLng.lng
|
||||
});
|
||||
wpt.ele = ele[0];
|
||||
});
|
||||
wpt.ele = getElevation(this.map, wpt.getCoordinates());
|
||||
});
|
||||
dragEndTimestamp = Date.now()
|
||||
});
|
||||
@@ -294,16 +297,17 @@ 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();
|
||||
}
|
||||
|
||||
remove() {
|
||||
if (get(map)) {
|
||||
this.map.off('click', this.fileId, this.layerOnClickBinded);
|
||||
this.map.off('contextmenu', this.fileId, this.layerOnContextMenuBinded);
|
||||
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');
|
||||
@@ -381,6 +385,12 @@ export class GPXLayer {
|
||||
}
|
||||
}
|
||||
|
||||
layerOnContextMenu(e: any) {
|
||||
if (e.originalEvent.ctrlKey) {
|
||||
this.layerOnClick(e);
|
||||
}
|
||||
}
|
||||
|
||||
showWaypointPopup(waypoint: Waypoint) {
|
||||
if (get(currentPopupWaypoint) !== null) {
|
||||
this.hideWaypointPopup();
|
||||
|
@@ -24,13 +24,13 @@
|
||||
if (text === undefined) {
|
||||
return '';
|
||||
}
|
||||
let sanitized = sanitizeHtml(text, {
|
||||
allowedTags: ['a', 'br'],
|
||||
return sanitizeHtml(text, {
|
||||
allowedTags: ['a', 'br', 'img'],
|
||||
allowedAttributes: {
|
||||
a: ['href', 'target']
|
||||
a: ['href', 'target'],
|
||||
img: ['src']
|
||||
}
|
||||
}).trim();
|
||||
return sanitized;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
>
|
||||
<Trash2 size="16" class="mr-1" />
|
||||
{$_('menu.delete')}
|
||||
<Shortcut key="" shift={true} click={true} />
|
||||
<Shortcut shift={true} click={true} />
|
||||
</Button>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
@@ -99,7 +99,12 @@
|
||||
|
||||
<style lang="postcss">
|
||||
div :global(a) {
|
||||
@apply text-blue-500 dark:text-blue-300;
|
||||
@apply text-link;
|
||||
@apply hover:underline;
|
||||
}
|
||||
|
||||
div :global(img) {
|
||||
@apply my-0;
|
||||
@apply rounded-md;
|
||||
}
|
||||
</style>
|
||||
|
@@ -1,417 +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 { 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, extendBasemap, type CustomLayer } from '$lib/assets/layers';
|
||||
import { map } from '$lib/stores';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import Sortable from 'sortablejs/Sortable';
|
||||
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,
|
||||
customBasemapOrder,
|
||||
customOverlayOrder
|
||||
} = 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';
|
||||
|
||||
let basemapContainer: HTMLElement;
|
||||
let overlayContainer: HTMLElement;
|
||||
let basemapContainer: HTMLElement;
|
||||
let overlayContainer: HTMLElement;
|
||||
|
||||
let basemapSortable: Sortable;
|
||||
let overlaySortable: Sortable;
|
||||
let basemapSortable: Sortable;
|
||||
let overlaySortable: Sortable;
|
||||
|
||||
onMount(() => {
|
||||
if ($customBasemapOrder.length === 0) {
|
||||
$customBasemapOrder = Object.keys($customLayers).filter(
|
||||
(id) => $customLayers[id].layerType === 'basemap'
|
||||
);
|
||||
}
|
||||
if ($customOverlayOrder.length === 0) {
|
||||
$customOverlayOrder = Object.keys($customLayers).filter(
|
||||
(id) => $customLayers[id].layerType === 'overlay'
|
||||
);
|
||||
}
|
||||
onMount(() => {
|
||||
if ($customBasemapOrder.length === 0) {
|
||||
$customBasemapOrder = Object.keys($customLayers).filter(
|
||||
(id) => $customLayers[id].layerType === 'basemap'
|
||||
);
|
||||
}
|
||||
if ($customOverlayOrder.length === 0) {
|
||||
$customOverlayOrder = Object.keys($customLayers).filter(
|
||||
(id) => $customLayers[id].layerType === 'overlay'
|
||||
);
|
||||
}
|
||||
|
||||
basemapSortable = Sortable.create(basemapContainer, {
|
||||
onSort: (e) => {
|
||||
$customBasemapOrder = basemapSortable.toArray();
|
||||
$selectedBasemapTree.basemaps['custom'] = $customBasemapOrder.reduce((acc, id) => {
|
||||
acc[id] = true;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
});
|
||||
overlaySortable = Sortable.create(overlayContainer, {
|
||||
onSort: (e) => {
|
||||
$customOverlayOrder = overlaySortable.toArray();
|
||||
$selectedOverlayTree.overlays['custom'] = $customOverlayOrder.reduce((acc, id) => {
|
||||
acc[id] = true;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
});
|
||||
basemapSortable = Sortable.create(basemapContainer, {
|
||||
onSort: (e) => {
|
||||
$customBasemapOrder = basemapSortable.toArray();
|
||||
$selectedBasemapTree.basemaps['custom'] = $customBasemapOrder.reduce((acc, id) => {
|
||||
acc[id] = true;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
});
|
||||
overlaySortable = Sortable.create(overlayContainer, {
|
||||
onSort: (e) => {
|
||||
$customOverlayOrder = overlaySortable.toArray();
|
||||
$selectedOverlayTree.overlays['custom'] = $customOverlayOrder.reduce((acc, id) => {
|
||||
acc[id] = true;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
});
|
||||
|
||||
basemapSortable.sort($customBasemapOrder);
|
||||
overlaySortable.sort($customOverlayOrder);
|
||||
});
|
||||
basemapSortable.sort($customBasemapOrder);
|
||||
overlaySortable.sort($customOverlayOrder);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
basemapSortable.destroy();
|
||||
overlaySortable.destroy();
|
||||
});
|
||||
onDestroy(() => {
|
||||
basemapSortable.destroy();
|
||||
overlaySortable.destroy();
|
||||
});
|
||||
|
||||
$: if (tileUrls[0].length > 0) {
|
||||
if (
|
||||
tileUrls[0].includes('.json') ||
|
||||
(tileUrls[0].includes('api.mapbox.com/styles') && !tileUrls[0].includes('tiles'))
|
||||
) {
|
||||
resourceType = 'vector';
|
||||
layerType = 'basemap';
|
||||
} else {
|
||||
resourceType = 'raster';
|
||||
}
|
||||
}
|
||||
$: 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 createLayer() {
|
||||
if (selectedLayerId && $customLayers[selectedLayerId].layerType !== layerType) {
|
||||
deleteLayer(selectedLayerId);
|
||||
}
|
||||
function createLayer() {
|
||||
if (selectedLayerId && $customLayers[selectedLayerId].layerType !== layerType) {
|
||||
deleteLayer(selectedLayerId);
|
||||
}
|
||||
|
||||
if (typeof maxZoom === 'string') {
|
||||
maxZoom = parseInt(maxZoom);
|
||||
}
|
||||
if (typeof maxZoom === 'string') {
|
||||
maxZoom = parseInt(maxZoom);
|
||||
}
|
||||
|
||||
let layerId = selectedLayerId ?? getLayerId();
|
||||
let layer: CustomLayer = {
|
||||
id: layerId,
|
||||
name: name,
|
||||
tileUrls: tileUrls,
|
||||
maxZoom: maxZoom,
|
||||
layerType: layerType,
|
||||
resourceType: resourceType,
|
||||
value: ''
|
||||
};
|
||||
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 (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();
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
function getLayerId() {
|
||||
for (let id = 0; ; id++) {
|
||||
if (!$customLayers.hasOwnProperty(`custom-${id}`)) {
|
||||
return `custom-${id}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
function getLayerId() {
|
||||
for (let id = 0; ; id++) {
|
||||
if (!$customLayers.hasOwnProperty(`custom-${id}`)) {
|
||||
return `custom-${id}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
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;
|
||||
});
|
||||
|
||||
$currentBasemap = layerId;
|
||||
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 (!$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 ($map && $map.getSource(layerId)) {
|
||||
// Reset source when updating an existing layer
|
||||
if ($map.getLayer(layerId)) {
|
||||
$map.removeLayer(layerId);
|
||||
}
|
||||
$map.removeSource(layerId);
|
||||
}
|
||||
if (
|
||||
$currentOverlays.overlays['custom'] &&
|
||||
$currentOverlays.overlays['custom'][layerId] &&
|
||||
$map
|
||||
) {
|
||||
try {
|
||||
$map.removeImport(layerId);
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to remove sources and layers
|
||||
}
|
||||
}
|
||||
|
||||
if (!$currentOverlays.overlays.hasOwnProperty('custom')) {
|
||||
$currentOverlays.overlays['custom'] = {};
|
||||
}
|
||||
$currentOverlays.overlays['custom'][layerId] = true;
|
||||
if (!$currentOverlays.overlays.hasOwnProperty('custom')) {
|
||||
$currentOverlays.overlays['custom'] = {};
|
||||
}
|
||||
$currentOverlays.overlays['custom'][layerId] = true;
|
||||
|
||||
if (!$customOverlayOrder.includes(layerId)) {
|
||||
$customOverlayOrder = [...$customOverlayOrder, layerId];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!$customOverlayOrder.includes(layerId)) {
|
||||
$customOverlayOrder = [...$customOverlayOrder, layerId];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function tryDeleteLayer(node: any, id: string): any {
|
||||
if (node.hasOwnProperty(id)) {
|
||||
delete node[id];
|
||||
}
|
||||
return node;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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
|
||||
);
|
||||
}
|
||||
$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);
|
||||
$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 ($map) {
|
||||
if ($map.getLayer(layerId)) {
|
||||
$map.removeLayer(layerId);
|
||||
}
|
||||
if ($map.getSource(layerId)) {
|
||||
$map.removeSource(layerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
$customLayers = tryDeleteLayer($customLayers, 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;
|
||||
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';
|
||||
}
|
||||
}
|
||||
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();
|
||||
$: selectedLayerId, setDataFromSelectedLayer();
|
||||
</script>
|
||||
|
||||
<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>
|
||||
{#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>
|
||||
|
@@ -11,7 +11,7 @@
|
||||
import { settings } from '$lib/db';
|
||||
import { map } from '$lib/stores';
|
||||
import { get, writable } from 'svelte/store';
|
||||
import { getLayers } from './utils';
|
||||
import { customBasemapUpdate, getLayers } from './utils';
|
||||
import { OverpassLayer } from './OverpassLayer';
|
||||
import OverpassPopup from './OverpassPopup.svelte';
|
||||
|
||||
@@ -35,33 +35,80 @@
|
||||
let basemap = basemaps.hasOwnProperty($currentBasemap)
|
||||
? basemaps[$currentBasemap]
|
||||
: $customLayers[$currentBasemap]?.value ?? basemaps[defaultBasemap];
|
||||
$map.setStyle(basemap, {
|
||||
diff: false
|
||||
});
|
||||
$map.removeImport('basemap');
|
||||
if (typeof basemap === 'string') {
|
||||
$map.addImport({ id: 'basemap', url: basemap }, 'overlays');
|
||||
} else {
|
||||
$map.addImport(
|
||||
{
|
||||
id: 'basemap',
|
||||
data: basemap
|
||||
},
|
||||
'overlays'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$: if ($map && $currentBasemap) {
|
||||
$: if ($map && ($currentBasemap || $customBasemapUpdate)) {
|
||||
setStyle();
|
||||
}
|
||||
|
||||
$: 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);
|
||||
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 {
|
||||
if ($opacities.hasOwnProperty(id)) {
|
||||
overlay = {
|
||||
...overlay,
|
||||
layers: overlay.layers.map((layer) => {
|
||||
if (layer.type === 'raster') {
|
||||
if (!layer.paint) {
|
||||
layer.paint = {};
|
||||
}
|
||||
layer.paint['raster-opacity'] = $opacities[id];
|
||||
}
|
||||
return layer;
|
||||
})
|
||||
};
|
||||
}
|
||||
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]);
|
||||
$map.addImport({
|
||||
id,
|
||||
data: overlay
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to add sources and layers
|
||||
}
|
||||
}
|
||||
|
||||
function updateOverlays() {
|
||||
if ($map && $currentOverlays) {
|
||||
let overlayLayers = getLayers($currentOverlays);
|
||||
try {
|
||||
let activeOverlays = $map
|
||||
.getStyle()
|
||||
.imports.filter((i) => i.id !== 'basemap' && i.id !== 'overlays');
|
||||
let toRemove = activeOverlays.filter((i) => !overlayLayers[i.id]);
|
||||
toRemove.forEach((i) => {
|
||||
$map.removeImport(i.id);
|
||||
});
|
||||
let toAdd = Object.entries(overlayLayers)
|
||||
.filter(([id, selected]) => selected && !activeOverlays.some((j) => j.id === id))
|
||||
.map(([id]) => id);
|
||||
toAdd.forEach((id) => {
|
||||
addOverlay(id);
|
||||
});
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to add sources and layers
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$: if ($map && $currentOverlays) {
|
||||
updateOverlays();
|
||||
}
|
||||
|
||||
$: if ($map) {
|
||||
@@ -70,6 +117,7 @@
|
||||
}
|
||||
overpassLayer = new OverpassLayer($map);
|
||||
overpassLayer.add();
|
||||
$map.on('style.import.load', updateOverlays);
|
||||
}
|
||||
|
||||
let selectedBasemap = writable(get(currentBasemap));
|
||||
@@ -85,37 +133,6 @@
|
||||
selectedBasemap.set(value);
|
||||
});
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let open = false;
|
||||
function openLayerControl() {
|
||||
open = true;
|
||||
|
@@ -9,8 +9,14 @@
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Slider } from '$lib/components/ui/slider';
|
||||
|
||||
import { basemapTree, overlays, overlayTree, overpassTree } from '$lib/assets/layers';
|
||||
import { isSelected } from '$lib/components/layer-control/utils';
|
||||
import {
|
||||
basemapTree,
|
||||
defaultBasemap,
|
||||
overlays,
|
||||
overlayTree,
|
||||
overpassTree
|
||||
} from '$lib/assets/layers';
|
||||
import { getLayers, isSelected, toggle } from '$lib/components/layer-control/utils';
|
||||
import { settings } from '$lib/db';
|
||||
|
||||
import { _ } from 'svelte-i18n';
|
||||
@@ -22,6 +28,7 @@
|
||||
selectedBasemapTree,
|
||||
selectedOverlayTree,
|
||||
selectedOverpassTree,
|
||||
currentBasemap,
|
||||
currentOverlays,
|
||||
customLayers,
|
||||
opacities
|
||||
@@ -46,6 +53,30 @@
|
||||
}
|
||||
}
|
||||
|
||||
$: if ($selectedBasemapTree && $currentBasemap) {
|
||||
if (!isSelected($selectedBasemapTree, $currentBasemap)) {
|
||||
if (!isSelected($selectedBasemapTree, defaultBasemap)) {
|
||||
$selectedBasemapTree = toggle($selectedBasemapTree, defaultBasemap);
|
||||
}
|
||||
$currentBasemap = defaultBasemap;
|
||||
}
|
||||
}
|
||||
|
||||
$: if ($selectedOverlayTree && $currentOverlays) {
|
||||
let overlayLayers = getLayers($currentOverlays);
|
||||
let toRemove = Object.entries(overlayLayers).filter(
|
||||
([id, checked]) => checked && !isSelected($selectedOverlayTree, id)
|
||||
);
|
||||
if (toRemove.length > 0) {
|
||||
currentOverlays.update((tree) => {
|
||||
toRemove.forEach(([id]) => {
|
||||
toggle(tree, id);
|
||||
});
|
||||
return tree;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$: if ($selectedOverlay) {
|
||||
setOpacityFromSelection();
|
||||
}
|
||||
|
@@ -46,6 +46,7 @@
|
||||
value={id}
|
||||
bind:checked={checked[id]}
|
||||
class="scale-90"
|
||||
aria-label={$_(`layers.label.${id}`)}
|
||||
/>
|
||||
{:else}
|
||||
<input id="{name}-{id}" type="radio" {name} value={id} bind:group={selected} />
|
||||
|
@@ -50,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();
|
||||
@@ -108,15 +108,19 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -62,11 +62,11 @@
|
||||
{#if key !== 'name' && !key.includes('image')}
|
||||
<span class="font-mono">{key}</span>
|
||||
{#if key === 'website' || key === 'contact:website' || key === 'contact:facebook' || key === 'contact:instagram' || key === 'contact:twitter'}
|
||||
<a href={value} target="_blank" class="text-blue-500 underline">{value}</a>
|
||||
<a href={value} target="_blank" class="text-link underline">{value}</a>
|
||||
{:else if key === 'phone' || key === 'contact:phone'}
|
||||
<a href={'tel:' + value} class="text-blue-500 underline">{value}</a>
|
||||
<a href={'tel:' + value} class="text-link underline">{value}</a>
|
||||
{:else if key === 'email' || key === 'contact:email'}
|
||||
<a href={'mailto:' + value} class="text-blue-500 underline">{value}</a>
|
||||
<a href={'mailto:' + value} class="text-link underline">{value}</a>
|
||||
{:else}
|
||||
<span>{value}</span>
|
||||
{/if}
|
||||
|
@@ -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,16 @@ export function isSelected(node: LayerTreeType, id: string) {
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
export function toggle(node: LayerTreeType, id: string) {
|
||||
Object.keys(node).forEach((key) => {
|
||||
if (key === id) {
|
||||
node[key] = !node[key];
|
||||
} else if (typeof node[key] !== "boolean") {
|
||||
toggle(node[key], id);
|
||||
}
|
||||
});
|
||||
return node;
|
||||
}
|
||||
|
||||
export const customBasemapUpdate = writable(0);
|
@@ -1,11 +1,13 @@
|
||||
<script lang="ts">
|
||||
import CustomControl from '$lib/components/custom-control/CustomControl.svelte';
|
||||
import Tooltip from '$lib/components/Tooltip.svelte';
|
||||
import { Toggle } from '$lib/components/ui/toggle';
|
||||
import { PersonStanding, X } from 'lucide-svelte';
|
||||
import { MapillaryLayer } from './Mapillary';
|
||||
import { GoogleRedirect } from './Google';
|
||||
import { map, streetViewEnabled } from '$lib/stores';
|
||||
import { settings } from '$lib/db';
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
const { streetViewSource } = settings;
|
||||
|
||||
@@ -38,9 +40,15 @@
|
||||
</script>
|
||||
|
||||
<CustomControl class="w-[29px] h-[29px] shrink-0">
|
||||
<Toggle bind:pressed={$streetViewEnabled} class="w-full h-full rounded p-0">
|
||||
<PersonStanding size="22" />
|
||||
</Toggle>
|
||||
<Tooltip class="w-full h-full" side="left" label={$_('menu.toggle_street_view')}>
|
||||
<Toggle
|
||||
bind:pressed={$streetViewEnabled}
|
||||
class="w-full h-full rounded p-0"
|
||||
aria-label={$_('menu.toggle_street_view')}
|
||||
>
|
||||
<PersonStanding size="22" />
|
||||
</Toggle>
|
||||
</Tooltip>
|
||||
</CustomControl>
|
||||
|
||||
<div
|
||||
|
@@ -9,7 +9,8 @@
|
||||
Ungroup,
|
||||
MapPin,
|
||||
Filter,
|
||||
Scissors
|
||||
Scissors,
|
||||
MountainSnow
|
||||
} from 'lucide-svelte';
|
||||
|
||||
import { _ } from 'svelte-i18n';
|
||||
@@ -21,37 +22,32 @@
|
||||
class="h-fit flex flex-col p-1 gap-1.5 bg-background rounded-r-md pointer-events-auto shadow-md {$$props.class ??
|
||||
''}"
|
||||
>
|
||||
<ToolbarItem tool={Tool.ROUTING}>
|
||||
<Pencil slot="icon" size="18" class="h-" />
|
||||
<span slot="tooltip">{$_('toolbar.routing.tooltip')}</span>
|
||||
<ToolbarItem tool={Tool.ROUTING} label={$_('toolbar.routing.tooltip')}>
|
||||
<Pencil slot="icon" size="18" />
|
||||
</ToolbarItem>
|
||||
<ToolbarItem tool={Tool.WAYPOINT}>
|
||||
<ToolbarItem tool={Tool.WAYPOINT} label={$_('toolbar.waypoint.tooltip')}>
|
||||
<MapPin slot="icon" size="18" />
|
||||
<span slot="tooltip">{$_('toolbar.waypoint.tooltip')}</span>
|
||||
</ToolbarItem>
|
||||
<ToolbarItem tool={Tool.SCISSORS}>
|
||||
<ToolbarItem tool={Tool.SCISSORS} label={$_('toolbar.scissors.tooltip')}>
|
||||
<Scissors slot="icon" size="18" />
|
||||
<span slot="tooltip">{$_('toolbar.scissors.tooltip')}</span>
|
||||
</ToolbarItem>
|
||||
<ToolbarItem tool={Tool.TIME}>
|
||||
<ToolbarItem tool={Tool.TIME} label={$_('toolbar.time.tooltip')}>
|
||||
<CalendarClock slot="icon" size="18" />
|
||||
<span slot="tooltip">{$_('toolbar.time.tooltip')}</span>
|
||||
</ToolbarItem>
|
||||
<ToolbarItem tool={Tool.MERGE}>
|
||||
<ToolbarItem tool={Tool.MERGE} label={$_('toolbar.merge.tooltip')}>
|
||||
<Group slot="icon" size="18" />
|
||||
<span slot="tooltip">{$_('toolbar.merge.tooltip')}</span>
|
||||
</ToolbarItem>
|
||||
<ToolbarItem tool={Tool.EXTRACT}>
|
||||
<ToolbarItem tool={Tool.EXTRACT} label={$_('toolbar.extract.tooltip')}>
|
||||
<Ungroup slot="icon" size="18" />
|
||||
<span slot="tooltip">{$_('toolbar.extract.tooltip')}</span>
|
||||
</ToolbarItem>
|
||||
<ToolbarItem tool={Tool.REDUCE}>
|
||||
<ToolbarItem tool={Tool.ELEVATION} label={$_('toolbar.elevation.button')}>
|
||||
<MountainSnow slot="icon" size="18" />
|
||||
</ToolbarItem>
|
||||
<ToolbarItem tool={Tool.REDUCE} label={$_('toolbar.reduce.tooltip')}>
|
||||
<Filter slot="icon" size="18" />
|
||||
<span slot="tooltip">{$_('toolbar.reduce.tooltip')}</span>
|
||||
</ToolbarItem>
|
||||
<ToolbarItem tool={Tool.CLEAN}>
|
||||
<ToolbarItem tool={Tool.CLEAN} label={$_('toolbar.clean.tooltip')}>
|
||||
<SquareDashedMousePointer slot="icon" size="18" />
|
||||
<span slot="tooltip">{$_('toolbar.clean.tooltip')}</span>
|
||||
</ToolbarItem>
|
||||
</div>
|
||||
<ToolbarItemMenu class={$$props.class ?? ''} />
|
||||
|
@@ -4,6 +4,7 @@
|
||||
import { currentTool, type Tool } from '$lib/stores';
|
||||
|
||||
export let tool: Tool;
|
||||
export let label: string;
|
||||
|
||||
function toggleTool() {
|
||||
currentTool.update((current) => (current === tool ? null : tool));
|
||||
@@ -17,11 +18,12 @@
|
||||
variant="ghost"
|
||||
class="h-[26px] px-1 py-1.5 {$currentTool === tool ? 'bg-accent' : ''}"
|
||||
on:click={toggleTool}
|
||||
aria-label={label}
|
||||
>
|
||||
<slot name="icon" />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="right">
|
||||
<slot name="tooltip" />
|
||||
<span>{label}</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
|
@@ -9,6 +9,7 @@
|
||||
import Time from '$lib/components/toolbar/tools/Time.svelte';
|
||||
import Merge from '$lib/components/toolbar/tools/Merge.svelte';
|
||||
import Extract from '$lib/components/toolbar/tools/Extract.svelte';
|
||||
import Elevation from '$lib/components/toolbar/tools/Elevation.svelte';
|
||||
import Clean from '$lib/components/toolbar/tools/Clean.svelte';
|
||||
import Reduce from '$lib/components/toolbar/tools/Reduce.svelte';
|
||||
import RoutingControlPopup from '$lib/components/toolbar/tools/routing/RoutingControlPopup.svelte';
|
||||
@@ -48,6 +49,8 @@
|
||||
<Time />
|
||||
{:else if $currentTool === Tool.MERGE}
|
||||
<Merge />
|
||||
{:else if $currentTool === Tool.ELEVATION}
|
||||
<Elevation />
|
||||
{:else if $currentTool === Tool.EXTRACT}
|
||||
<Extract />
|
||||
{:else if $currentTool === Tool.CLEAN}
|
||||
|
@@ -11,9 +11,9 @@
|
||||
import * as RadioGroup from '$lib/components/ui/radio-group';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { resetCursor, setCrosshairCursor } from '$lib/utils';
|
||||
import { getURLForLanguage, resetCursor, setCrosshairCursor } from '$lib/utils';
|
||||
import { Trash2 } from 'lucide-svelte';
|
||||
import { map } from '$lib/stores';
|
||||
import { selection } from '$lib/components/file-list/Selection';
|
||||
@@ -178,7 +178,7 @@
|
||||
<Trash2 size="16" class="mr-1" />
|
||||
{$_('toolbar.clean.button')}
|
||||
</Button>
|
||||
<Help link="./help/toolbar/clean">
|
||||
<Help link={getURLForLanguage($locale, '/help/toolbar/clean')}>
|
||||
{#if validSelection}
|
||||
{$_('toolbar.clean.help')}
|
||||
{:else}
|
||||
|
35
website/src/lib/components/toolbar/tools/Elevation.svelte
Normal file
35
website/src/lib/components/toolbar/tools/Elevation.svelte
Normal file
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { selection } from '$lib/components/file-list/Selection';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { MountainSnow } from 'lucide-svelte';
|
||||
import { dbUtils } from '$lib/db';
|
||||
import { map } from '$lib/stores';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
|
||||
$: validSelection = $selection.size > 0;
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3 w-full max-w-80 {$$props.class ?? ''}">
|
||||
<Button
|
||||
variant="outline"
|
||||
class="whitespace-normal h-fit"
|
||||
disabled={!validSelection}
|
||||
on:click={async () => {
|
||||
if ($map) {
|
||||
dbUtils.addElevationToSelection($map);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MountainSnow size="16" class="mr-1 shrink-0" />
|
||||
{$_('toolbar.elevation.button')}
|
||||
</Button>
|
||||
<Help link={getURLForLanguage($locale, '/help/toolbar/elevation')}>
|
||||
{#if validSelection}
|
||||
{$_('toolbar.elevation.help')}
|
||||
{:else}
|
||||
{$_('toolbar.elevation.help_no_selection')}
|
||||
{/if}
|
||||
</Help>
|
||||
</div>
|
@@ -11,7 +11,8 @@
|
||||
} from '$lib/components/file-list/FileList';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { dbUtils, getFile } from '$lib/db';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
|
||||
$: validSelection =
|
||||
$selection.size > 0 &&
|
||||
@@ -42,7 +43,7 @@
|
||||
<Ungroup size="16" class="mr-1" />
|
||||
{$_('toolbar.extract.button')}
|
||||
</Button>
|
||||
<Help link="./help/toolbar/extract">
|
||||
<Help link={getURLForLanguage($locale, '/help/toolbar/extract')}>
|
||||
{#if validSelection}
|
||||
{$_('toolbar.extract.help')}
|
||||
{:else}
|
||||
|
@@ -12,9 +12,11 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import * as RadioGroup from '$lib/components/ui/radio-group';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { dbUtils, getFile } from '$lib/db';
|
||||
import { Group } from 'lucide-svelte';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
import Shortcut from '$lib/components/Shortcut.svelte';
|
||||
|
||||
let canMergeTraces = false;
|
||||
let canMergeContents = false;
|
||||
@@ -65,24 +67,39 @@
|
||||
</RadioGroup.Root>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="whitespace-normal h-fit"
|
||||
disabled={(mergeType === MergeType.TRACES && !canMergeTraces) ||
|
||||
(mergeType === MergeType.CONTENTS && !canMergeContents)}
|
||||
on:click={() => {
|
||||
dbUtils.mergeSelection(mergeType === MergeType.TRACES);
|
||||
}}
|
||||
>
|
||||
<Group size="16" class="mr-1" />
|
||||
<Group size="16" class="mr-1 shrink-0" />
|
||||
{$_('toolbar.merge.merge_selection')}
|
||||
</Button>
|
||||
<Help link="./help/toolbar/merge">
|
||||
<Help link={getURLForLanguage($locale, '/help/toolbar/merge')}>
|
||||
{#if mergeType === MergeType.TRACES && canMergeTraces}
|
||||
{$_('toolbar.merge.help_merge_traces')}
|
||||
{:else if mergeType === MergeType.TRACES && !canMergeTraces}
|
||||
{$_('toolbar.merge.help_cannot_merge_traces')}
|
||||
{$_('toolbar.merge.selection_tip').split('{KEYBOARD_SHORTCUT}')[0]}
|
||||
<Shortcut
|
||||
ctrl={true}
|
||||
click={true}
|
||||
class="inline-flex text-muted-foreground text-xs border rounded p-0.5 gap-0"
|
||||
/>
|
||||
{$_('toolbar.merge.selection_tip').split('{KEYBOARD_SHORTCUT}')[1]}
|
||||
{:else if mergeType === MergeType.CONTENTS && canMergeContents}
|
||||
{$_('toolbar.merge.help_merge_contents')}
|
||||
{:else if mergeType === MergeType.CONTENTS && !canMergeContents}
|
||||
{$_('toolbar.merge.help_cannot_merge_contents')}
|
||||
{$_('toolbar.merge.selection_tip').split('{KEYBOARD_SHORTCUT}')[0]}
|
||||
<Shortcut
|
||||
ctrl={true}
|
||||
click={true}
|
||||
class="inline-flex text-muted-foreground text-xs border rounded p-0.5 gap-0"
|
||||
/>
|
||||
{$_('toolbar.merge.selection_tip').split('{KEYBOARD_SHORTCUT}')[1]}
|
||||
{/if}
|
||||
</Help>
|
||||
</div>
|
||||
|
@@ -6,13 +6,14 @@
|
||||
import { ListItem, ListRootItem, ListTrackSegmentItem } from '$lib/components/file-list/FileList';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { Filter } from 'lucide-svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import WithUnits from '$lib/components/WithUnits.svelte';
|
||||
import { dbUtils, fileObservers } from '$lib/db';
|
||||
import { map } from '$lib/stores';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { ramerDouglasPeucker, TrackPoint, type SimplifiedTrackPoint } from 'gpx';
|
||||
import { derived } from 'svelte/store';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
|
||||
let sliderValue = [50];
|
||||
let maxPoints = 0;
|
||||
@@ -153,18 +154,18 @@
|
||||
</div>
|
||||
<Label class="flex flex-row justify-between">
|
||||
<span>{$_('toolbar.reduce.tolerance')}</span>
|
||||
<WithUnits value={tolerance / 1000} type="distance" decimals={3} />
|
||||
<WithUnits value={tolerance / 1000} type="distance" decimals={3} class="font-normal" />
|
||||
</Label>
|
||||
<Label class="flex flex-row justify-between">
|
||||
<span>{$_('toolbar.reduce.number_of_points')}</span>
|
||||
<span>{currentPoints}/{maxPoints}</span>
|
||||
<span class="font-normal">{currentPoints}/{maxPoints}</span>
|
||||
</Label>
|
||||
<Button variant="outline" disabled={!validSelection} on:click={reduce}>
|
||||
<Filter size="16" class="mr-1" />
|
||||
{$_('toolbar.reduce.button')}
|
||||
</Button>
|
||||
|
||||
<Help link="./help/toolbar/minify">
|
||||
<Help link={getURLForLanguage($locale, '/help/toolbar/minify')}>
|
||||
{#if validSelection}
|
||||
{$_('toolbar.reduce.help')}
|
||||
{:else}
|
||||
|
@@ -10,7 +10,8 @@
|
||||
import {
|
||||
distancePerHourToSecondsPerDistance,
|
||||
getConvertedVelocity,
|
||||
milesToKilometers
|
||||
milesToKilometers,
|
||||
nauticalMilesToKilometers
|
||||
} from '$lib/units';
|
||||
import { CalendarDate, type DateValue } from '@internationalized/date';
|
||||
import { CalendarClock, CirclePlay, CircleStop, CircleX, Timer, Zap } from 'lucide-svelte';
|
||||
@@ -25,6 +26,7 @@
|
||||
ListTrackSegmentItem
|
||||
} from '$lib/components/file-list/FileList';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
|
||||
let startDate: DateValue | undefined = undefined;
|
||||
let startTime: string | undefined = undefined;
|
||||
@@ -32,11 +34,16 @@
|
||||
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 toTimeString(date: Date): string {
|
||||
return date.toTimeString().split(' ')[0];
|
||||
}
|
||||
|
||||
const { velocityUnits, distanceUnits } = settings;
|
||||
|
||||
function setSpeed(value: number) {
|
||||
@@ -50,14 +57,14 @@
|
||||
function setGPXData() {
|
||||
if ($gpxStatistics.global.time.start) {
|
||||
startDate = toCalendarDate($gpxStatistics.global.time.start);
|
||||
startTime = $gpxStatistics.global.time.start.toLocaleTimeString();
|
||||
startTime = toTimeString($gpxStatistics.global.time.start);
|
||||
} else {
|
||||
startDate = undefined;
|
||||
startTime = undefined;
|
||||
}
|
||||
if ($gpxStatistics.global.time.end) {
|
||||
endDate = toCalendarDate($gpxStatistics.global.time.end);
|
||||
endTime = $gpxStatistics.global.time.end.toLocaleTimeString();
|
||||
endTime = toTimeString($gpxStatistics.global.time.end);
|
||||
} else {
|
||||
endDate = undefined;
|
||||
endTime = undefined;
|
||||
@@ -83,6 +90,9 @@
|
||||
return new Date();
|
||||
}
|
||||
let [hours, minutes, seconds] = time.split(':').map((x) => parseInt(x));
|
||||
if (seconds === undefined) {
|
||||
seconds = 0;
|
||||
}
|
||||
return new Date(date.year, date.month - 1, date.day, hours, minutes, seconds);
|
||||
}
|
||||
|
||||
@@ -98,7 +108,7 @@
|
||||
: 1;
|
||||
let end = new Date(start.getTime() + ratio * movingTime * 1000);
|
||||
endDate = toCalendarDate(end);
|
||||
endTime = end.toLocaleTimeString();
|
||||
endTime = toTimeString(end);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +124,7 @@
|
||||
: 1;
|
||||
let start = new Date(end.getTime() - ratio * movingTime * 1000);
|
||||
startDate = toCalendarDate(start);
|
||||
startTime = start.toLocaleTimeString();
|
||||
startTime = toTimeString(start);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +139,8 @@
|
||||
}
|
||||
if ($distanceUnits === 'imperial') {
|
||||
speedValue = milesToKilometers(speedValue);
|
||||
} else if ($distanceUnits === 'nautical') {
|
||||
speedValue = nauticalMilesToKilometers(speedValue);
|
||||
}
|
||||
return speedValue;
|
||||
}
|
||||
@@ -190,8 +202,10 @@
|
||||
<span class="text-sm shrink-0">
|
||||
{#if $distanceUnits === 'imperial'}
|
||||
{$_('units.miles_per_hour')}
|
||||
{:else}
|
||||
{:else if $distanceUnits === 'metric'}
|
||||
{$_('units.kilometers_per_hour')}
|
||||
{:else if $distanceUnits === 'nautical'}
|
||||
{$_('units.knots')}
|
||||
{/if}
|
||||
</span>
|
||||
{:else}
|
||||
@@ -204,8 +218,10 @@
|
||||
<span class="text-sm shrink-0">
|
||||
{#if $distanceUnits === 'imperial'}
|
||||
{$_('units.minutes_per_mile')}
|
||||
{:else}
|
||||
{:else if $distanceUnits === 'metric'}
|
||||
{$_('units.minutes_per_kilometer')}
|
||||
{:else if $distanceUnits === 'nautical'}
|
||||
{$_('units.minutes_per_nautical_mile')}
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
@@ -274,19 +290,19 @@
|
||||
/>
|
||||
</div>
|
||||
{#if $gpxStatistics.global.time.moving === 0 || $gpxStatistics.global.time.moving === undefined}
|
||||
<div class="mt-0.5 flex flex-row gap-1 items-center hidden">
|
||||
<Checkbox id="artificial-time" disabled={!canUpdate} />
|
||||
<div class="mt-0.5 flex flex-row gap-1 items-center">
|
||||
<Checkbox id="artificial-time" bind:checked={artificial} disabled={!canUpdate} />
|
||||
<Label for="artificial-time">
|
||||
{$_('toolbar.time.artificial')}
|
||||
</Label>
|
||||
</div>
|
||||
{/if}
|
||||
</fieldset>
|
||||
<div class="flex flex-row gap-2">
|
||||
<div class="flex flex-row gap-2 items-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!canUpdate}
|
||||
class="grow"
|
||||
class="grow whitespace-normal h-fit"
|
||||
on:click={() => {
|
||||
let effectiveSpeed = getSpeed();
|
||||
if (startDate === undefined || startTime === undefined || effectiveSpeed === undefined) {
|
||||
@@ -309,34 +325,55 @@
|
||||
let fileId = item.getFileId();
|
||||
dbUtils.applyToFile(fileId, (file) => {
|
||||
if (item instanceof ListFileItem) {
|
||||
file.changeTimestamps(getDate(startDate, startTime), effectiveSpeed, ratio);
|
||||
if (artificial) {
|
||||
file.createArtificialTimestamps(getDate(startDate, startTime), movingTime);
|
||||
} else {
|
||||
file.changeTimestamps(getDate(startDate, startTime), effectiveSpeed, ratio);
|
||||
}
|
||||
} else if (item instanceof ListTrackItem) {
|
||||
file.changeTimestamps(
|
||||
getDate(startDate, startTime),
|
||||
effectiveSpeed,
|
||||
ratio,
|
||||
item.getTrackIndex()
|
||||
);
|
||||
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) {
|
||||
file.changeTimestamps(
|
||||
getDate(startDate, startTime),
|
||||
effectiveSpeed,
|
||||
ratio,
|
||||
item.getTrackIndex(),
|
||||
item.getSegmentIndex()
|
||||
);
|
||||
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" />
|
||||
<CalendarClock size="16" class="mr-1 shrink-0" />
|
||||
{$_('toolbar.time.update')}
|
||||
</Button>
|
||||
<Button variant="outline" on:click={setGPXData}>
|
||||
<CircleX size="16" />
|
||||
</Button>
|
||||
</div>
|
||||
<Help link="./help/toolbar/time">
|
||||
<Help link={getURLForLanguage($locale, '/help/toolbar/time')}>
|
||||
{#if canUpdate}
|
||||
{$_('toolbar.time.help')}
|
||||
{:else}
|
||||
|
@@ -19,8 +19,8 @@
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { map } from '$lib/stores';
|
||||
import { resetCursor, setCrosshairCursor } from '$lib/utils';
|
||||
import { CirclePlus, CircleX, Save } from 'lucide-svelte';
|
||||
import { getURLForLanguage, resetCursor, setCrosshairCursor } from '$lib/utils';
|
||||
import { MapPin, CircleX, Save } from 'lucide-svelte';
|
||||
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
||||
|
||||
let name: string;
|
||||
@@ -181,12 +181,21 @@
|
||||
<div class="flex flex-col gap-3 w-full max-w-96 {$$props.class ?? ''}">
|
||||
<fieldset class="flex flex-col gap-2">
|
||||
<Label for="name">{$_('menu.metadata.name')}</Label>
|
||||
<Input bind:value={name} id="name" class="font-semibold h-8" />
|
||||
<Input
|
||||
bind:value={name}
|
||||
id="name"
|
||||
class="font-semibold h-8"
|
||||
disabled={!canCreate && !$selectedWaypoint}
|
||||
/>
|
||||
<Label for="description">{$_('menu.metadata.description')}</Label>
|
||||
<Textarea bind:value={description} id="description" />
|
||||
<Textarea
|
||||
bind:value={description}
|
||||
id="description"
|
||||
disabled={!canCreate && !$selectedWaypoint}
|
||||
/>
|
||||
<Label for="symbol">{$_('toolbar.waypoint.icon')}</Label>
|
||||
<Select.Root bind:selected={selectedSymbol}>
|
||||
<Select.Trigger id="symbol" class="w-full h-8">
|
||||
<Select.Trigger id="symbol" class="w-full h-8" disabled={!canCreate && !$selectedWaypoint}>
|
||||
<Select.Value />
|
||||
</Select.Trigger>
|
||||
<Select.Content class="max-h-60 overflow-y-scroll">
|
||||
@@ -209,9 +218,9 @@
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
<Label for="link">{$_('toolbar.waypoint.link')}</Label>
|
||||
<Input bind:value={link} id="link" class="h-8" />
|
||||
<Input bind:value={link} id="link" class="h-8" disabled={!canCreate && !$selectedWaypoint} />
|
||||
<div class="flex flex-row gap-2">
|
||||
<div>
|
||||
<div class="grow">
|
||||
<Label for="latitude">{$_('toolbar.waypoint.latitude')}</Label>
|
||||
<Input
|
||||
bind:value={latitude}
|
||||
@@ -221,9 +230,10 @@
|
||||
min={-90}
|
||||
max={90}
|
||||
class="text-xs h-8"
|
||||
disabled={!canCreate && !$selectedWaypoint}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="grow">
|
||||
<Label for="longitude">{$_('toolbar.waypoint.longitude')}</Label>
|
||||
<Input
|
||||
bind:value={longitude}
|
||||
@@ -233,28 +243,28 @@
|
||||
min={-180}
|
||||
max={180}
|
||||
class="text-xs h-8"
|
||||
disabled={!canCreate && !$selectedWaypoint}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="flex flex-row flex-wrap gap-2">
|
||||
<div class="flex flex-row gap-2 items-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!canCreate && !$selectedWaypoint}
|
||||
class="grow"
|
||||
class="grow whitespace-normal h-fit"
|
||||
on:click={createOrUpdateWaypoint}
|
||||
>
|
||||
{#if $selectedWaypoint}
|
||||
<Save size="16" class="mr-1" />
|
||||
<Save size="16" class="mr-1 shrink-0" />
|
||||
{$_('menu.metadata.save')}
|
||||
{:else}
|
||||
<CirclePlus size="16" class="mr-1" />
|
||||
<MapPin size="16" class="mr-1 shrink-0" />
|
||||
{$_('toolbar.waypoint.create')}
|
||||
{/if}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="ml-auto"
|
||||
on:click={() => {
|
||||
selectedWaypoint.set(undefined);
|
||||
resetWaypointData();
|
||||
@@ -263,7 +273,7 @@
|
||||
<CircleX size="16" />
|
||||
</Button>
|
||||
</div>
|
||||
<Help link="./help/toolbar/poi">
|
||||
<Help link={getURLForLanguage($locale, '/help/toolbar/poi')}>
|
||||
{#if $selectedWaypoint || canCreate}
|
||||
{$_('toolbar.waypoint.help')}
|
||||
{:else}
|
||||
|
@@ -4,6 +4,7 @@
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import Help from '$lib/components/Help.svelte';
|
||||
import ButtonWithTooltip from '$lib/components/ButtonWithTooltip.svelte';
|
||||
import Tooltip from '$lib/components/Tooltip.svelte';
|
||||
import Shortcut from '$lib/components/Shortcut.svelte';
|
||||
import {
|
||||
@@ -25,7 +26,7 @@
|
||||
import { dbUtils, getFile, getFileIds, settings } from '$lib/db';
|
||||
import { brouterProfiles, routingProfileSelectItem } from './Routing';
|
||||
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { RoutingControls } from './RoutingControls';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import { fileObservers } from '$lib/db';
|
||||
@@ -38,7 +39,7 @@
|
||||
ListTrackSegmentItem,
|
||||
type ListItem
|
||||
} from '$lib/components/file-list/FileList';
|
||||
import { flyAndScale, resetCursor, setCrosshairCursor } from '$lib/utils';
|
||||
import { flyAndScale, getURLForLanguage, resetCursor, setCrosshairCursor } from '$lib/utils';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { TrackPoint } from 'gpx';
|
||||
|
||||
@@ -105,7 +106,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if minimized}
|
||||
{#if minimizable && minimized}
|
||||
<div class="-m-1.5 -mb-2">
|
||||
<Button variant="ghost" class="px-1 h-[26px]" on:click={() => (minimized = false)}>
|
||||
<SquareArrowOutDownRight size="18" />
|
||||
@@ -116,27 +117,24 @@
|
||||
class="flex flex-col gap-3 w-full max-w-80 {$$props.class ?? ''}"
|
||||
in:flyAndScale={{ x: -2, y: 0, duration: 50 }}
|
||||
>
|
||||
<div class="grow flex flex-col gap-3">
|
||||
<Tooltip>
|
||||
<Label slot="data" class="w-full flex flex-row justify-between items-center gap-2">
|
||||
<span class="flex flex-row gap-1">
|
||||
{#if $routing}
|
||||
<Route size="16" />
|
||||
{:else}
|
||||
<RouteOff size="16" />
|
||||
{/if}
|
||||
{$_('toolbar.routing.use_routing')}
|
||||
</span>
|
||||
<Switch class="scale-90" bind:checked={$routing} />
|
||||
</Label>
|
||||
<span slot="tooltip" class="flex flex-row items-center">
|
||||
{$_('toolbar.routing.use_routing_tooltip')}
|
||||
<Shortcut key="F5" />
|
||||
<div class="flex flex-col gap-3">
|
||||
<Label class="flex flex-row justify-between items-center gap-2">
|
||||
<span class="flex flex-row items-center gap-1">
|
||||
{#if $routing}
|
||||
<Route size="16" />
|
||||
{:else}
|
||||
<RouteOff size="16" />
|
||||
{/if}
|
||||
{$_('toolbar.routing.use_routing')}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip label={$_('toolbar.routing.use_routing_tooltip')}>
|
||||
<Switch class="scale-90" bind:checked={$routing} />
|
||||
<Shortcut slot="extra" key="F5" />
|
||||
</Tooltip>
|
||||
</Label>
|
||||
{#if $routing}
|
||||
<div class="flex flex-col gap-3" in:slide>
|
||||
<Label class="w-full flex flex-row justify-between items-center gap-2">
|
||||
<Label class="flex flex-row justify-between items-center gap-2">
|
||||
<span class="shrink-0 flex flex-row items-center gap-1">
|
||||
{#if $routingProfileSelectItem.value.includes('bike') || $routingProfileSelectItem.value.includes('motorcycle')}
|
||||
<Bike size="16" />
|
||||
@@ -162,81 +160,73 @@
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</Label>
|
||||
<Label class="w-full flex flex-row justify-between items-center gap-2">
|
||||
<span class="flex flex-row gap-1"
|
||||
><TriangleAlert size="16" />{$_('toolbar.routing.allow_private')}</span
|
||||
>
|
||||
<Label class="flex flex-row justify-between items-center gap-2">
|
||||
<span class="flex flex-row gap-1">
|
||||
<TriangleAlert size="16" />
|
||||
{$_('toolbar.routing.allow_private')}
|
||||
</span>
|
||||
<Switch class="scale-90" bind:checked={$privateRoads} />
|
||||
</Label>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-row flex-wrap justify-center gap-1">
|
||||
<Tooltip>
|
||||
<Button
|
||||
slot="data"
|
||||
variant="outline"
|
||||
class="flex flex-row gap-1 text-xs px-2"
|
||||
disabled={!validSelection}
|
||||
on:click={dbUtils.reverseSelection}
|
||||
>
|
||||
<ArrowRightLeft size="12" />{$_('toolbar.routing.reverse.button')}
|
||||
</Button>
|
||||
<span slot="tooltip">{$_('toolbar.routing.reverse.tooltip')}</span>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<Button
|
||||
slot="data"
|
||||
variant="outline"
|
||||
class="flex flex-row gap-1 text-xs px-2"
|
||||
disabled={!validSelection}
|
||||
on:click={() => {
|
||||
const selected = getOrderedSelection();
|
||||
if (selected.length > 0) {
|
||||
const firstFileId = selected[0].getFileId();
|
||||
const firstFile = getFile(firstFileId);
|
||||
if (firstFile) {
|
||||
let start = (() => {
|
||||
if (selected[0] instanceof ListFileItem) {
|
||||
return firstFile.trk[0]?.trkseg[0]?.trkpt[0];
|
||||
} else if (selected[0] instanceof ListTrackItem) {
|
||||
return firstFile.trk[selected[0].getTrackIndex()]?.trkseg[0]?.trkpt[0];
|
||||
} else if (selected[0] instanceof ListTrackSegmentItem) {
|
||||
return firstFile.trk[selected[0].getTrackIndex()]?.trkseg[
|
||||
selected[0].getSegmentIndex()
|
||||
]?.trkpt[0];
|
||||
}
|
||||
})();
|
||||
|
||||
if (start !== undefined) {
|
||||
const lastFileId = selected[selected.length - 1].getFileId();
|
||||
routingControls
|
||||
.get(lastFileId)
|
||||
?.appendAnchorWithCoordinates(start.getCoordinates());
|
||||
<ButtonWithTooltip
|
||||
label={$_('toolbar.routing.reverse.tooltip')}
|
||||
variant="outline"
|
||||
class="flex flex-row gap-1 text-xs px-2"
|
||||
disabled={!validSelection}
|
||||
on:click={dbUtils.reverseSelection}
|
||||
>
|
||||
<ArrowRightLeft size="12" />{$_('toolbar.routing.reverse.button')}
|
||||
</ButtonWithTooltip>
|
||||
<ButtonWithTooltip
|
||||
label={$_('toolbar.routing.route_back_to_start.tooltip')}
|
||||
variant="outline"
|
||||
class="flex flex-row gap-1 text-xs px-2"
|
||||
disabled={!validSelection}
|
||||
on:click={() => {
|
||||
const selected = getOrderedSelection();
|
||||
if (selected.length > 0) {
|
||||
const firstFileId = selected[0].getFileId();
|
||||
const firstFile = getFile(firstFileId);
|
||||
if (firstFile) {
|
||||
let start = (() => {
|
||||
if (selected[0] instanceof ListFileItem) {
|
||||
return firstFile.trk[0]?.trkseg[0]?.trkpt[0];
|
||||
} else if (selected[0] instanceof ListTrackItem) {
|
||||
return firstFile.trk[selected[0].getTrackIndex()]?.trkseg[0]?.trkpt[0];
|
||||
} else if (selected[0] instanceof ListTrackSegmentItem) {
|
||||
return firstFile.trk[selected[0].getTrackIndex()]?.trkseg[
|
||||
selected[0].getSegmentIndex()
|
||||
]?.trkpt[0];
|
||||
}
|
||||
})();
|
||||
|
||||
if (start !== undefined) {
|
||||
const lastFileId = selected[selected.length - 1].getFileId();
|
||||
routingControls
|
||||
.get(lastFileId)
|
||||
?.appendAnchorWithCoordinates(start.getCoordinates());
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Home size="12" />{$_('toolbar.routing.route_back_to_start.button')}
|
||||
</Button>
|
||||
<span slot="tooltip">{$_('toolbar.routing.route_back_to_start.tooltip')}</span>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<Button
|
||||
slot="data"
|
||||
variant="outline"
|
||||
class="flex flex-row gap-1 text-xs px-2"
|
||||
disabled={!validSelection}
|
||||
on:click={dbUtils.createRoundTripForSelection}
|
||||
>
|
||||
<Repeat size="12" />{$_('toolbar.routing.round_trip.button')}
|
||||
</Button>
|
||||
<span slot="tooltip">{$_('toolbar.routing.round_trip.tooltip')}</span>
|
||||
</Tooltip>
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Home size="12" />{$_('toolbar.routing.route_back_to_start.button')}
|
||||
</ButtonWithTooltip>
|
||||
<ButtonWithTooltip
|
||||
label={$_('toolbar.routing.round_trip.tooltip')}
|
||||
variant="outline"
|
||||
class="flex flex-row gap-1 text-xs px-2"
|
||||
disabled={!validSelection}
|
||||
on:click={dbUtils.createRoundTripForSelection}
|
||||
>
|
||||
<Repeat size="12" />{$_('toolbar.routing.round_trip.button')}
|
||||
</ButtonWithTooltip>
|
||||
</div>
|
||||
<div class="w-full flex flex-row gap-2 items-end justify-between">
|
||||
<Help link="./help/toolbar/routing">
|
||||
<Help link={getURLForLanguage($locale, '/help/toolbar/routing')}>
|
||||
{#if !validSelection}
|
||||
{$_('toolbar.routing.help_no_file')}
|
||||
{:else}
|
||||
|
@@ -24,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}`);
|
||||
@@ -66,7 +66,7 @@ async function getRoute(points: Coordinates[], brouterProfile: string, privateRo
|
||||
const latIdx = messages[0].indexOf("Latitude");
|
||||
const tagIdx = messages[0].indexOf("WayTags");
|
||||
let messageIdx = 1;
|
||||
let surface = messageIdx < messages.length ? getSurface(messages[messageIdx][tagIdx]) : "unknown";
|
||||
let surface = messageIdx < messages.length ? getSurface(messages[messageIdx][tagIdx]) : undefined;
|
||||
|
||||
for (let i = 0; i < coordinates.length; i++) {
|
||||
let coord = coordinates[i];
|
||||
@@ -77,27 +77,30 @@ async function getRoute(points: Coordinates[], brouterProfile: string, privateRo
|
||||
},
|
||||
ele: coord[2] ?? (i > 0 ? route[i - 1].ele : 0)
|
||||
}));
|
||||
route[route.length - 1].setSurface(surface)
|
||||
|
||||
if (messageIdx < messages.length &&
|
||||
coordinates[i][0] == Number(messages[messageIdx][lngIdx]) / 1000000 &&
|
||||
coordinates[i][1] == Number(messages[messageIdx][latIdx]) / 1000000) {
|
||||
messageIdx++;
|
||||
|
||||
if (messageIdx == messages.length) surface = "unknown";
|
||||
if (messageIdx == messages.length) surface = undefined;
|
||||
else surface = getSurface(messages[messageIdx][tagIdx]);
|
||||
}
|
||||
|
||||
if (surface) {
|
||||
route[route.length - 1].setSurface(surface);
|
||||
}
|
||||
}
|
||||
|
||||
return route;
|
||||
}
|
||||
|
||||
function getSurface(message: string): string {
|
||||
function getSurface(message: string): string | undefined {
|
||||
const fields = message.split(" ");
|
||||
for (let i = 0; i < fields.length; i++) if (fields[i].startsWith("surface=")) {
|
||||
return fields[i].substring(8);
|
||||
}
|
||||
return "unknown";
|
||||
return undefined;
|
||||
};
|
||||
|
||||
function getIntermediatePoints(points: Coordinates[]): Promise<TrackPoint[]> {
|
||||
@@ -125,13 +128,10 @@ function getIntermediatePoints(points: Coordinates[]): Promise<TrackPoint[]> {
|
||||
}
|
||||
}));
|
||||
|
||||
let m = get(map);
|
||||
route.forEach((point) => {
|
||||
point.setSurface("unknown");
|
||||
if (m) {
|
||||
point.ele = getElevation(m, point.getCoordinates());
|
||||
}
|
||||
return getElevation(route).then((elevations) => {
|
||||
route.forEach((point, i) => {
|
||||
point.ele = elevations[i];
|
||||
});
|
||||
return route;
|
||||
});
|
||||
|
||||
return new Promise((resolve) => resolve(route));
|
||||
}
|
@@ -30,7 +30,7 @@
|
||||
>
|
||||
<Trash2 size="16" class="mr-1" />
|
||||
{$_('menu.delete')}
|
||||
<Shortcut key="" shift={true} click={true} />
|
||||
<Shortcut shift={true} click={true} />
|
||||
</Button>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { distance, type Coordinates, TrackPoint, TrackSegment, Track, crossarcDistance } 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";
|
||||
@@ -81,7 +81,6 @@ 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);
|
||||
@@ -129,7 +128,6 @@ 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);
|
||||
@@ -187,11 +185,13 @@ export class RoutingControls {
|
||||
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() - this.lastDragEvent < 100) { // Prevent click event during drag
|
||||
if (marker === this.temporaryAnchor.marker) {
|
||||
this.turnIntoPermanentAnchor();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -228,14 +228,15 @@ export class RoutingControls {
|
||||
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 {
|
||||
@@ -335,14 +336,14 @@ 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))) {
|
||||
let details: any = {};
|
||||
let closest = getClosestLinePoint(segment.trkpt, this.temporaryAnchor.point, details);
|
||||
if (details.distance < minDistance) {
|
||||
minDistance = details.distance;
|
||||
if (details.distance < minDetails.distance) {
|
||||
minDetails = details;
|
||||
minAnchor = {
|
||||
point: closest,
|
||||
segment,
|
||||
@@ -353,9 +354,65 @@ export class RoutingControls {
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
|
@@ -17,11 +17,12 @@
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import { gpxStatistics, map, slicedGPXStatistics, splitAs } from '$lib/stores';
|
||||
import { get } from 'svelte/store';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { onDestroy, tick } from 'svelte';
|
||||
import { Crop } from 'lucide-svelte';
|
||||
import { dbUtils } from '$lib/db';
|
||||
import { SplitControls } from './SplitControls';
|
||||
import { getURLForLanguage } from '$lib/utils';
|
||||
|
||||
let splitControls: SplitControls | undefined = undefined;
|
||||
let canCrop = false;
|
||||
@@ -37,8 +38,8 @@
|
||||
$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];
|
||||
|
||||
function updateCanCrop() {
|
||||
canCrop = sliderValues[0] != 0 || sliderValues[1] != maxSliderValue;
|
||||
@@ -66,7 +67,7 @@
|
||||
if (validSelection && $gpxStatistics.local.points.length > 0) {
|
||||
maxSliderValue = $gpxStatistics.local.points.length - 1;
|
||||
} else {
|
||||
maxSliderValue = 100;
|
||||
maxSliderValue = 1;
|
||||
}
|
||||
await tick();
|
||||
sliderValues = [0, maxSliderValue];
|
||||
@@ -135,7 +136,7 @@
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</Label>
|
||||
<Help link="./help/toolbar/scissors">
|
||||
<Help link={getURLForLanguage($locale, '/help/toolbar/scissors')}>
|
||||
{#if validSelection}
|
||||
{$_('toolbar.scissors.help')}
|
||||
{:else}
|
||||
|
@@ -49,9 +49,7 @@ export class SplitControls {
|
||||
}
|
||||
|
||||
updateControls() { // Update the markers when the files change
|
||||
|
||||
let controlIndex = 0;
|
||||
|
||||
applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
|
||||
let file = getFile(fileId);
|
||||
|
||||
@@ -61,6 +59,7 @@ export class SplitControls {
|
||||
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].fileId = fileId;
|
||||
this.controls[controlIndex].point = point;
|
||||
this.controls[controlIndex].segment = segment;
|
||||
this.controls[controlIndex].trackIndex = trackIndex;
|
||||
@@ -117,7 +116,7 @@ export class SplitControls {
|
||||
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"', "");
|
||||
element.innerHTML = Scissors.replace('width="24"', "").replace('height="24"', "").replace('stroke="currentColor"', 'stroke="black"');
|
||||
|
||||
let marker = new mapboxgl.Marker({
|
||||
draggable: true,
|
||||
@@ -137,7 +136,7 @@ export class SplitControls {
|
||||
|
||||
marker.getElement().addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
dbUtils.split(fileId, trackIndex, segmentIndex, point.getCoordinates(), point._data.index);
|
||||
dbUtils.split(control.fileId, control.trackIndex, control.segmentIndex, control.point.getCoordinates(), control.point._data.index);
|
||||
});
|
||||
|
||||
return control;
|
||||
|
@@ -10,7 +10,7 @@ import { updateAnchorPoints } from '$lib/components/toolbar/tools/routing/Simpli
|
||||
import { SplitType } from '$lib/components/toolbar/tools/scissors/Scissors.svelte';
|
||||
import { getClosestLinePoint, getElevation } from '$lib/utils';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
import type mapboxgl from 'mapbox-gl';
|
||||
|
||||
enableMapSet();
|
||||
enablePatches();
|
||||
@@ -80,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),
|
||||
@@ -111,7 +111,6 @@ export const settings = {
|
||||
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
|
||||
@@ -181,7 +180,7 @@ function dexieGPXFileStore(id: string): Readable<GPXFileWithStatistics> & { dest
|
||||
|
||||
let statistics = new GPXStatisticsTree(gpx);
|
||||
if (!fileState.has(id)) { // Update the map bounds for new files
|
||||
updateTargetMapBounds(statistics.getStatisticsFor(new ListFileItem(id)).global.bounds);
|
||||
updateTargetMapBounds(id, statistics.getStatisticsFor(new ListFileItem(id)).global.bounds);
|
||||
}
|
||||
|
||||
fileState.set(id, gpx);
|
||||
@@ -288,12 +287,12 @@ export const fileObservers: Writable<Map<string, Readable<GPXFileWithStatistics
|
||||
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() {
|
||||
export function observeFilesFromDatabase(fitBounds: boolean) {
|
||||
let initialize = true;
|
||||
liveQuery(() => db.fileids.toArray()).subscribe(dbFileIds => {
|
||||
if (initialize) {
|
||||
if (dbFileIds.length > 0) {
|
||||
initTargetMapBounds(dbFileIds.length);
|
||||
if (fitBounds && dbFileIds.length > 0) {
|
||||
initTargetMapBounds(dbFileIds);
|
||||
}
|
||||
initialize = false;
|
||||
}
|
||||
@@ -454,13 +453,14 @@ export const dbUtils = {
|
||||
});
|
||||
},
|
||||
addMultiple: (files: GPXFile[]) => {
|
||||
return applyGlobal((draft) => {
|
||||
let ids = getFileIds(files.length);
|
||||
let ids = getFileIds(files.length);
|
||||
applyGlobal((draft) => {
|
||||
files.forEach((file, index) => {
|
||||
file._data.id = ids[index];
|
||||
draft.set(file._data.id, freeze(file));
|
||||
});
|
||||
});
|
||||
return ids;
|
||||
},
|
||||
applyToFile: (id: string, callback: (file: WritableDraft<GPXFile>) => void) => {
|
||||
applyToFiles([id], callback);
|
||||
@@ -513,8 +513,17 @@ export const dbUtils = {
|
||||
});
|
||||
});
|
||||
},
|
||||
addNewTrack: (fileId: string) => {
|
||||
dbUtils.applyToFile(fileId, (file) => file.replaceTracks(file.trk.length, file.trk.length, [new Track()]));
|
||||
},
|
||||
addNewSegment: (fileId: string, trackIndex: number) => {
|
||||
dbUtils.applyToFile(fileId, (file) => {
|
||||
let track = file.trk[trackIndex];
|
||||
track.replaceTrackSegments(track.trkseg.length, track.trkseg.length, [new TrackSegment()]);
|
||||
});
|
||||
},
|
||||
reverseSelection: () => {
|
||||
if (!get(selection).hasAnyChildren(new ListRootItem(), true, ['waypoints'])) {
|
||||
if (!get(selection).hasAnyChildren(new ListRootItem(), true, ['waypoints']) || get(gpxStatistics).local.points?.length <= 1) {
|
||||
return;
|
||||
}
|
||||
applyGlobal((draft) => {
|
||||
@@ -912,29 +921,30 @@ export const dbUtils = {
|
||||
if (m === null) {
|
||||
return;
|
||||
}
|
||||
let ele = getElevation(m, waypoint.attributes);
|
||||
if (item) {
|
||||
dbUtils.applyToFile(item.getFileId(), (file) => {
|
||||
let wpt = file.wpt[item.getWaypointIndex()];
|
||||
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;
|
||||
});
|
||||
} else {
|
||||
let fileIds = new Set<string>();
|
||||
get(selection).getSelected().forEach((item) => {
|
||||
fileIds.add(item.getFileId());
|
||||
});
|
||||
let wpt = new Waypoint(waypoint);
|
||||
wpt.ele = ele;
|
||||
dbUtils.applyToFiles(Array.from(fileIds), (file) =>
|
||||
file.replaceWaypoints(file.wpt.length, file.wpt.length, [wpt])
|
||||
);
|
||||
}
|
||||
getElevation([waypoint.attributes]).then((elevation) => {
|
||||
if (item) {
|
||||
dbUtils.applyToFile(item.getFileId(), (file) => {
|
||||
let wpt = file.wpt[item.getWaypointIndex()];
|
||||
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 = elevation[0];
|
||||
});
|
||||
} else {
|
||||
let fileIds = new Set<string>();
|
||||
get(selection).getSelected().forEach((item) => {
|
||||
fileIds.add(item.getFileId());
|
||||
});
|
||||
let wpt = new Waypoint(waypoint);
|
||||
wpt.ele = elevation[0];
|
||||
dbUtils.applyToFiles(Array.from(fileIds), (file) =>
|
||||
file.replaceWaypoints(file.wpt.length, file.wpt.length, [wpt])
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
setStyleToSelection: (style: LineStyleExtension) => {
|
||||
if (get(selection).size === 0) {
|
||||
@@ -1022,6 +1032,66 @@ export const dbUtils = {
|
||||
});
|
||||
});
|
||||
},
|
||||
addElevationToSelection: async (map: mapboxgl.Map) => {
|
||||
if (get(selection).size === 0) {
|
||||
return;
|
||||
}
|
||||
let points: (TrackPoint | Waypoint)[] = [];
|
||||
applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
|
||||
let file = fileState.get(fileId);
|
||||
if (file) {
|
||||
if (level === ListLevel.FILE) {
|
||||
points.push(...file.getTrackPoints());
|
||||
points.push(...file.wpt);
|
||||
} else if (level === ListLevel.TRACK) {
|
||||
let trackIndices = items.map((item) => (item as ListTrackItem).getTrackIndex());
|
||||
trackIndices.forEach((trackIndex) => {
|
||||
points.push(...file.trk[trackIndex].getTrackPoints());
|
||||
});
|
||||
} else if (level === ListLevel.SEGMENT) {
|
||||
let trackIndex = (items[0] as ListTrackSegmentItem).getTrackIndex();
|
||||
let segmentIndices = items.map((item) => (item as ListTrackSegmentItem).getSegmentIndex());
|
||||
segmentIndices.forEach((segmentIndex) => {
|
||||
points.push(...file.trk[trackIndex].trkseg[segmentIndex].getTrackPoints());
|
||||
});
|
||||
} else if (level === ListLevel.WAYPOINTS) {
|
||||
points.push(...file.wpt);
|
||||
} else if (level === ListLevel.WAYPOINT) {
|
||||
let waypointIndices = items.map((item) => (item as ListWaypointItem).getWaypointIndex());
|
||||
points.push(...waypointIndices.map((waypointIndex) => file.wpt[waypointIndex]));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (points.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
getElevation(points).then((elevations) => {
|
||||
applyGlobal((draft) => {
|
||||
applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
|
||||
let file = draft.get(fileId);
|
||||
if (file) {
|
||||
if (level === ListLevel.FILE) {
|
||||
file.addElevation(elevations);
|
||||
} else if (level === ListLevel.TRACK) {
|
||||
let trackIndices = items.map((item) => (item as ListTrackItem).getTrackIndex());
|
||||
file.addElevation(elevations, trackIndices, undefined, []);
|
||||
} else if (level === ListLevel.SEGMENT) {
|
||||
let trackIndices = [(items[0] as ListTrackSegmentItem).getTrackIndex()];
|
||||
let segmentIndices = items.map((item) => (item as ListTrackSegmentItem).getSegmentIndex());
|
||||
file.addElevation(elevations, trackIndices, segmentIndices, []);
|
||||
} else if (level === ListLevel.WAYPOINTS) {
|
||||
file.addElevation(elevations, [], [], undefined);
|
||||
} else if (level === ListLevel.WAYPOINT) {
|
||||
let waypointIndices = items.map((item) => (item as ListWaypointItem).getWaypointIndex());
|
||||
file.addElevation(elevations, [], [], waypointIndices);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
deleteSelectedFiles: () => {
|
||||
if (get(selection).size === 0) {
|
||||
return;
|
||||
|
35
website/src/lib/docs/be/faq.mdx
Normal file
35
website/src/lib/docs/be/faq.mdx
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
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>
|
82
website/src/lib/docs/be/files-and-stats.mdx
Normal file
82
website/src/lib/docs/be/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.
|
37
website/src/lib/docs/be/getting-started.mdx
Normal file
37
website/src/lib/docs/be/getting-started.mdx
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
title: Getting started
|
||||
---
|
||||
|
||||
<script lang="ts">
|
||||
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="getting-started/interface" 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/be/gpx.mdx
Normal file
34
website/src/lib/docs/be/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/be/home/funding.mdx
Normal file
13
website/src/lib/docs/be/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/be/home/mapbox.mdx
Normal file
5
website/src/lib/docs/be/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/be/home/translation.mdx
Normal file
12
website/src/lib/docs/be/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>.
|
||||
|
||||
Any help is greatly appreciated!
|
27
website/src/lib/docs/be/integration.mdx
Normal file
27
website/src/lib/docs/be/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 />
|
70
website/src/lib/docs/be/map-controls.mdx
Normal file
70
website/src/lib/docs/be/map-controls.mdx
Normal file
@@ -0,0 +1,70 @@
|
||||
---
|
||||
title: Map controls
|
||||
---
|
||||
|
||||
<script>
|
||||
import { Plus, Minus, Diff, Compass, Search, LocateFixed, PersonStanding, Layers } from 'lucide-svelte';
|
||||
import DocsNote from '$lib/components/docs/DocsNote.svelte';
|
||||
import DocsLayers from '$lib/components/docs/DocsLayers.svelte';
|
||||
</script>
|
||||
|
||||
# { title }
|
||||
|
||||
The map controls are located on the right side of the interface.
|
||||
These controls allow you to navigate the map, zoom in and out, and switch between different map styles.
|
||||
|
||||
### <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.
|
||||
|
||||
### <Layers 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/be/menu.mdx
Normal file
17
website/src/lib/docs/be/menu.mdx
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
title: Menu
|
||||
---
|
||||
|
||||
<script lang="ts">
|
||||
import DocsNote from '$lib/components/docs/DocsNote.svelte';
|
||||
</script>
|
||||
|
||||
# { title }
|
||||
|
||||
Асноўнае меню, размешчанае ў верхняй частцы інтэрфэйсу, забяспечвае доступ да дзеянняў, опцый і налад, падзеленых на некалькі катэгорый, якія тлумачацца асобна ў наступных раздзелах.
|
||||
|
||||
<DocsNote>
|
||||
|
||||
Большасць з дзеянняў таксама можа быць выклікана з дапамогай спалучэння клавіш адлюстраваных у меню.
|
||||
|
||||
</DocsNote>
|
96
website/src/lib/docs/be/menu/edit.mdx
Normal file
96
website/src/lib/docs/be/menu/edit.mdx
Normal file
@@ -0,0 +1,96 @@
|
||||
---
|
||||
title: Edit actions
|
||||
---
|
||||
|
||||
<script lang="ts">
|
||||
import { Undo2, Redo2, Info, PaintBucket, EyeOff, FileStack, ClipboardCopy, Scissors, ClipboardPaste, Trash2, Maximize, Plus } 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.
|
||||
|
||||
### <Plus size="16" class="inline-block" style="margin-bottom: 2px" /> New track
|
||||
|
||||
Create a new track in the selected file.
|
||||
|
||||
<DocsNote>
|
||||
|
||||
This action is only available when the vertical layout of the files list is enabled.
|
||||
Additionally, the selection must be a single file.
|
||||
|
||||
</DocsNote>
|
||||
|
||||
### <Plus size="16" class="inline-block" style="margin-bottom: 2px" /> New segment
|
||||
|
||||
Create a new segment in the selected track.
|
||||
|
||||
<DocsNote>
|
||||
|
||||
This action is only available when the vertical layout of the files list is enabled.
|
||||
Additionally, the selection must be a single track.
|
||||
|
||||
</DocsNote>
|
||||
|
||||
### <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/be/menu/file.mdx
Normal file
52
website/src/lib/docs/be/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/be/menu/settings.mdx
Normal file
50
website/src/lib/docs/be/menu/settings.mdx
Normal file
@@ -0,0 +1,50 @@
|
||||
---
|
||||
title: Settings
|
||||
---
|
||||
|
||||
<script lang="ts">
|
||||
import { Ruler, Zap, Thermometer, Languages, Sun, PersonStanding, Layers } 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>.
|
||||
Any help is greatly appreciated!
|
||||
|
||||
</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).
|
||||
|
||||
### <Layers 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/be/menu/view.mdx
Normal file
48
website/src/lib/docs/be/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/be/toolbar.mdx
Normal file
32
website/src/lib/docs/be/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 }
|
||||
|
||||
Панэль інструментаў знаходзіцца з левага боку мапы і з'яўляецца сэрцам прыкладання, яна забяспечвае доступ да асноўных функцый **gpx.studio**.
|
||||
Кожны інструмент прадстаўлены цэтлікам і можа быць актываваны пстрыкам на яго.
|
||||
|
||||
<div class="flex flex-row justify-center text-foreground">
|
||||
<div>
|
||||
<Toolbar class="border rounded-md shadow-lg" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
З дапамогай [edit actions](./menu/edit), большасць інструментаў можа быць прыменена да некалькіх файлаў адначасова, а таксама для [inner tracks and segments](./gpx).
|
||||
|
||||
Наступныя секцыі апісваюць кожны інструмент больш дэтальна.
|
18
website/src/lib/docs/be/toolbar/clean.mdx
Normal file
18
website/src/lib/docs/be/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>
|
24
website/src/lib/docs/be/toolbar/elevation.mdx
Normal file
24
website/src/lib/docs/be/toolbar/elevation.mdx
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
title: Elevation
|
||||
---
|
||||
|
||||
<script>
|
||||
import { MountainSnow } from 'lucide-svelte';
|
||||
import Elevation from '$lib/components/toolbar/tools/Elevation.svelte';
|
||||
import DocsNote from '$lib/components/docs/DocsNote.svelte';
|
||||
</script>
|
||||
|
||||
# <MountainSnow size="24" class="inline-block" style="margin-bottom: 5px" /> { title }
|
||||
|
||||
This tool allows you to add elevation data to traces and [points of interest](../gpx), or to replace the existing data.
|
||||
|
||||
<div class="flex flex-row justify-center">
|
||||
<Elevation class="text-foreground p-3 border rounded-md shadow-lg" />
|
||||
</div>
|
||||
|
||||
<DocsNote>
|
||||
|
||||
Elevation data is provided by <a href="https://mapbox.com" target="_blank">Mapbox</a>.
|
||||
You can learn more about its origin and accuracy in the <a href="https://docs.mapbox.com/data/tilesets/reference/mapbox-terrain-dem-v1/#elevation-data" target="_blank">documentation</a>.
|
||||
|
||||
</DocsNote>
|
26
website/src/lib/docs/be/toolbar/extract.mdx
Normal file
26
website/src/lib/docs/be/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/be/toolbar/merge.mdx
Normal file
20
website/src/lib/docs/be/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/be/toolbar/minify.mdx
Normal file
26
website/src/lib/docs/be/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/be/toolbar/poi.mdx
Normal file
27
website/src/lib/docs/be/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.
|
84
website/src/lib/docs/be/toolbar/routing.mdx
Normal file
84
website/src/lib/docs/be/toolbar/routing.mdx
Normal file
@@ -0,0 +1,84 @@
|
||||
---
|
||||
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 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="tools/routing" 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.
|
32
website/src/lib/docs/be/toolbar/scissors.mdx
Normal file
32
website/src/lib/docs/be/toolbar/scissors.mdx
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
title: Crop and split
|
||||
---
|
||||
|
||||
<script>
|
||||
import { ScissorsIcon } from 'lucide-svelte';
|
||||
import Scissors from '$lib/components/toolbar/tools/scissors/Scissors.svelte';
|
||||
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="tools/split" alt="Hovering over the selected trace turns your cursor into scissors." />
|
27
website/src/lib/docs/be/toolbar/time.mdx
Normal file
27
website/src/lib/docs/be/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>
|
35
website/src/lib/docs/ca/faq.mdx
Normal file
35
website/src/lib/docs/ca/faq.mdx
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
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>
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user