Compare commits
254 commits
layer-hove
...
main
Author | SHA1 | Date | |
---|---|---|---|
![]() |
c8e09fcd90 | ||
![]() |
c5f20d323c | ||
![]() |
e3dcdf2f41 | ||
![]() |
82d8b5d61e | ||
![]() |
47692656e4 | ||
![]() |
b5bf06b37a | ||
![]() |
bc3b1e5f7c | ||
![]() |
63eae15191 | ||
![]() |
848b6dcef3 | ||
![]() |
dfcdd71057 | ||
![]() |
7368945bf3 | ||
![]() |
de52203e89 | ||
![]() |
62d1a3e01f | ||
![]() |
68b4ecadf5 | ||
![]() |
7831774703 | ||
![]() |
52984d4b70 | ||
![]() |
0b457f9a1e | ||
![]() |
01cfd448f0 | ||
![]() |
c189ebd8ca | ||
![]() |
b35d11c9ed | ||
![]() |
5fa5908072 | ||
![]() |
a89f2754d3 | ||
![]() |
453ae55db0 | ||
![]() |
c1a5bdd7ae | ||
![]() |
d19e702084 | ||
![]() |
e02a22eaea | ||
![]() |
63f3d63518 | ||
![]() |
0d03ebfe96 | ||
![]() |
074da855c1 | ||
![]() |
9a028b9d5d | ||
![]() |
a502980a39 | ||
![]() |
1a1f6e5131 | ||
![]() |
a67501e6de | ||
![]() |
1f4164aca3 | ||
![]() |
25e05f9855 | ||
![]() |
1555799533 | ||
![]() |
93d5211d27 | ||
![]() |
de38fea917 | ||
![]() |
3a3bc1c0db | ||
![]() |
b5871d974e | ||
![]() |
2985c3201b | ||
![]() |
cf2fcd8221 | ||
![]() |
2cb21a43c5 | ||
![]() |
bae0a3f93b | ||
![]() |
a853c45ec7 | ||
![]() |
6cb6c88cd1 | ||
![]() |
077f2b4435 | ||
![]() |
8c3c4860f8 | ||
![]() |
143592f724 | ||
![]() |
dc404706c5 | ||
![]() |
7a80e9e104 | ||
![]() |
d7aae81c41 | ||
![]() |
745c7e8470 | ||
![]() |
52623350bd | ||
![]() |
7d2c030ebd | ||
![]() |
b841326e19 | ||
![]() |
23c41f18de | ||
![]() |
44e11e1a51 | ||
![]() |
798d8e7a14 | ||
![]() |
29748cf114 | ||
![]() |
f5794b1355 | ||
![]() |
4ada271ad3 | ||
![]() |
1bda957778 | ||
![]() |
95328db1ee | ||
![]() |
65cbf5e751 | ||
![]() |
60f24f8757 | ||
![]() |
d1e4588813 | ||
![]() |
711825f5a3 | ||
![]() |
d823f44558 | ||
![]() |
193c77e51a | ||
![]() |
45f6c405c0 | ||
![]() |
78e1ea1e59 | ||
![]() |
ca51e1d788 | ||
![]() |
72b0b5a706 | ||
![]() |
4d1de97ba5 | ||
![]() |
04769002d0 | ||
![]() |
8190284148 | ||
![]() |
a668b4be62 | ||
![]() |
87534468d2 | ||
![]() |
d7a02f714a | ||
![]() |
0c16ddd534 | ||
![]() |
a96f989199 | ||
![]() |
35c7c9d965 | ||
![]() |
efa05b93ce | ||
![]() |
0e298cd0e4 | ||
![]() |
3ef98b2110 | ||
![]() |
fbf93ed6f9 | ||
![]() |
1bd56b6505 | ||
![]() |
acf0750ccb | ||
![]() |
48eaa344e4 | ||
![]() |
3262dec7d3 | ||
![]() |
572d206c2c | ||
![]() |
11934e5825 | ||
![]() |
5cca106d18 | ||
![]() |
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 |
708 changed files with 44422 additions and 34406 deletions
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
|
||||
|
||||
|
|
16
.prettierrc
Normal file
16
.prettierrc
Normal file
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"useTabs": false,
|
||||
"tabWidth": 4,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "**/*.svelte",
|
||||
"options": {
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"parser": "svelte"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
7
.vscode/extensions.json
vendored
Normal file
7
.vscode/extensions.json
vendored
Normal file
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"recommendations": [
|
||||
"esbenp.prettier-vscode",
|
||||
"svelte.svelte-vscode"
|
||||
]
|
||||
}
|
||||
|
13
.vscode/settings.json
vendored
Normal file
13
.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"editor.formatOnSave": true,
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[svelte]": {
|
||||
"editor.defaultFormatter": "svelte.svelte-vscode"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
1
gpx/.prettierignore
Normal file
1
gpx/.prettierignore
Normal file
|
@ -0,0 +1 @@
|
|||
package-lock.json
|
1633
gpx/package-lock.json
generated
1633
gpx/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -11,16 +11,21 @@
|
|||
},
|
||||
"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-eslint/parser": "^8.22.0",
|
||||
"prettier": "^3.4.2",
|
||||
"typescript": "^5.6.2"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"postinstall": "npm run build",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"format": "prettier --write ."
|
||||
}
|
||||
}
|
1241
gpx/src/gpx.ts
1241
gpx/src/gpx.ts
File diff suppressed because it is too large
Load diff
|
@ -2,4 +2,3 @@ export * from './gpx';
|
|||
export { Coordinates, LineStyleExtension, WaypointType } from './types';
|
||||
export { parseGPX, buildGPX } from './io';
|
||||
export * from './simplify';
|
||||
|
||||
|
|
106
gpx/src/io.ts
106
gpx/src/io.ts
|
@ -1,14 +1,40 @@
|
|||
import { XMLParser, XMLBuilder } from "fast-xml-parser";
|
||||
import { GPXFileType } from "./types";
|
||||
import { GPXFile } from "./gpx";
|
||||
import { XMLParser, XMLBuilder } from 'fast-xml-parser';
|
||||
import { GPXFileType } from './types';
|
||||
import { GPXFile } from './gpx';
|
||||
|
||||
const attributesWithNamespace = {
|
||||
RoutePointExtension: 'gpxx:RoutePointExtension',
|
||||
rpt: 'gpxx:rpt',
|
||||
TrackPointExtension: 'gpxtpx:TrackPointExtension',
|
||||
PowerExtension: 'gpxpx:PowerExtension',
|
||||
atemp: 'gpxtpx:atemp',
|
||||
hr: 'gpxtpx:hr',
|
||||
cad: 'gpxtpx:cad',
|
||||
Extensions: 'gpxtpx:Extensions',
|
||||
PowerInWatts: 'gpxpx:PowerInWatts',
|
||||
power: 'gpxpx:PowerExtension',
|
||||
line: 'gpx_style:line',
|
||||
color: 'gpx_style:color',
|
||||
opacity: 'gpx_style:opacity',
|
||||
width: 'gpx_style:width',
|
||||
};
|
||||
|
||||
export function parseGPX(gpxData: string): GPXFile {
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: "",
|
||||
attributeNamePrefix: '',
|
||||
attributesGroupName: 'attributes',
|
||||
removeNSPrefix: true,
|
||||
isArray(name: string) {
|
||||
return name === 'trk' || name === 'trkseg' || name === 'trkpt' || name === 'wpt' || name === 'rte' || name === 'rtept' || name === 'gpxx:rpt';
|
||||
return (
|
||||
name === 'trk' ||
|
||||
name === 'trkseg' ||
|
||||
name === 'trkpt' ||
|
||||
name === 'wpt' ||
|
||||
name === 'rte' ||
|
||||
name === 'rtept' ||
|
||||
name === 'gpxx:rpt'
|
||||
);
|
||||
},
|
||||
attributeValueProcessor(attrName, attrValue, jPath) {
|
||||
if (attrName === 'lat' || attrName === 'lon') {
|
||||
|
@ -17,9 +43,8 @@ export function parseGPX(gpxData: string): GPXFile {
|
|||
return attrValue;
|
||||
},
|
||||
transformTagName(tagName: string) {
|
||||
if (tagName === 'power') {
|
||||
// Transform the simple <power> tag to the more complex <gpxpx:PowerExtension> tag, the nested <gpxpx:PowerInWatts> tag is then handled by the tagValueProcessor
|
||||
return 'gpxpx:PowerExtension';
|
||||
if (attributesWithNamespace[tagName]) {
|
||||
return attributesWithNamespace[tagName];
|
||||
}
|
||||
return tagName;
|
||||
},
|
||||
|
@ -34,7 +59,14 @@ 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 === 'gpx_style:opacity' ||
|
||||
tagName === 'gpx_style:width'
|
||||
) {
|
||||
return parseFloat(tagValue);
|
||||
}
|
||||
|
||||
|
@ -42,7 +74,7 @@ export function parseGPX(gpxData: string): GPXFile {
|
|||
// Finish the transformation of the simple <power> tag to the more complex <gpxpx:PowerExtension> tag
|
||||
// Note that this only targets the transformed <power> tag, since it must be a leaf node
|
||||
return {
|
||||
'gpxpx:PowerInWatts': parseFloat(tagValue)
|
||||
'gpxpx:PowerInWatts': parseFloat(tagValue),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -54,7 +86,7 @@ export function parseGPX(gpxData: string): GPXFile {
|
|||
const parsed: GPXFileType = parser.parse(gpxData).gpx;
|
||||
|
||||
// @ts-ignore
|
||||
if (parsed.metadata === "") {
|
||||
if (parsed.metadata === '') {
|
||||
parsed.metadata = {};
|
||||
}
|
||||
|
||||
|
@ -64,49 +96,67 @@ export function parseGPX(gpxData: string): GPXFile {
|
|||
export function buildGPX(file: GPXFile, exclude: string[]): string {
|
||||
const gpx = file.toGPXFileType(exclude);
|
||||
|
||||
let lastDate = undefined;
|
||||
const builder = new XMLBuilder({
|
||||
format: true,
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: "",
|
||||
attributeNamePrefix: '',
|
||||
attributesGroupName: 'attributes',
|
||||
suppressEmptyNode: true,
|
||||
tagValueProcessor: (tagName: string, tagValue: unknown): string => {
|
||||
tagValueProcessor: (tagName: string, tagValue: unknown): string | undefined => {
|
||||
if (tagValue instanceof Date) {
|
||||
if (isNaN(tagValue.getTime())) {
|
||||
return lastDate?.toISOString();
|
||||
}
|
||||
lastDate = tagValue;
|
||||
return tagValue.toISOString();
|
||||
}
|
||||
return tagValue.toString();
|
||||
},
|
||||
});
|
||||
|
||||
gpx.attributes.creator = gpx.attributes.creator ?? 'https://gpx.studio';
|
||||
if (!gpx.attributes) gpx.attributes = {};
|
||||
gpx.attributes['creator'] = gpx.attributes['creator'] ?? 'https://gpx.studio';
|
||||
gpx.attributes['version'] = '1.1';
|
||||
gpx.attributes['xmlns'] = 'http://www.topografix.com/GPX/1/1';
|
||||
gpx.attributes['xmlns:xsi'] = 'http://www.w3.org/2001/XMLSchema-instance';
|
||||
gpx.attributes['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.garmin.com/xmlschemas/PowerExtension/v1 http://www.garmin.com/xmlschemas/PowerExtensionv1.xsd http://www.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd';
|
||||
gpx.attributes['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.garmin.com/xmlschemas/PowerExtension/v1 http://www.garmin.com/xmlschemas/PowerExtensionv1.xsd http://www.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd';
|
||||
gpx.attributes['xmlns:gpxtpx'] = 'http://www.garmin.com/xmlschemas/TrackPointExtension/v1';
|
||||
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;
|
||||
}
|
||||
|
||||
return builder.build({
|
||||
"?xml": {
|
||||
'?xml': {
|
||||
attributes: {
|
||||
version: "1.0",
|
||||
encoding: "UTF-8",
|
||||
}
|
||||
version: '1.0',
|
||||
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;
|
||||
}
|
||||
|
|
|
@ -1,33 +1,48 @@
|
|||
import { TrackPoint } from "./gpx";
|
||||
import { Coordinates } from "./types";
|
||||
import { TrackPoint } from './gpx';
|
||||
import { Coordinates } from './types';
|
||||
|
||||
export type SimplifiedTrackPoint = { point: TrackPoint, distance?: number };
|
||||
export type SimplifiedTrackPoint = { point: TrackPoint; distance?: number };
|
||||
|
||||
const earthRadius = 6371008.8;
|
||||
|
||||
export function ramerDouglasPeucker(points: TrackPoint[], epsilon: number = 50, measure: (a: TrackPoint, b: TrackPoint, c: TrackPoint) => number = crossarcDistance): SimplifiedTrackPoint[] {
|
||||
export function ramerDouglasPeucker(
|
||||
points: TrackPoint[],
|
||||
epsilon: number = 50,
|
||||
measure: (a: TrackPoint, b: TrackPoint, c: TrackPoint) => number = crossarcDistance
|
||||
): SimplifiedTrackPoint[] {
|
||||
if (points.length == 0) {
|
||||
return [];
|
||||
} else if (points.length == 1) {
|
||||
return [{
|
||||
point: points[0]
|
||||
}];
|
||||
return [
|
||||
{
|
||||
point: points[0],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
let simplified = [{
|
||||
point: points[0]
|
||||
}];
|
||||
let simplified = [
|
||||
{
|
||||
point: points[0],
|
||||
},
|
||||
];
|
||||
ramerDouglasPeuckerRecursive(points, epsilon, measure, 0, points.length - 1, simplified);
|
||||
simplified.push({
|
||||
point: points[points.length - 1]
|
||||
point: points[points.length - 1],
|
||||
});
|
||||
return simplified;
|
||||
}
|
||||
|
||||
function ramerDouglasPeuckerRecursive(points: TrackPoint[], epsilon: number, measure: (a: TrackPoint, b: TrackPoint, c: TrackPoint) => number, start: number, end: number, simplified: SimplifiedTrackPoint[]) {
|
||||
function ramerDouglasPeuckerRecursive(
|
||||
points: TrackPoint[],
|
||||
epsilon: number,
|
||||
measure: (a: TrackPoint, b: TrackPoint, c: TrackPoint) => number,
|
||||
start: number,
|
||||
end: number,
|
||||
simplified: SimplifiedTrackPoint[]
|
||||
) {
|
||||
let largest = {
|
||||
index: 0,
|
||||
distance: 0
|
||||
distance: 0,
|
||||
};
|
||||
|
||||
for (let i = start + 1; i < end; i++) {
|
||||
|
@ -45,8 +60,16 @@ function ramerDouglasPeuckerRecursive(points: TrackPoint[], epsilon: number, mea
|
|||
}
|
||||
}
|
||||
|
||||
export function crossarcDistance(point1: TrackPoint, point2: TrackPoint, point3: TrackPoint | Coordinates): number {
|
||||
return crossarc(point1.getCoordinates(), point2.getCoordinates(), point3 instanceof TrackPoint ? point3.getCoordinates() : point3);
|
||||
export function crossarcDistance(
|
||||
point1: TrackPoint,
|
||||
point2: TrackPoint,
|
||||
point3: TrackPoint | Coordinates
|
||||
): number {
|
||||
return crossarc(
|
||||
point1.getCoordinates(),
|
||||
point2.getCoordinates(),
|
||||
point3 instanceof TrackPoint ? point3.getCoordinates() : point3
|
||||
);
|
||||
}
|
||||
|
||||
function crossarc(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates): number {
|
||||
|
@ -74,7 +97,7 @@ function crossarc(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates)
|
|||
}
|
||||
|
||||
// Is relative bearing obtuse?
|
||||
if (diff > (Math.PI / 2)) {
|
||||
if (diff > Math.PI / 2) {
|
||||
return dis13;
|
||||
}
|
||||
|
||||
|
@ -83,7 +106,8 @@ function crossarc(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates)
|
|||
|
||||
// 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;
|
||||
let dis14 =
|
||||
Math.acos(Math.cos(dis13 / earthRadius) / Math.cos(dxt / earthRadius)) * earthRadius;
|
||||
if (dis14 > dis12) {
|
||||
return distance(lat2, lon2, lat3, lon3);
|
||||
} else {
|
||||
|
@ -93,12 +117,85 @@ function crossarc(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates)
|
|||
|
||||
function distance(latA: number, lonA: number, latB: number, lonB: number): number {
|
||||
// Finds the distance between two lat / lon points.
|
||||
return Math.acos(Math.sin(latA) * Math.sin(latB) + Math.cos(latA) * Math.cos(latB) * Math.cos(lonB - lonA)) * earthRadius;
|
||||
return (
|
||||
Math.acos(
|
||||
Math.sin(latA) * Math.sin(latB) +
|
||||
Math.cos(latA) * Math.cos(latB) * Math.cos(lonB - lonA)
|
||||
) * earthRadius
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function bearing(latA: number, lonA: number, latB: number, lonB: number): number {
|
||||
// Finds the bearing from one lat / lon point to another.
|
||||
return Math.atan2(Math.sin(lonB - lonA) * Math.cos(latB),
|
||||
Math.cos(latA) * Math.sin(latB) - Math.sin(latA) * Math.cos(latB) * Math.cos(lonB - lonA));
|
||||
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 = {
|
||||
|
@ -67,9 +67,9 @@ export type TrackExtensions = {
|
|||
};
|
||||
|
||||
export type LineStyleExtension = {
|
||||
color?: string;
|
||||
opacity?: number;
|
||||
weight?: number;
|
||||
'gpx_style:color'?: string;
|
||||
'gpx_style:opacity'?: number;
|
||||
'gpx_style:width'?: number;
|
||||
};
|
||||
|
||||
export type TrackSegmentType = {
|
||||
|
@ -89,17 +89,15 @@ export type TrackPointExtensions = {
|
|||
};
|
||||
|
||||
export type TrackPointExtension = {
|
||||
'gpxtpx:atemp'?: number;
|
||||
'gpxtpx:hr'?: number;
|
||||
'gpxtpx:cad'?: number;
|
||||
'gpxtpx:atemp'?: number;
|
||||
'gpxtpx:Extensions'?: {
|
||||
surface?: string;
|
||||
};
|
||||
}
|
||||
'gpxtpx:Extensions'?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type PowerExtension = {
|
||||
'gpxpx:PowerInWatts'?: number;
|
||||
}
|
||||
};
|
||||
|
||||
export type Author = {
|
||||
name?: string;
|
||||
|
@ -116,12 +114,12 @@ export type RouteType = {
|
|||
type?: string;
|
||||
extensions?: TrackExtensions;
|
||||
rtept: WaypointType[];
|
||||
}
|
||||
};
|
||||
|
||||
export type RoutePointExtension = {
|
||||
'gpxx:rpt'?: GPXXRoutePoint[];
|
||||
}
|
||||
};
|
||||
|
||||
export type GPXXRoutePoint = {
|
||||
attributes: Coordinates;
|
||||
}
|
||||
};
|
||||
|
|
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>
|
|
@ -16,9 +16,9 @@
|
|||
<type>Cycling</type>
|
||||
<extensions>
|
||||
<gpx_style:line>
|
||||
<color>#2d3ee9</color>
|
||||
<opacity>0.5</opacity>
|
||||
<weight>6</weight>
|
||||
<gpx_style:color>2d3ee9</gpx_style:color>
|
||||
<gpx_style:opacity>0.5</gpx_style:opacity>
|
||||
<gpx_style:width>6</gpx_style:width>
|
||||
</gpx_style:line>
|
||||
</extensions>
|
||||
<trkseg>
|
||||
|
|
|
@ -4,9 +4,7 @@
|
|||
"target": "ES2015",
|
||||
"declaration": true,
|
||||
"outDir": "./dist",
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "node"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"include": ["src"]
|
||||
}
|
|
@ -5,27 +5,27 @@ module.exports = {
|
|||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:svelte/recommended',
|
||||
'prettier'
|
||||
'prettier',
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['@typescript-eslint'],
|
||||
parserOptions: {
|
||||
sourceType: 'module',
|
||||
ecmaVersion: 2020,
|
||||
extraFileExtensions: ['.svelte']
|
||||
extraFileExtensions: ['.svelte'],
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
es2017: true,
|
||||
node: true
|
||||
node: true,
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ['*.svelte'],
|
||||
parser: 'svelte-eslint-parser',
|
||||
parserOptions: {
|
||||
parser: '@typescript-eslint/parser'
|
||||
}
|
||||
}
|
||||
]
|
||||
parser: '@typescript-eslint/parser',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
@ -2,3 +2,5 @@
|
|||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
src/lib/components/ui
|
||||
*.mdx
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||
}
|
4833
website/package-lock.json
generated
4833
website/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -13,61 +13,70 @@
|
|||
"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/file-saver": "^2.0.7",
|
||||
"@types/mapbox__mapbox-gl-geocoder": "^5.0.0",
|
||||
"@types/mapbox-gl": "^3.1.0",
|
||||
"@types/node": "^20.14.6",
|
||||
"@types/sanitize-html": "^2.11.0",
|
||||
"@types/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",
|
||||
"file-saver": "^2.0.5",
|
||||
"gpx": "file:../gpx",
|
||||
"immer": "^10.1.1",
|
||||
"lucide-static": "^0.427.0",
|
||||
"lucide-svelte": "^0.427.0",
|
||||
"mapbox-gl": "^3.4.0",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-static": "^0.460.0",
|
||||
"lucide-svelte": "^0.460.1",
|
||||
"mapbox-gl": "^3.9.1",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,4 +3,4 @@ export default {
|
|||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,15 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<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>
|
||||
</head>
|
||||
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
|
||||
</body>
|
||||
</html>
|
|
@ -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,7 +70,9 @@
|
|||
|
||||
--support: 255 110 190;
|
||||
|
||||
--ring: hsl(212.7,26.8%,83.9);
|
||||
--link: 80 190 255;
|
||||
|
||||
--ring: hsl(212.7, 26.8%, 83.9);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
54
website/src/hooks.server.js
Normal file
54
website/src/hooks.server.js
Normal file
|
@ -0,0 +1,54 @@
|
|||
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 response = await resolve(event, {
|
||||
transformPageChunk: ({ html }) =>
|
||||
html.replace('<html>', htmlTag).replace('<head>', headTag),
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
171
website/src/lib/assets/colors.ts
Normal file
171
website/src/lib/assets/colors.ts
Normal file
|
@ -0,0 +1,171 @@
|
|||
export const surfaceColors: { [key: string]: string } = {
|
||||
missing: '#d1d1d1',
|
||||
paved: '#8c8c8c',
|
||||
unpaved: '#6b443a',
|
||||
asphalt: '#8c8c8c',
|
||||
concrete: '#8c8c8c',
|
||||
cobblestone: '#ffd991',
|
||||
paving_stones: '#8c8c8c',
|
||||
sett: '#ffd991',
|
||||
metal: '#8c8c8c',
|
||||
wood: '#6b443a',
|
||||
compacted: '#ffffa8',
|
||||
fine_gravel: '#ffffa8',
|
||||
gravel: '#ffffa8',
|
||||
pebblestone: '#ffffa8',
|
||||
rock: '#ffd991',
|
||||
dirt: '#ffffa8',
|
||||
ground: '#6b443a',
|
||||
earth: '#6b443a',
|
||||
mud: '#6b443a',
|
||||
sand: '#ffffc4',
|
||||
grass: '#61b55c',
|
||||
grass_paver: '#61b55c',
|
||||
clay: '#6b443a',
|
||||
stone: '#ffd991',
|
||||
};
|
||||
|
||||
export function getSurfaceColor(surface: string): string {
|
||||
return surfaceColors[surface] ? surfaceColors[surface] : surfaceColors.missing;
|
||||
}
|
||||
|
||||
export const highwayColors: { [key: string]: string } = {
|
||||
missing: '#d1d1d1',
|
||||
motorway: '#ff4d33',
|
||||
motorway_link: '#ff4d33',
|
||||
trunk: '#ff5e4d',
|
||||
trunk_link: '#ff947f',
|
||||
primary: '#ff6e5c',
|
||||
primary_link: '#ff6e5c',
|
||||
secondary: '#ff8d7b',
|
||||
secondary_link: '#ff8d7b',
|
||||
tertiary: '#ffd75f',
|
||||
tertiary_link: '#ffd75f',
|
||||
unclassified: '#f1f2a5',
|
||||
road: '#f1f2a5',
|
||||
residential: '#73b2ff',
|
||||
living_street: '#73b2ff',
|
||||
service: '#9c9cd9',
|
||||
track: '#a8e381',
|
||||
footway: '#a8e381',
|
||||
path: '#a8e381',
|
||||
pedestrian: '#a8e381',
|
||||
cycleway: '#9de2ff',
|
||||
construction: '#e09a4a',
|
||||
bridleway: '#946f43',
|
||||
raceway: '#ff0000',
|
||||
rest_area: '#9c9cd9',
|
||||
services: '#9c9cd9',
|
||||
corridor: '#474747',
|
||||
elevator: '#474747',
|
||||
steps: '#474747',
|
||||
bus_stop: '#8545a3',
|
||||
busway: '#8545a3',
|
||||
via_ferrata: '#474747',
|
||||
};
|
||||
|
||||
export const sacScaleColors: { [key: string]: string } = {
|
||||
hiking: '#007700',
|
||||
mountain_hiking: '#1843ad',
|
||||
demanding_mountain_hiking: '#ffff00',
|
||||
alpine_hiking: '#ff9233',
|
||||
demanding_alpine_hiking: '#ff0000',
|
||||
difficult_alpine_hiking: '#000000',
|
||||
};
|
||||
|
||||
export const mtbScaleColors: { [key: string]: string } = {
|
||||
'0-': '#007700',
|
||||
'0': '#007700',
|
||||
'0+': '#007700',
|
||||
'1-': '#1843ad',
|
||||
'1': '#1843ad',
|
||||
'1+': '#1843ad',
|
||||
'2-': '#ffff00',
|
||||
'2': '#ffff00',
|
||||
'2+': '#ffff00',
|
||||
'3': '#ff0000',
|
||||
'4': '#00ff00',
|
||||
'5': '#000000',
|
||||
'6': '#b105eb',
|
||||
};
|
||||
|
||||
function createPattern(
|
||||
backgroundColor: string,
|
||||
sacScaleColor: string | undefined,
|
||||
mtbScaleColor: string | undefined,
|
||||
size: number = 16,
|
||||
lineWidth: number = 4
|
||||
) {
|
||||
let canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
let ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.fillStyle = backgroundColor;
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
ctx.lineWidth = lineWidth;
|
||||
|
||||
const halfSize = size / 2;
|
||||
const halfLineWidth = lineWidth / 2;
|
||||
if (sacScaleColor) {
|
||||
ctx.strokeStyle = sacScaleColor;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(halfSize - halfLineWidth, -halfLineWidth);
|
||||
ctx.lineTo(size + halfLineWidth, halfSize + halfLineWidth);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-halfLineWidth, halfSize - halfLineWidth);
|
||||
ctx.lineTo(halfSize + halfLineWidth, size + halfLineWidth);
|
||||
ctx.stroke();
|
||||
}
|
||||
if (mtbScaleColor) {
|
||||
ctx.strokeStyle = mtbScaleColor;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(halfSize - halfLineWidth, size + halfLineWidth);
|
||||
ctx.lineTo(size + halfLineWidth, halfSize - halfLineWidth);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-halfLineWidth, halfSize + halfLineWidth);
|
||||
ctx.lineTo(halfSize + halfLineWidth, -halfLineWidth);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
return ctx?.createPattern(canvas, 'repeat') || backgroundColor;
|
||||
}
|
||||
|
||||
const patterns: Record<string, string | CanvasPattern> = {};
|
||||
export function getHighwayColor(
|
||||
highway: string,
|
||||
sacScale: string | undefined,
|
||||
mtbScale: string | undefined
|
||||
) {
|
||||
let backgroundColor = highwayColors[highway] ? highwayColors[highway] : highwayColors.missing;
|
||||
let sacScaleColor = sacScale ? sacScaleColors[sacScale] : undefined;
|
||||
let mtbScaleColor = mtbScale ? mtbScaleColors[mtbScale] : undefined;
|
||||
if (sacScale || mtbScale) {
|
||||
let patternId = `${backgroundColor}-${[sacScale, mtbScale].filter((x) => x).join('-')}`;
|
||||
if (!patterns[patternId]) {
|
||||
patterns[patternId] = createPattern(backgroundColor, sacScaleColor, mtbScaleColor);
|
||||
}
|
||||
return patterns[patternId];
|
||||
}
|
||||
return backgroundColor;
|
||||
}
|
||||
|
||||
const maxSlope = 20;
|
||||
export function getSlopeColor(slope: number): string {
|
||||
if (slope > maxSlope) {
|
||||
slope = maxSlope;
|
||||
} else if (slope < -maxSlope) {
|
||||
slope = -maxSlope;
|
||||
}
|
||||
|
||||
let v = slope / maxSlope;
|
||||
v = 1 / (1 + Math.exp(-6 * v));
|
||||
v = v - 0.5;
|
||||
|
||||
let hue = ((0.5 - v) * 120).toString(10);
|
||||
let lightness = 90 - Math.abs(v) * 70;
|
||||
|
||||
return `hsl(${hue},70%,${lightness}%)`;
|
||||
}
|
864
website/src/lib/assets/custom/bikerouter-gravel.json
Normal file
864
website/src/lib/assets/custom/bikerouter-gravel.json
Normal file
|
@ -0,0 +1,864 @@
|
|||
{
|
||||
"_info": "Taken from https://github.com/mjaschen/gravel-overlay, with prior authorization from the author (https://github.com/gpxstudio/gpx.studio/issues/32#issuecomment-2320219804).",
|
||||
"version": 8,
|
||||
"name": "Gravel Overlay",
|
||||
"metadata": {
|
||||
"mapbox:autocomposite": false,
|
||||
"mapbox:type": "template",
|
||||
"maputnik:renderer": "mbgljs",
|
||||
"openmaptiles:version": "3.x",
|
||||
"openmaptiles:mapbox:owner": "openmaptiles",
|
||||
"openmaptiles:mapbox:source:url": "mapbox://openmaptiles.4qljc88t"
|
||||
},
|
||||
"sources": {
|
||||
"openmaptiles": {
|
||||
"type": "vector",
|
||||
"url": "https://tiles.bikerouter.de/services/gravel/"
|
||||
}
|
||||
},
|
||||
"sprite": "https://demotiles.maplibre.org/styles/osm-bright-gl-style/sprite",
|
||||
"glyphs": "https://api.maptiler.com/fonts/{fontstack}/{range}.pbf?key={key}",
|
||||
"layers": [
|
||||
{
|
||||
"id": "background",
|
||||
"type": "background",
|
||||
"layout": {
|
||||
"visibility": "none"
|
||||
},
|
||||
"paint": {
|
||||
"background-color": "rgba(145, 211, 164, 1)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "debug_rail",
|
||||
"type": "line",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "transportation",
|
||||
"filter": ["all", ["==", "$type", "LineString"], ["in", "class", "rail"]],
|
||||
"layout": {
|
||||
"visibility": "none"
|
||||
},
|
||||
"paint": {
|
||||
"line-color": "rgba(144, 144, 144, 1)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "debug_road",
|
||||
"type": "line",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "transportation",
|
||||
"filter": [
|
||||
"all",
|
||||
["==", "$type", "LineString"],
|
||||
[
|
||||
"in",
|
||||
"class",
|
||||
"motorway",
|
||||
"trunk",
|
||||
"primary",
|
||||
"secondary",
|
||||
"tertiary",
|
||||
"minor",
|
||||
"residential",
|
||||
"track",
|
||||
"path"
|
||||
]
|
||||
],
|
||||
"layout": {
|
||||
"visibility": "none"
|
||||
},
|
||||
"paint": {
|
||||
"line-color": "rgba(204, 204, 204, 1)",
|
||||
"line-width": {
|
||||
"stops": [
|
||||
[10, 0.5],
|
||||
[12, 1]
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "tr_X_g45-bg",
|
||||
"type": "line",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "transportation",
|
||||
"minzoom": 10,
|
||||
"filter": [
|
||||
"all",
|
||||
["==", ["geometry-type"], "LineString"],
|
||||
["match", ["get", "class"], ["track"], true, false],
|
||||
[
|
||||
"any",
|
||||
["match", ["get", "tracktype"], ["grade5"], true, false],
|
||||
[
|
||||
"all",
|
||||
["match", ["get", "tracktype"], ["grade4"], true, false],
|
||||
["match", ["get", "surface"], ["dirt", "grass", "mud", "sand"], true, false]
|
||||
]
|
||||
]
|
||||
],
|
||||
"layout": {
|
||||
"line-cap": "square",
|
||||
"line-join": "bevel",
|
||||
"visibility": "visible"
|
||||
},
|
||||
"paint": {
|
||||
"line-color": "rgba(255, 0, 0, 0.7)",
|
||||
"line-width": {
|
||||
"base": 1.55,
|
||||
"stops": [
|
||||
[10, 0.4],
|
||||
[12, 1.3],
|
||||
[14, 1.7]
|
||||
]
|
||||
},
|
||||
"line-dasharray": [1],
|
||||
"line-offset": {
|
||||
"stops": [
|
||||
[12, 0],
|
||||
[13, 1.8],
|
||||
[15, 3],
|
||||
[16, 4]
|
||||
],
|
||||
"base": 1.55
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "tr_X_g45",
|
||||
"type": "line",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "transportation",
|
||||
"minzoom": 10,
|
||||
"filter": [
|
||||
"all",
|
||||
["==", ["geometry-type"], "LineString"],
|
||||
["match", ["get", "class"], ["track"], true, false],
|
||||
[
|
||||
"any",
|
||||
["match", ["get", "tracktype"], ["grade5"], true, false],
|
||||
[
|
||||
"all",
|
||||
["match", ["get", "tracktype"], ["grade4"], true, false],
|
||||
["match", ["get", "surface"], ["dirt", "grass", "mud", "sand"], true, false]
|
||||
]
|
||||
]
|
||||
],
|
||||
"layout": {
|
||||
"line-cap": "square",
|
||||
"line-join": "bevel",
|
||||
"visibility": "visible"
|
||||
},
|
||||
"paint": {
|
||||
"line-color": "rgba(255, 255, 0, 0.7)",
|
||||
"line-width": {
|
||||
"base": 1.55,
|
||||
"stops": [
|
||||
[10, 0.4],
|
||||
[12, 1.3],
|
||||
[14, 1.7]
|
||||
]
|
||||
},
|
||||
"line-dasharray": [2, 2],
|
||||
"line-offset": {
|
||||
"stops": [
|
||||
[12, 0],
|
||||
[13, 1.8],
|
||||
[15, 3],
|
||||
[16, 4]
|
||||
],
|
||||
"base": 1.55
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "tr_B_g3",
|
||||
"type": "line",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "transportation",
|
||||
"minzoom": 10,
|
||||
"filter": [
|
||||
"all",
|
||||
["==", ["geometry-type"], "LineString"],
|
||||
["==", ["get", "class"], "track"],
|
||||
["match", ["get", "tracktype"], ["grade3"], true, false],
|
||||
[
|
||||
"any",
|
||||
["match", ["get", "smoothness"], ["bad", "good", "intermediate"], true, false],
|
||||
[
|
||||
"match",
|
||||
["get", "surface"],
|
||||
["compacted", "fine_gravel", "gravel"],
|
||||
true,
|
||||
false
|
||||
]
|
||||
]
|
||||
],
|
||||
"layout": {
|
||||
"line-cap": "butt",
|
||||
"line-join": "miter",
|
||||
"visibility": "visible"
|
||||
},
|
||||
"paint": {
|
||||
"line-color": "rgba(235, 6, 158, 1)",
|
||||
"line-width": {
|
||||
"base": 1.55,
|
||||
"stops": [
|
||||
[10, 0.4],
|
||||
[12, 1.3],
|
||||
[14, 1.7]
|
||||
]
|
||||
},
|
||||
"line-offset": {
|
||||
"stops": [
|
||||
[12, 0],
|
||||
[13, 2],
|
||||
[15, 4],
|
||||
[16, 5]
|
||||
],
|
||||
"base": 1.55
|
||||
},
|
||||
"line-dasharray": [3, 1.5]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "tr_A_g2-plain-case",
|
||||
"type": "line",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "transportation",
|
||||
"minzoom": 10,
|
||||
"filter": [
|
||||
"all",
|
||||
["==", ["geometry-type"], "LineString"],
|
||||
["==", ["get", "class"], "track"],
|
||||
["match", ["get", "tracktype"], ["grade2"], true, false],
|
||||
["!", ["has", "surface"]],
|
||||
["!", ["has", "smoothness"]]
|
||||
],
|
||||
"layout": {
|
||||
"line-cap": "butt",
|
||||
"line-join": "miter",
|
||||
"visibility": "visible"
|
||||
},
|
||||
"paint": {
|
||||
"line-color": "rgba(255, 255, 255, 0.6)",
|
||||
"line-width": {
|
||||
"base": 1.55,
|
||||
"stops": [
|
||||
[10, 0.8],
|
||||
[12, 3],
|
||||
[14, 4]
|
||||
]
|
||||
},
|
||||
"line-offset": {
|
||||
"stops": [
|
||||
[12, 0],
|
||||
[13, 1.5],
|
||||
[15, 3],
|
||||
[16, 4]
|
||||
],
|
||||
"base": 1.55
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "tr_A_g2-plain",
|
||||
"type": "line",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "transportation",
|
||||
"minzoom": 10,
|
||||
"filter": [
|
||||
"all",
|
||||
["==", ["geometry-type"], "LineString"],
|
||||
["==", ["get", "class"], "track"],
|
||||
["match", ["get", "tracktype"], ["grade2"], true, false],
|
||||
["!", ["has", "surface"]],
|
||||
["!", ["has", "smoothness"]]
|
||||
],
|
||||
"layout": {
|
||||
"line-cap": "butt",
|
||||
"line-join": "miter",
|
||||
"visibility": "visible"
|
||||
},
|
||||
"paint": {
|
||||
"line-color": "rgba(235, 6, 158, 0.6)",
|
||||
"line-width": {
|
||||
"base": 1.55,
|
||||
"stops": [
|
||||
[10, 0.5],
|
||||
[12, 1.5],
|
||||
[14, 2]
|
||||
]
|
||||
},
|
||||
"line-offset": {
|
||||
"stops": [
|
||||
[12, 0],
|
||||
[13, 1.5],
|
||||
[15, 3],
|
||||
[16, 4]
|
||||
],
|
||||
"base": 1.55
|
||||
},
|
||||
"line-dasharray": [5, 1]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "tr_A_g2-case",
|
||||
"type": "line",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "transportation",
|
||||
"minzoom": 10,
|
||||
"filter": [
|
||||
"all",
|
||||
["==", ["geometry-type"], "LineString"],
|
||||
["==", ["get", "class"], "track"],
|
||||
["match", ["get", "tracktype"], ["grade2"], true, false],
|
||||
[
|
||||
"any",
|
||||
[
|
||||
"match",
|
||||
["get", "surface"],
|
||||
["compacted", "fine_gravel", "gravel"],
|
||||
true,
|
||||
false
|
||||
],
|
||||
["match", ["get", "smoothness"], ["bad", "good", "intermediate"], true, false]
|
||||
]
|
||||
],
|
||||
"layout": {
|
||||
"line-cap": "butt",
|
||||
"line-join": "miter",
|
||||
"visibility": "visible"
|
||||
},
|
||||
"paint": {
|
||||
"line-color": "rgba(255, 255, 255, 1)",
|
||||
"line-width": {
|
||||
"base": 1.55,
|
||||
"stops": [
|
||||
[10, 0.8],
|
||||
[12, 3],
|
||||
[14, 4]
|
||||
]
|
||||
},
|
||||
"line-offset": {
|
||||
"stops": [
|
||||
[12, 0],
|
||||
[13, 2],
|
||||
[15, 4],
|
||||
[16, 5]
|
||||
],
|
||||
"base": 1.55
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "tr_A_g2",
|
||||
"type": "line",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "transportation",
|
||||
"minzoom": 10,
|
||||
"filter": [
|
||||
"all",
|
||||
["==", ["geometry-type"], "LineString"],
|
||||
["==", ["get", "class"], "track"],
|
||||
["match", ["get", "tracktype"], ["grade2"], true, false],
|
||||
[
|
||||
"any",
|
||||
[
|
||||
"match",
|
||||
["get", "surface"],
|
||||
["compacted", "fine_gravel", "gravel"],
|
||||
true,
|
||||
false
|
||||
],
|
||||
["match", ["get", "smoothness"], ["bad", "good", "intermediate"], true, false]
|
||||
]
|
||||
],
|
||||
"layout": {
|
||||
"line-cap": "butt",
|
||||
"line-join": "miter",
|
||||
"visibility": "visible"
|
||||
},
|
||||
"paint": {
|
||||
"line-color": "rgba(235, 6, 158, 1)",
|
||||
"line-width": {
|
||||
"base": 1.55,
|
||||
"stops": [
|
||||
[10, 0.5],
|
||||
[12, 1.5],
|
||||
[14, 2]
|
||||
]
|
||||
},
|
||||
"line-offset": {
|
||||
"stops": [
|
||||
[12, 0],
|
||||
[13, 2],
|
||||
[15, 4],
|
||||
[16, 5]
|
||||
],
|
||||
"base": 1.55
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "p_X-bg",
|
||||
"type": "line",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "transportation",
|
||||
"minzoom": 10,
|
||||
"filter": [
|
||||
"all",
|
||||
["==", "$type", "LineString"],
|
||||
["in", "class", "path"],
|
||||
["in", "smoothness", "very_bad", "horrible", "very_horrible", "impassable"],
|
||||
["!in", "tracktype", "grade5", "grade4"]
|
||||
],
|
||||
"layout": {
|
||||
"line-cap": "square",
|
||||
"line-join": "bevel",
|
||||
"visibility": "visible"
|
||||
},
|
||||
"paint": {
|
||||
"line-color": "rgba(255, 0, 0, 0.7)",
|
||||
"line-width": {
|
||||
"base": 1.55,
|
||||
"stops": [
|
||||
[10, 0.4],
|
||||
[12, 1.1],
|
||||
[14, 1.5]
|
||||
]
|
||||
},
|
||||
"line-offset": {
|
||||
"stops": [
|
||||
[12, 0],
|
||||
[13, 1.8],
|
||||
[15, 3],
|
||||
[16, 4]
|
||||
],
|
||||
"base": 1.55
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "p_X",
|
||||
"type": "line",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "transportation",
|
||||
"minzoom": 10,
|
||||
"filter": [
|
||||
"all",
|
||||
["==", "$type", "LineString"],
|
||||
["in", "class", "path"],
|
||||
["in", "smoothness", "very_bad", "horrible", "very_horrible", "impassable"],
|
||||
["!in", "tracktype", "grade5", "grade4"]
|
||||
],
|
||||
"layout": {
|
||||
"line-cap": "square",
|
||||
"line-join": "bevel",
|
||||
"visibility": "visible"
|
||||
},
|
||||
"paint": {
|
||||
"line-color": "rgba(255, 255, 0, 0.7)",
|
||||
"line-width": {
|
||||
"base": 1.55,
|
||||
"stops": [
|
||||
[10, 0.4],
|
||||
[12, 1.1],
|
||||
[14, 1.5]
|
||||
]
|
||||
},
|
||||
"line-dasharray": [2, 2],
|
||||
"line-offset": {
|
||||
"stops": [
|
||||
[12, 0],
|
||||
[13, 1.8],
|
||||
[15, 3],
|
||||
[16, 4]
|
||||
],
|
||||
"base": 1.55
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "p_B",
|
||||
"type": "line",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "transportation",
|
||||
"minzoom": 10,
|
||||
"filter": [
|
||||
"all",
|
||||
["==", "$type", "LineString"],
|
||||
["==", "class", "path"],
|
||||
["in", "smoothness", "good", "intermediate", "bad"],
|
||||
[
|
||||
"!in",
|
||||
"surface",
|
||||
"gravel",
|
||||
"fine_gravel",
|
||||
"compacted",
|
||||
"cobblestone",
|
||||
"sett",
|
||||
"unhewn_cobblestone",
|
||||
"paving_stones"
|
||||
],
|
||||
["!in", "bicycle", "no"],
|
||||
["!in", "access", "no"]
|
||||
],
|
||||
"layout": {
|
||||
"line-cap": "butt",
|
||||
"line-join": "miter",
|
||||
"visibility": "visible"
|
||||
},
|
||||
"paint": {
|
||||
"line-color": "rgba(235, 6, 158, 1)",
|
||||
"line-width": {
|
||||
"base": 1.55,
|
||||
"stops": [
|
||||
[10, 0.4],
|
||||
[12, 1.1],
|
||||
[14, 1.5]
|
||||
]
|
||||
},
|
||||
"line-offset": {
|
||||
"stops": [
|
||||
[12, 0],
|
||||
[13, 2],
|
||||
[15, 4],
|
||||
[16, 5]
|
||||
],
|
||||
"base": 1.55
|
||||
},
|
||||
"line-dasharray": [1.5, 1]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "p_A-case",
|
||||
"type": "line",
|
||||
"metadata": {
|
||||
"maputnik:comment": "Gravel surface with ok-ish smoothness"
|
||||
},
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "transportation",
|
||||
"minzoom": 10,
|
||||
"filter": [
|
||||
"all",
|
||||
["==", ["geometry-type"], "LineString"],
|
||||
["==", ["get", "class"], "path"],
|
||||
[
|
||||
"any",
|
||||
["match", ["get", "surface"], ["compacted", "fine_gravel"], true, false],
|
||||
[
|
||||
"all",
|
||||
["match", ["get", "surface"], ["gravel"], true, false],
|
||||
[
|
||||
"match",
|
||||
["get", "smoothness"],
|
||||
["bad", "good", "intermediate"],
|
||||
true,
|
||||
false
|
||||
]
|
||||
]
|
||||
],
|
||||
["match", ["get", "bicycle"], ["no"], false, true],
|
||||
["match", ["get", "access"], ["no"], false, true]
|
||||
],
|
||||
"layout": {
|
||||
"line-cap": "butt",
|
||||
"line-join": "miter",
|
||||
"visibility": "visible"
|
||||
},
|
||||
"paint": {
|
||||
"line-color": "rgba(255, 255, 255, 1)",
|
||||
"line-width": {
|
||||
"base": 1.55,
|
||||
"stops": [
|
||||
[10, 0.7],
|
||||
[12, 2.5],
|
||||
[14, 3.2]
|
||||
]
|
||||
},
|
||||
"line-offset": {
|
||||
"stops": [
|
||||
[12, 0],
|
||||
[13, 2],
|
||||
[15, 4],
|
||||
[16, 5]
|
||||
],
|
||||
"base": 1.55
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "p_A",
|
||||
"type": "line",
|
||||
"metadata": {
|
||||
"maputnik:comment": "Gravel surface with ok-ish smoothness"
|
||||
},
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "transportation",
|
||||
"minzoom": 10,
|
||||
"filter": [
|
||||
"all",
|
||||
["==", ["geometry-type"], "LineString"],
|
||||
["==", ["get", "class"], "path"],
|
||||
[
|
||||
"any",
|
||||
["match", ["get", "surface"], ["compacted", "fine_gravel"], true, false],
|
||||
[
|
||||
"all",
|
||||
["match", ["get", "surface"], ["gravel"], true, false],
|
||||
[
|
||||
"match",
|
||||
["get", "smoothness"],
|
||||
["bad", "good", "intermediate"],
|
||||
true,
|
||||
false
|
||||
]
|
||||
]
|
||||
],
|
||||
["match", ["get", "bicycle"], ["no"], false, true],
|
||||
["match", ["get", "access"], ["no"], false, true]
|
||||
],
|
||||
"layout": {
|
||||
"line-cap": "butt",
|
||||
"line-join": "miter",
|
||||
"visibility": "visible"
|
||||
},
|
||||
"paint": {
|
||||
"line-color": "rgba(235, 6, 158, 1)",
|
||||
"line-width": {
|
||||
"base": 1.55,
|
||||
"stops": [
|
||||
[10, 0.5],
|
||||
[12, 1.5],
|
||||
[14, 2]
|
||||
]
|
||||
},
|
||||
"line-offset": {
|
||||
"stops": [
|
||||
[12, 0],
|
||||
[13, 2],
|
||||
[15, 4],
|
||||
[16, 5]
|
||||
],
|
||||
"base": 1.55
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "r_X_cobbles-case",
|
||||
"type": "line",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "transportation",
|
||||
"minzoom": 10,
|
||||
"filter": [
|
||||
"all",
|
||||
["==", "$type", "LineString"],
|
||||
["in", "class", "minor", "service", "track", "path", "residential"],
|
||||
["in", "surface", "sett", "cobblestone", "unhewn_cobblestone"],
|
||||
["!in", "service", "driveway"]
|
||||
],
|
||||
"layout": {
|
||||
"line-cap": "butt",
|
||||
"line-join": "miter",
|
||||
"visibility": "visible"
|
||||
},
|
||||
"paint": {
|
||||
"line-color": "rgba(0, 0, 0, 1)",
|
||||
"line-width": {
|
||||
"base": 1.55,
|
||||
"stops": [
|
||||
[10, 0.5],
|
||||
[12, 1.5],
|
||||
[14, 2]
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "r_X_cobbles",
|
||||
"type": "line",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "transportation",
|
||||
"minzoom": 10,
|
||||
"filter": [
|
||||
"all",
|
||||
["==", "$type", "LineString"],
|
||||
["in", "class", "minor", "service", "track", "path", "residential"],
|
||||
["in", "surface", "sett", "cobblestone", "unhewn_cobblestone"],
|
||||
["!in", "service", "driveway"]
|
||||
],
|
||||
"layout": {
|
||||
"line-cap": "butt",
|
||||
"line-join": "miter",
|
||||
"visibility": "visible"
|
||||
},
|
||||
"paint": {
|
||||
"line-color": "rgba(245, 255, 0, 1)",
|
||||
"line-width": {
|
||||
"base": 1.55,
|
||||
"stops": [
|
||||
[10, 0.5],
|
||||
[12, 1.5],
|
||||
[14, 2]
|
||||
]
|
||||
},
|
||||
"line-dasharray": [1.5, 1]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "r_X-bg",
|
||||
"type": "line",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "transportation",
|
||||
"minzoom": 10,
|
||||
"filter": [
|
||||
"all",
|
||||
["==", "$type", "LineString"],
|
||||
["in", "class", "minor"],
|
||||
["in", "smoothness", "very_bad", "horrible", "very_horrible", "impassable"],
|
||||
["!in", "surface", "sett", "cobblestone", "unhewn_cobblestone"]
|
||||
],
|
||||
"layout": {
|
||||
"line-cap": "square",
|
||||
"line-join": "bevel",
|
||||
"visibility": "visible"
|
||||
},
|
||||
"paint": {
|
||||
"line-color": "rgba(255, 0, 0, 0.7)",
|
||||
"line-width": {
|
||||
"base": 1.55,
|
||||
"stops": [
|
||||
[10, 0.4],
|
||||
[12, 1.1],
|
||||
[14, 1.5]
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "r_X",
|
||||
"type": "line",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "transportation",
|
||||
"minzoom": 10,
|
||||
"filter": [
|
||||
"all",
|
||||
["==", "$type", "LineString"],
|
||||
["in", "class", "minor"],
|
||||
["in", "smoothness", "very_bad", "horrible", "very_horrible", "impassable"],
|
||||
["!in", "surface", "sett", "cobblestone", "unhewn_cobblestone"]
|
||||
],
|
||||
"layout": {
|
||||
"line-cap": "square",
|
||||
"line-join": "bevel",
|
||||
"visibility": "visible"
|
||||
},
|
||||
"paint": {
|
||||
"line-color": "rgba(255, 255, 0, 0.7)",
|
||||
"line-width": {
|
||||
"base": 1.55,
|
||||
"stops": [
|
||||
[10, 0.4],
|
||||
[12, 1.1],
|
||||
[14, 1.5]
|
||||
]
|
||||
},
|
||||
"line-dasharray": [2, 2]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "r_A_case",
|
||||
"type": "line",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "transportation",
|
||||
"minzoom": 10,
|
||||
"filter": [
|
||||
"all",
|
||||
["==", "$type", "LineString"],
|
||||
["in", "class", "minor", "residential", "service"],
|
||||
["in", "surface", "gravel", "compacted", "fine_gravel"],
|
||||
["!in", "service", "driveway", "parking_aisle", "drive-through", "emergency_access"]
|
||||
],
|
||||
"layout": {
|
||||
"line-cap": "square",
|
||||
"line-join": "bevel",
|
||||
"visibility": "visible"
|
||||
},
|
||||
"paint": {
|
||||
"line-color": "rgba(255, 255, 255, 1)",
|
||||
"line-width": {
|
||||
"base": 1.55,
|
||||
"stops": [
|
||||
[10, 0.8],
|
||||
[12, 3],
|
||||
[14, 4]
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "r_A",
|
||||
"type": "line",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "transportation",
|
||||
"minzoom": 10,
|
||||
"filter": [
|
||||
"all",
|
||||
["==", "$type", "LineString"],
|
||||
["in", "class", "minor", "residential", "service"],
|
||||
["in", "surface", "gravel", "compacted", "fine_gravel"],
|
||||
["!in", "service", "driveway", "parking_aisle", "drive-through", "emergency_access"]
|
||||
],
|
||||
"layout": {
|
||||
"line-cap": "square",
|
||||
"line-join": "bevel",
|
||||
"visibility": "visible"
|
||||
},
|
||||
"paint": {
|
||||
"line-color": "rgba(235, 6, 158, 1)",
|
||||
"line-width": {
|
||||
"base": 1.55,
|
||||
"stops": [
|
||||
[10, 0.5],
|
||||
[12, 1.5],
|
||||
[14, 2]
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "cemetery",
|
||||
"type": "symbol",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "landuse",
|
||||
"filter": ["all", ["==", "class", "cemetery"]],
|
||||
"layout": {
|
||||
"icon-image": "cemetery_11",
|
||||
"icon-rotation-alignment": "map",
|
||||
"icon-size": 1.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "drinking_water",
|
||||
"type": "symbol",
|
||||
"source": "openmaptiles",
|
||||
"source-layer": "poi",
|
||||
"minzoom": 9,
|
||||
"maxzoom": 20,
|
||||
"filter": [
|
||||
"any",
|
||||
["==", "class", "drinking_water"],
|
||||
["==", "subclass", "drinking_water"]
|
||||
],
|
||||
"layout": {
|
||||
"icon-image": "drinking_water_11",
|
||||
"visibility": "visible",
|
||||
"icon-rotation-alignment": "map",
|
||||
"icon-size": 1.4
|
||||
}
|
||||
}
|
||||
],
|
||||
"id": "basic",
|
||||
"owner": "Marcus Jaschen"
|
||||
}
|
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because one or more lines are too long
Binary file not shown.
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 2 MiB |
File diff suppressed because it is too large
Load diff
|
@ -1,31 +0,0 @@
|
|||
export const surfaceColors: { [key: string]: string } = {
|
||||
'missing': '#d1d1d1',
|
||||
'paved': '#8c8c8c',
|
||||
'unpaved': '#6b443a',
|
||||
'asphalt': '#8c8c8c',
|
||||
'concrete': '#8c8c8c',
|
||||
'chipseal': '#8c8c8c',
|
||||
'cobblestone': '#ffd991',
|
||||
'unhewn_cobblestone': '#ffd991',
|
||||
'paving_stones': '#8c8c8c',
|
||||
'stepping_stones': '#c7b2db',
|
||||
'sett': '#ffd991',
|
||||
'metal': '#8c8c8c',
|
||||
'wood': '#6b443a',
|
||||
'compacted': '#ffffa8',
|
||||
'fine_gravel': '#ffffa8',
|
||||
'gravel': '#ffffa8',
|
||||
'pebblestone': '#ffffa8',
|
||||
'rock': '#ffd991',
|
||||
'dirt': '#ffffa8',
|
||||
'ground': '#6b443a',
|
||||
'earth': '#6b443a',
|
||||
'snow': '#bdfffc',
|
||||
'ice': '#bdfffc',
|
||||
'salt': '#b6c0f2',
|
||||
'mud': '#6b443a',
|
||||
'sand': '#ffffc4',
|
||||
'woodchips': '#6b443a',
|
||||
'grass': '#61b55c',
|
||||
'grass_paver': '#61b55c'
|
||||
}
|
|
@ -1,6 +1,67 @@
|
|||
import { Landmark, Icon, Shell, Bike, Building, Tent, Car, Wrench, ShoppingBasket, Droplet, DoorOpen, Trees, Fuel, Home, Info, TreeDeciduous, CircleParking, Cross, Utensils, Construction, BrickWall, ShowerHead, Mountain, Phone, TrainFront, Bed, Binoculars, TriangleAlert, Anchor } from "lucide-svelte";
|
||||
import { Landmark as LandmarkSvg, Shell as ShellSvg, Bike as BikeSvg, Building as BuildingSvg, Tent as TentSvg, Car as CarSvg, Wrench as WrenchSvg, ShoppingBasket as ShoppingBasketSvg, Droplet as DropletSvg, DoorOpen as DoorOpenSvg, Trees as TreesSvg, Fuel as FuelSvg, Home as HomeSvg, Info as InfoSvg, TreeDeciduous as TreeDeciduousSvg, CircleParking as CircleParkingSvg, Cross as CrossSvg, Utensils as UtensilsSvg, Construction as ConstructionSvg, BrickWall as BrickWallSvg, ShowerHead as ShowerHeadSvg, Mountain as MountainSvg, Phone as PhoneSvg, TrainFront as TrainFrontSvg, Bed as BedSvg, Binoculars as BinocularsSvg, TriangleAlert as TriangleAlertSvg, Anchor as AnchorSvg } from "lucide-static";
|
||||
import type { ComponentType } from "svelte";
|
||||
import {
|
||||
Landmark,
|
||||
Icon,
|
||||
Shell,
|
||||
Bike,
|
||||
Building,
|
||||
Tent,
|
||||
Car,
|
||||
Wrench,
|
||||
ShoppingBasket,
|
||||
Droplet,
|
||||
DoorOpen,
|
||||
Trees,
|
||||
Fuel,
|
||||
Home,
|
||||
Info,
|
||||
TreeDeciduous,
|
||||
CircleParking,
|
||||
Cross,
|
||||
Utensils,
|
||||
Construction,
|
||||
BrickWall,
|
||||
ShowerHead,
|
||||
Mountain,
|
||||
Phone,
|
||||
TrainFront,
|
||||
Bed,
|
||||
Binoculars,
|
||||
TriangleAlert,
|
||||
Anchor,
|
||||
Toilet,
|
||||
} from 'lucide-svelte';
|
||||
import {
|
||||
Landmark as LandmarkSvg,
|
||||
Shell as ShellSvg,
|
||||
Bike as BikeSvg,
|
||||
Building as BuildingSvg,
|
||||
Tent as TentSvg,
|
||||
Car as CarSvg,
|
||||
Wrench as WrenchSvg,
|
||||
ShoppingBasket as ShoppingBasketSvg,
|
||||
Droplet as DropletSvg,
|
||||
DoorOpen as DoorOpenSvg,
|
||||
Trees as TreesSvg,
|
||||
Fuel as FuelSvg,
|
||||
Home as HomeSvg,
|
||||
Info as InfoSvg,
|
||||
TreeDeciduous as TreeDeciduousSvg,
|
||||
CircleParking as CircleParkingSvg,
|
||||
Cross as CrossSvg,
|
||||
Utensils as UtensilsSvg,
|
||||
Construction as ConstructionSvg,
|
||||
BrickWall as BrickWallSvg,
|
||||
ShowerHead as ShowerHeadSvg,
|
||||
Mountain as MountainSvg,
|
||||
Phone as PhoneSvg,
|
||||
TrainFront as TrainFrontSvg,
|
||||
Bed as BedSvg,
|
||||
Binoculars as BinocularsSvg,
|
||||
TriangleAlert as TriangleAlertSvg,
|
||||
Anchor as AnchorSvg,
|
||||
Toilet as ToiletSvg,
|
||||
} from 'lucide-static';
|
||||
import type { ComponentType } from 'svelte';
|
||||
|
||||
export type Symbol = {
|
||||
value: string;
|
||||
|
@ -20,16 +81,28 @@ export const symbols: { [key: string]: Symbol } = {
|
|||
campground: { value: 'Campground', icon: Tent, iconSvg: TentSvg },
|
||||
car: { value: 'Car', icon: Car, iconSvg: CarSvg },
|
||||
car_repair: { value: 'Car Repair', icon: Wrench, iconSvg: WrenchSvg },
|
||||
convenience_store: { value: 'Convenience Store', icon: ShoppingBasket, iconSvg: ShoppingBasketSvg },
|
||||
convenience_store: {
|
||||
value: 'Convenience Store',
|
||||
icon: ShoppingBasket,
|
||||
iconSvg: ShoppingBasketSvg,
|
||||
},
|
||||
crossing: { value: 'Crossing' },
|
||||
department_store: { value: 'Department Store', icon: ShoppingBasket, iconSvg: ShoppingBasketSvg },
|
||||
department_store: {
|
||||
value: 'Department Store',
|
||||
icon: ShoppingBasket,
|
||||
iconSvg: ShoppingBasketSvg,
|
||||
},
|
||||
drinking_water: { value: 'Drinking Water', icon: Droplet, iconSvg: DropletSvg },
|
||||
exit: { value: 'Exit', icon: DoorOpen, iconSvg: DoorOpenSvg },
|
||||
lodge: { value: 'Lodge', icon: Home, iconSvg: HomeSvg },
|
||||
lodging: { value: 'Lodging', icon: Bed, iconSvg: BedSvg },
|
||||
forest: { value: 'Forest', icon: Trees, iconSvg: TreesSvg },
|
||||
gas_station: { value: 'Gas Station', icon: Fuel, iconSvg: FuelSvg },
|
||||
ground_transportation: { value: 'Ground Transportation', icon: TrainFront, iconSvg: TrainFrontSvg },
|
||||
ground_transportation: {
|
||||
value: 'Ground Transportation',
|
||||
icon: TrainFront,
|
||||
iconSvg: TrainFrontSvg,
|
||||
},
|
||||
hotel: { value: 'Hotel', icon: Bed, iconSvg: BedSvg },
|
||||
house: { value: 'House', icon: Home, iconSvg: HomeSvg },
|
||||
information: { value: 'Information', icon: Info, iconSvg: InfoSvg },
|
||||
|
@ -39,7 +112,7 @@ export const symbols: { [key: string]: Symbol } = {
|
|||
picnic_area: { value: 'Picnic Area', icon: Utensils, iconSvg: UtensilsSvg },
|
||||
restaurant: { value: 'Restaurant', icon: Utensils, iconSvg: UtensilsSvg },
|
||||
restricted_area: { value: 'Restricted Area', icon: Construction, iconSvg: ConstructionSvg },
|
||||
restroom: { value: 'Restroom' },
|
||||
restroom: { value: 'Restroom', icon: Toilet, iconSvg: ToiletSvg },
|
||||
road: { value: 'Road', icon: BrickWall, iconSvg: BrickWallSvg },
|
||||
scenic_area: { value: 'Scenic Area', icon: Binoculars, iconSvg: BinocularsSvg },
|
||||
shelter: { value: 'Shelter', icon: Tent, iconSvg: TentSvg },
|
||||
|
@ -55,6 +128,6 @@ export function getSymbolKey(value: string | undefined): string | undefined {
|
|||
if (value === undefined) {
|
||||
return undefined;
|
||||
} else {
|
||||
return Object.keys(symbols).find(key => symbols[key].value === value);
|
||||
return Object.keys(symbols).find((key) => symbols[key].value === value);
|
||||
}
|
||||
}
|
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>
|
28
website/src/lib/components/ButtonWithTooltip.svelte
Normal file
28
website/src/lib/components/ButtonWithTooltip.svelte
Normal file
|
@ -0,0 +1,28 @@
|
|||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
||||
import type { Builder } from 'bits-ui';
|
||||
|
||||
export let variant:
|
||||
| 'default'
|
||||
| 'secondary'
|
||||
| 'link'
|
||||
| 'destructive'
|
||||
| 'outline'
|
||||
| 'ghost'
|
||||
| undefined = 'default';
|
||||
export let label: string;
|
||||
export let side: 'top' | 'right' | 'bottom' | 'left' = 'top';
|
||||
export let builders: Builder[] = [];
|
||||
</script>
|
||||
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild let:builder>
|
||||
<Button builders={[...builders, builder]} {variant} {...$$restProps} on:click>
|
||||
<slot />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content {side}>
|
||||
<span>{label}</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
18
website/src/lib/components/CoordinatesPopup.svelte
Normal file
18
website/src/lib/components/CoordinatesPopup.svelte
Normal file
|
@ -0,0 +1,18 @@
|
|||
<script lang="ts">
|
||||
import { map } from '$lib/stores';
|
||||
import { trackpointPopup } from '$lib/components/gpx-layer/GPXLayerPopup';
|
||||
import { TrackPoint } from 'gpx';
|
||||
|
||||
$: if ($map) {
|
||||
$map.on('contextmenu', (e) => {
|
||||
trackpointPopup?.setItem({
|
||||
item: new TrackPoint({
|
||||
attributes: {
|
||||
lat: e.lngLat.lat,
|
||||
lon: e.lngLat.lng,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
import ButtonWithTooltip from '$lib/components/ButtonWithTooltip.svelte';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import * as ToggleGroup from '$lib/components/ui/toggle-group';
|
||||
import Tooltip from '$lib/components/Tooltip.svelte';
|
||||
import Chart from 'chart.js/auto';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import { map } from '$lib/stores';
|
||||
|
@ -12,12 +13,15 @@
|
|||
Orbit,
|
||||
SquareActivity,
|
||||
Thermometer,
|
||||
Zap
|
||||
Zap,
|
||||
Circle,
|
||||
Check,
|
||||
ChartNoAxesColumn,
|
||||
Construction,
|
||||
} from 'lucide-svelte';
|
||||
import { surfaceColors } from '$lib/assets/surfaces';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { getSlopeColor, getSurfaceColor, getHighwayColor } from '$lib/assets/colors';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import {
|
||||
getCadenceUnits,
|
||||
getCadenceWithUnits,
|
||||
getConvertedDistance,
|
||||
getConvertedElevation,
|
||||
|
@ -26,45 +30,26 @@
|
|||
getDistanceUnits,
|
||||
getDistanceWithUnits,
|
||||
getElevationWithUnits,
|
||||
getHeartRateUnits,
|
||||
getHeartRateWithUnits,
|
||||
getPowerUnits,
|
||||
getPowerWithUnits,
|
||||
getTemperatureUnits,
|
||||
getTemperatureWithUnits,
|
||||
getVelocityUnits,
|
||||
getVelocityWithUnits,
|
||||
secondsToHHMMSS
|
||||
} from '$lib/units';
|
||||
import type { Writable } from 'svelte/store';
|
||||
import { DateFormatter } from '@internationalized/date';
|
||||
import type { GPXStatistics } from 'gpx';
|
||||
import { settings } from '$lib/db';
|
||||
import { mode } from 'mode-watcher';
|
||||
import { df } from '$lib/utils';
|
||||
|
||||
export let gpxStatistics: Writable<GPXStatistics>;
|
||||
export let slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>;
|
||||
export let panelSize: number;
|
||||
export let additionalDatasets: string[];
|
||||
export let elevationFill: 'slope' | 'surface' | undefined;
|
||||
export let elevationFill: 'slope' | 'surface' | 'highway' | undefined;
|
||||
export let showControls: boolean = true;
|
||||
|
||||
const { distanceUnits, velocityUnits, temperatureUnits } = settings;
|
||||
|
||||
let df: DateFormatter;
|
||||
|
||||
$: if ($locale) {
|
||||
df = new DateFormatter($locale, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'medium'
|
||||
});
|
||||
}
|
||||
|
||||
let canvas: HTMLCanvasElement;
|
||||
let showAdditionalScales = true;
|
||||
let updateShowAdditionalScales = () => {
|
||||
showAdditionalScales = canvas.width / window.devicePixelRatio >= 600;
|
||||
};
|
||||
let overlay: HTMLCanvasElement;
|
||||
let chart: Chart;
|
||||
|
||||
|
@ -83,41 +68,41 @@
|
|||
x: {
|
||||
type: 'linear',
|
||||
ticks: {
|
||||
callback: function (value: number, index: number, ticks: { value: number }[]) {
|
||||
if (index === ticks.length - 1) {
|
||||
return `${value.toFixed(1).replace(/\.0+$/, '')}`;
|
||||
}
|
||||
callback: function (value: number) {
|
||||
return `${value.toFixed(1).replace(/\.0+$/, '')} ${getDistanceUnits()}`;
|
||||
}
|
||||
}
|
||||
},
|
||||
align: 'inner',
|
||||
maxRotation: 0,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
type: 'linear',
|
||||
ticks: {
|
||||
callback: function (value: number) {
|
||||
return getElevationWithUnits(value, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
datasets: {
|
||||
line: {
|
||||
pointRadius: 0,
|
||||
tension: 0.4,
|
||||
borderWidth: 2
|
||||
}
|
||||
borderWidth: 2,
|
||||
cubicInterpolationMode: 'monotone',
|
||||
},
|
||||
},
|
||||
interaction: {
|
||||
mode: 'nearest',
|
||||
axis: 'x',
|
||||
intersect: false
|
||||
intersect: false,
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
display: false,
|
||||
},
|
||||
decimation: {
|
||||
enabled: true
|
||||
enabled: true,
|
||||
},
|
||||
tooltip: {
|
||||
enabled: () => !dragging && !panning,
|
||||
|
@ -156,13 +141,20 @@
|
|||
let slope = {
|
||||
at: point.slope.at.toFixed(1),
|
||||
segment: point.slope.segment.toFixed(1),
|
||||
length: getDistanceWithUnits(point.slope.length)
|
||||
length: getDistanceWithUnits(point.slope.length),
|
||||
};
|
||||
let surface = point.surface ? point.surface : 'unknown';
|
||||
let surface = point.extensions.surface
|
||||
? point.extensions.surface
|
||||
: 'unknown';
|
||||
let highway = point.extensions.highway
|
||||
? point.extensions.highway
|
||||
: 'unknown';
|
||||
let sacScale = point.extensions.sac_scale;
|
||||
let mtbScale = point.extensions.mtb_scale;
|
||||
|
||||
let labels = [
|
||||
` ${$_('quantities.distance')}: ${getDistanceWithUnits(point.x, false)}`,
|
||||
` ${$_('quantities.slope')}: ${slope.at} %${elevationFill === 'slope' ? ` (${slope.length} @${slope.segment} %)` : ''}`
|
||||
` ${$_('quantities.slope')}: ${slope.at} %${elevationFill === 'slope' ? ` (${slope.length} @${slope.segment} %)` : ''}`,
|
||||
];
|
||||
|
||||
if (elevationFill === 'surface') {
|
||||
|
@ -171,13 +163,26 @@
|
|||
);
|
||||
}
|
||||
|
||||
if (elevationFill === 'highway') {
|
||||
labels.push(
|
||||
` ${$_('quantities.highway')}: ${$_(`toolbar.routing.highway.${highway}`)}${
|
||||
sacScale
|
||||
? ` (${$_(`toolbar.routing.sac_scale.${sacScale}`)})`
|
||||
: ''
|
||||
}`
|
||||
);
|
||||
if (mtbScale) {
|
||||
labels.push(` ${$_('toolbar.routing.mtb_scale')}: ${mtbScale}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (point.time) {
|
||||
labels.push(` ${$_('quantities.time')}: ${df.format(point.time)}`);
|
||||
}
|
||||
|
||||
return labels;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
zoom: {
|
||||
pan: {
|
||||
|
@ -191,18 +196,19 @@
|
|||
},
|
||||
onPanComplete: function () {
|
||||
panning = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
zoom: {
|
||||
wheel: {
|
||||
enabled: true
|
||||
enabled: true,
|
||||
},
|
||||
mode: 'x',
|
||||
onZoomStart: function ({ chart, event }: { chart: Chart; event: any }) {
|
||||
if (
|
||||
event.deltaY < 0 &&
|
||||
Math.abs(
|
||||
chart.getInitialScaleBounds().x.max / chart.options.plugins.zoom.limits.x.minRange -
|
||||
chart.getInitialScaleBounds().x.max /
|
||||
chart.options.plugins.zoom.limits.x.minRange -
|
||||
chart.getZoomLevel()
|
||||
) < 0.01
|
||||
) {
|
||||
|
@ -211,86 +217,35 @@
|
|||
}
|
||||
|
||||
$slicedGPXStatistics = undefined;
|
||||
}
|
||||
},
|
||||
},
|
||||
limits: {
|
||||
x: {
|
||||
min: 'original',
|
||||
max: 'original',
|
||||
minRange: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
minRange: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
stacked: false,
|
||||
onResize: function () {
|
||||
updateOverlay();
|
||||
updateShowAdditionalScales();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let datasets: {
|
||||
[key: string]: {
|
||||
id: string;
|
||||
getLabel: () => string;
|
||||
getUnits: () => string;
|
||||
};
|
||||
} = {
|
||||
speed: {
|
||||
id: 'speed',
|
||||
getLabel: () => ($velocityUnits === 'speed' ? $_('quantities.speed') : $_('quantities.pace')),
|
||||
getUnits: () => getVelocityUnits()
|
||||
},
|
||||
hr: {
|
||||
id: 'hr',
|
||||
getLabel: () => $_('quantities.heartrate'),
|
||||
getUnits: () => getHeartRateUnits()
|
||||
},
|
||||
cad: {
|
||||
id: 'cad',
|
||||
getLabel: () => $_('quantities.cadence'),
|
||||
getUnits: () => getCadenceUnits()
|
||||
},
|
||||
atemp: {
|
||||
id: 'atemp',
|
||||
getLabel: () => $_('quantities.temperature'),
|
||||
getUnits: () => getTemperatureUnits()
|
||||
},
|
||||
power: {
|
||||
id: 'power',
|
||||
getLabel: () => $_('quantities.power'),
|
||||
getUnits: () => getPowerUnits()
|
||||
}
|
||||
};
|
||||
|
||||
for (let [id, dataset] of Object.entries(datasets)) {
|
||||
let datasets: string[] = ['speed', 'hr', 'cad', 'atemp', 'power'];
|
||||
datasets.forEach((id) => {
|
||||
options.scales[`y${id}`] = {
|
||||
type: 'linear',
|
||||
position: 'right',
|
||||
title: {
|
||||
display: true,
|
||||
text: dataset.getLabel() + ' (' + dataset.getUnits() + ')',
|
||||
padding: {
|
||||
top: 6,
|
||||
bottom: 0
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
display: false
|
||||
display: false,
|
||||
},
|
||||
reverse: () => id === 'speed' && $velocityUnits === 'pace',
|
||||
display: false
|
||||
};
|
||||
}
|
||||
options.scales.yspeed['ticks'] = {
|
||||
callback: function (value: number) {
|
||||
if ($velocityUnits === 'speed') {
|
||||
return value;
|
||||
} else {
|
||||
return secondsToHHMMSS(value);
|
||||
}
|
||||
}
|
||||
display: false,
|
||||
};
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
Chart.register((await import('chartjs-plugin-zoom')).default); // dynamic import to avoid SSR and 'window is not defined' error
|
||||
|
@ -298,7 +253,7 @@
|
|||
chart = new Chart(canvas, {
|
||||
type: 'line',
|
||||
data: {
|
||||
datasets: []
|
||||
datasets: [],
|
||||
},
|
||||
options,
|
||||
plugins: [
|
||||
|
@ -311,20 +266,18 @@
|
|||
marker.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Map marker to show on hover
|
||||
let element = document.createElement('div');
|
||||
element.className = 'h-4 w-4 rounded-full bg-cyan-500 border-2 border-white';
|
||||
marker = new mapboxgl.Marker({
|
||||
element
|
||||
element,
|
||||
});
|
||||
|
||||
updateShowAdditionalScales();
|
||||
|
||||
let startIndex = 0;
|
||||
let endIndex = 0;
|
||||
function getIndex(evt) {
|
||||
|
@ -332,7 +285,7 @@
|
|||
evt,
|
||||
'x',
|
||||
{
|
||||
intersect: false
|
||||
intersect: false,
|
||||
},
|
||||
true
|
||||
);
|
||||
|
@ -375,9 +328,12 @@
|
|||
startIndex = endIndex;
|
||||
} else if (startIndex !== endIndex) {
|
||||
$slicedGPXStatistics = [
|
||||
$gpxStatistics.slice(Math.min(startIndex, endIndex), Math.max(startIndex, endIndex)),
|
||||
$gpxStatistics.slice(
|
||||
Math.min(startIndex, endIndex),
|
||||
Math.max(startIndex, endIndex)
|
||||
),
|
||||
Math.min(startIndex, endIndex),
|
||||
Math.max(startIndex, endIndex),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@ -411,126 +367,111 @@
|
|||
slope: {
|
||||
at: data.local.slope.at[index],
|
||||
segment: data.local.slope.segment[index],
|
||||
length: data.local.slope.length[index]
|
||||
length: data.local.slope.length[index],
|
||||
},
|
||||
surface: point.getSurface(),
|
||||
extensions: point.getExtensions(),
|
||||
coordinates: point.getCoordinates(),
|
||||
index: index
|
||||
index: index,
|
||||
};
|
||||
}),
|
||||
normalized: true,
|
||||
fill: 'start',
|
||||
order: 1
|
||||
order: 1,
|
||||
};
|
||||
chart.data.datasets[1] = {
|
||||
label: datasets.speed.getLabel(),
|
||||
data: data.local.points.map((point, index) => {
|
||||
return {
|
||||
x: getConvertedDistance(data.local.distance.total[index]),
|
||||
y: getConvertedVelocity(data.local.speed[index]),
|
||||
index: index
|
||||
index: index,
|
||||
};
|
||||
}),
|
||||
normalized: true,
|
||||
yAxisID: `y${datasets.speed.id}`,
|
||||
hidden: true
|
||||
yAxisID: 'yspeed',
|
||||
hidden: true,
|
||||
};
|
||||
chart.data.datasets[2] = {
|
||||
label: datasets.hr.getLabel(),
|
||||
data: data.local.points.map((point, index) => {
|
||||
return {
|
||||
x: getConvertedDistance(data.local.distance.total[index]),
|
||||
y: point.getHeartRate(),
|
||||
index: index
|
||||
index: index,
|
||||
};
|
||||
}),
|
||||
normalized: true,
|
||||
yAxisID: `y${datasets.hr.id}`,
|
||||
hidden: true
|
||||
yAxisID: 'yhr',
|
||||
hidden: true,
|
||||
};
|
||||
chart.data.datasets[3] = {
|
||||
label: datasets.cad.getLabel(),
|
||||
data: data.local.points.map((point, index) => {
|
||||
return {
|
||||
x: getConvertedDistance(data.local.distance.total[index]),
|
||||
y: point.getCadence(),
|
||||
index: index
|
||||
index: index,
|
||||
};
|
||||
}),
|
||||
normalized: true,
|
||||
yAxisID: `y${datasets.cad.id}`,
|
||||
hidden: true
|
||||
yAxisID: 'ycad',
|
||||
hidden: true,
|
||||
};
|
||||
chart.data.datasets[4] = {
|
||||
label: datasets.atemp.getLabel(),
|
||||
data: data.local.points.map((point, index) => {
|
||||
return {
|
||||
x: getConvertedDistance(data.local.distance.total[index]),
|
||||
y: getConvertedTemperature(point.getTemperature()),
|
||||
index: index
|
||||
index: index,
|
||||
};
|
||||
}),
|
||||
normalized: true,
|
||||
yAxisID: `y${datasets.atemp.id}`,
|
||||
hidden: true
|
||||
yAxisID: 'yatemp',
|
||||
hidden: true,
|
||||
};
|
||||
chart.data.datasets[5] = {
|
||||
label: datasets.power.getLabel(),
|
||||
data: data.local.points.map((point, index) => {
|
||||
return {
|
||||
x: getConvertedDistance(data.local.distance.total[index]),
|
||||
y: point.getPower(),
|
||||
index: index
|
||||
index: index,
|
||||
};
|
||||
}),
|
||||
normalized: true,
|
||||
yAxisID: `y${datasets.power.id}`,
|
||||
hidden: true
|
||||
yAxisID: 'ypower',
|
||||
hidden: true,
|
||||
};
|
||||
chart.options.scales.x['min'] = 0;
|
||||
chart.options.scales.x['max'] = getConvertedDistance(data.global.distance.total);
|
||||
|
||||
// update units
|
||||
for (let [id, dataset] of Object.entries(datasets)) {
|
||||
chart.options.scales[`y${id}`].title.text =
|
||||
dataset.getLabel() + ' (' + dataset.getUnits() + ')';
|
||||
}
|
||||
|
||||
chart.update();
|
||||
}
|
||||
|
||||
let maxSlope = 20;
|
||||
function slopeFillCallback(context) {
|
||||
let slope = context.p0.raw.slope.segment;
|
||||
if (slope > maxSlope) {
|
||||
slope = maxSlope;
|
||||
} else if (slope < -maxSlope) {
|
||||
slope = -maxSlope;
|
||||
}
|
||||
|
||||
let v = slope / maxSlope;
|
||||
v = 1 / (1 + Math.exp(-6 * v));
|
||||
v = v - 0.5;
|
||||
|
||||
let hue = ((0.5 - v) * 120).toString(10);
|
||||
let lightness = 90 - Math.abs(v) * 70;
|
||||
|
||||
return ['hsl(', hue, ',70%,', lightness, '%)'].join('');
|
||||
return getSlopeColor(context.p0.raw.slope.segment);
|
||||
}
|
||||
|
||||
function surfaceFillCallback(context) {
|
||||
let surface = context.p0.raw.surface;
|
||||
return surfaceColors[surface] ? surfaceColors[surface] : surfaceColors.missing;
|
||||
return getSurfaceColor(context.p0.raw.extensions.surface);
|
||||
}
|
||||
|
||||
function highwayFillCallback(context) {
|
||||
return getHighwayColor(
|
||||
context.p0.raw.extensions.highway,
|
||||
context.p0.raw.extensions.sac_scale,
|
||||
context.p0.raw.extensions.mtb_scale
|
||||
);
|
||||
}
|
||||
|
||||
$: if (chart) {
|
||||
if (elevationFill === 'slope') {
|
||||
chart.data.datasets[0]['segment'] = {
|
||||
backgroundColor: slopeFillCallback
|
||||
backgroundColor: slopeFillCallback,
|
||||
};
|
||||
} else if (elevationFill === 'surface') {
|
||||
chart.data.datasets[0]['segment'] = {
|
||||
backgroundColor: surfaceFillCallback
|
||||
backgroundColor: surfaceFillCallback,
|
||||
};
|
||||
} else if (elevationFill === 'highway') {
|
||||
chart.data.datasets[0]['segment'] = {
|
||||
backgroundColor: highwayFillCallback,
|
||||
};
|
||||
} else {
|
||||
chart.data.datasets[0]['segment'] = {};
|
||||
|
@ -551,12 +492,6 @@
|
|||
chart.data.datasets[4].hidden = !includeTemperature;
|
||||
chart.data.datasets[5].hidden = !includePower;
|
||||
}
|
||||
chart.options.scales[`y${datasets.speed.id}`].display = includeSpeed && showAdditionalScales;
|
||||
chart.options.scales[`y${datasets.hr.id}`].display = includeHeartRate && showAdditionalScales;
|
||||
chart.options.scales[`y${datasets.cad.id}`].display = includeCadence && showAdditionalScales;
|
||||
chart.options.scales[`y${datasets.atemp.id}`].display =
|
||||
includeTemperature && showAdditionalScales;
|
||||
chart.options.scales[`y${datasets.power.id}`].display = includePower && showAdditionalScales;
|
||||
chart.update();
|
||||
}
|
||||
|
||||
|
@ -567,6 +502,8 @@
|
|||
|
||||
overlay.width = canvas.width / window.devicePixelRatio;
|
||||
overlay.height = canvas.height / window.devicePixelRatio;
|
||||
overlay.style.width = `${overlay.width}px`;
|
||||
overlay.style.height = `${overlay.height}px`;
|
||||
|
||||
if ($slicedGPXStatistics) {
|
||||
let startIndex = $slicedGPXStatistics[1];
|
||||
|
@ -590,7 +527,7 @@
|
|||
startPixel,
|
||||
chart.chartArea.top,
|
||||
endPixel - startPixel,
|
||||
chart.chartArea.bottom - chart.chartArea.top
|
||||
chart.chartArea.height
|
||||
);
|
||||
}
|
||||
} else if (overlay) {
|
||||
|
@ -610,73 +547,141 @@
|
|||
});
|
||||
</script>
|
||||
|
||||
<div class="h-full grow min-w-0 flex flex-row gap-4 items-center {$$props.class ?? ''}">
|
||||
<div class="grow h-full min-w-0 relative">
|
||||
<canvas bind:this={overlay} class=" w-full h-full absolute pointer-events-none"></canvas>
|
||||
<canvas bind:this={canvas} class="w-full h-full"></canvas>
|
||||
</div>
|
||||
<div class="h-full grow min-w-0 relative py-2">
|
||||
<canvas bind:this={overlay} class="w-full h-full absolute pointer-events-none"></canvas>
|
||||
<canvas bind:this={canvas} class="w-full h-full absolute"></canvas>
|
||||
{#if showControls}
|
||||
<div class="h-full flex flex-col justify-center" style="width: {panelSize > 158 ? 22 : 42}px">
|
||||
<div class="absolute bottom-10 right-1.5">
|
||||
<Popover.Root>
|
||||
<Popover.Trigger asChild let:builder>
|
||||
<ButtonWithTooltip
|
||||
label={$_('chart.settings')}
|
||||
builders={[builder]}
|
||||
variant="outline"
|
||||
class="w-7 h-7 p-0 flex justify-center opacity-70 hover:opacity-100 transition-opacity duration-300 hover:bg-background"
|
||||
>
|
||||
<ChartNoAxesColumn size="18" />
|
||||
</ButtonWithTooltip>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content
|
||||
class="w-fit p-0 flex flex-col divide-y"
|
||||
side="top"
|
||||
sideOffset={-32}
|
||||
>
|
||||
<ToggleGroup.Root
|
||||
class="{panelSize > 158
|
||||
? 'flex-col'
|
||||
: 'flex-row'} flex-wrap gap-0 min-h-0 content-center border rounded-t-md"
|
||||
class="flex flex-col items-start gap-0 p-1"
|
||||
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>
|
||||
</Tooltip>
|
||||
<ToggleGroup.Item
|
||||
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
|
||||
value="slope"
|
||||
>
|
||||
<div class="w-6 flex justify-center items-center">
|
||||
{#if elevationFill === 'slope'}
|
||||
<Circle class="h-1.5 w-1.5 fill-current text-current" />
|
||||
{/if}
|
||||
</div>
|
||||
<TriangleRight size="15" class="mr-1" />
|
||||
{$_('quantities.slope')}
|
||||
</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>
|
||||
</Tooltip>
|
||||
<ToggleGroup.Item
|
||||
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
|
||||
value="surface"
|
||||
variant="outline"
|
||||
>
|
||||
<div class="w-6 flex justify-center items-center">
|
||||
{#if elevationFill === 'surface'}
|
||||
<Circle class="h-1.5 w-1.5 fill-current text-current" />
|
||||
{/if}
|
||||
</div>
|
||||
<BrickWall size="15" class="mr-1" />
|
||||
{$_('quantities.surface')}
|
||||
</ToggleGroup.Item>
|
||||
<ToggleGroup.Item
|
||||
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
|
||||
value="highway"
|
||||
variant="outline"
|
||||
>
|
||||
<div class="w-6 flex justify-center items-center">
|
||||
{#if elevationFill === 'highway'}
|
||||
<Circle class="h-1.5 w-1.5 fill-current text-current" />
|
||||
{/if}
|
||||
</div>
|
||||
<Construction size="15" class="mr-1" />
|
||||
{$_('quantities.highway')}
|
||||
</ToggleGroup.Item>
|
||||
</ToggleGroup.Root>
|
||||
<ToggleGroup.Root
|
||||
class="{panelSize > 158
|
||||
? 'flex-col'
|
||||
: 'flex-row'} flex-wrap gap-0 min-h-0 content-center border rounded-b-md -mt-[1px]"
|
||||
class="flex flex-col items-start gap-0 p-1"
|
||||
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 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
|
||||
value="speed"
|
||||
>
|
||||
</Tooltip>
|
||||
<div class="w-6 flex justify-center items-center">
|
||||
{#if additionalDatasets.includes('speed')}
|
||||
<Check size="14" />
|
||||
{/if}
|
||||
</div>
|
||||
<Zap size="15" class="mr-1" />
|
||||
{$velocityUnits === 'speed'
|
||||
? $_('quantities.speed')
|
||||
: $_('quantities.pace')}
|
||||
</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>
|
||||
</Tooltip>
|
||||
<ToggleGroup.Item
|
||||
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
|
||||
value="hr"
|
||||
>
|
||||
<div class="w-6 flex justify-center items-center">
|
||||
{#if additionalDatasets.includes('hr')}
|
||||
<Check size="14" />
|
||||
{/if}
|
||||
</div>
|
||||
<HeartPulse size="15" class="mr-1" />
|
||||
{$_('quantities.heartrate')}
|
||||
</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>
|
||||
</Tooltip>
|
||||
<ToggleGroup.Item
|
||||
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
|
||||
value="cad"
|
||||
>
|
||||
<div class="w-6 flex justify-center items-center">
|
||||
{#if additionalDatasets.includes('cad')}
|
||||
<Check size="14" />
|
||||
{/if}
|
||||
</div>
|
||||
<Orbit size="15" class="mr-1" />
|
||||
{$_('quantities.cadence')}
|
||||
</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>
|
||||
</Tooltip>
|
||||
<ToggleGroup.Item
|
||||
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
|
||||
value="atemp"
|
||||
>
|
||||
<div class="w-6 flex justify-center items-center">
|
||||
{#if additionalDatasets.includes('atemp')}
|
||||
<Check size="14" />
|
||||
{/if}
|
||||
</div>
|
||||
<Thermometer size="15" class="mr-1" />
|
||||
{$_('quantities.temperature')}
|
||||
</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>
|
||||
</Tooltip>
|
||||
<ToggleGroup.Item
|
||||
class="p-0 pr-1.5 h-6 w-full rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
|
||||
value="power"
|
||||
>
|
||||
<div class="w-6 flex justify-center items-center">
|
||||
{#if additionalDatasets.includes('power')}
|
||||
<Check size="14" />
|
||||
{/if}
|
||||
</div>
|
||||
<SquareActivity size="15" class="mr-1" />
|
||||
{$_('quantities.power')}
|
||||
</ToggleGroup.Item>
|
||||
</ToggleGroup.Root>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
@ -10,17 +10,17 @@
|
|||
exportSelectedFiles,
|
||||
ExportState,
|
||||
exportState,
|
||||
gpxStatistics
|
||||
gpxStatistics,
|
||||
} from '$lib/stores';
|
||||
import { fileObservers } from '$lib/db';
|
||||
import {
|
||||
Download,
|
||||
Zap,
|
||||
BrickWall,
|
||||
Earth,
|
||||
HeartPulse,
|
||||
Orbit,
|
||||
Thermometer,
|
||||
SquareActivity
|
||||
SquareActivity,
|
||||
} from 'lucide-svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { selection } from './file-list/Selection';
|
||||
|
@ -31,19 +31,19 @@
|
|||
let open = false;
|
||||
let exportOptions: Record<string, boolean> = {
|
||||
time: true,
|
||||
surface: true,
|
||||
hr: true,
|
||||
cad: true,
|
||||
atemp: true,
|
||||
power: true
|
||||
power: true,
|
||||
extensions: true,
|
||||
};
|
||||
let hide: Record<string, boolean> = {
|
||||
time: false,
|
||||
surface: false,
|
||||
hr: false,
|
||||
cad: false,
|
||||
atemp: false,
|
||||
power: false
|
||||
power: false,
|
||||
extensions: false,
|
||||
};
|
||||
|
||||
$: if ($exportState !== ExportState.NONE) {
|
||||
|
@ -67,6 +67,7 @@
|
|||
hide.cad = statistics.global.cad.count === 0;
|
||||
hide.atemp = statistics.global.atemp.count === 0;
|
||||
hide.power = statistics.global.power.count === 0;
|
||||
hide.extensions = Object.keys(statistics.global.extensions).length === 0;
|
||||
}
|
||||
|
||||
$: exclude = Object.keys(exportOptions).filter((key) => !exportOptions[key]);
|
||||
|
@ -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,13 @@
|
|||
{/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,11 +146,13 @@
|
|||
{$_('quantities.time')}
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-1.5">
|
||||
<Checkbox id="export-surface" bind:checked={exportOptions.surface} />
|
||||
<Label for="export-surface" class="flex flex-row items-center gap-1">
|
||||
<BrickWall size="16" />
|
||||
{$_('quantities.surface')}
|
||||
<div
|
||||
class="flex flex-row items-center gap-1.5 {hide.extensions ? 'hidden' : ''}"
|
||||
>
|
||||
<Checkbox id="export-extensions" bind:checked={exportOptions.extensions} />
|
||||
<Label for="export-extensions" class="flex flex-row items-center gap-1">
|
||||
<Earth size="16" />
|
||||
{$_('quantities.osm_extensions')}
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-1.5 {hide.hr ? 'hidden' : ''}">
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -28,7 +28,7 @@
|
|||
|
||||
<Card.Root
|
||||
class="h-full {orientation === 'vertical'
|
||||
? 'min-w-44 sm:min-w-52 text-sm sm:text-base'
|
||||
? 'min-w-40 sm:min-w-44 text-sm sm:text-base'
|
||||
: 'w-full'} border-none shadow-none"
|
||||
>
|
||||
<Card.Content
|
||||
|
@ -36,48 +36,52 @@
|
|||
? 'flex-col justify-center'
|
||||
: 'flex-row w-full justify-between'} gap-4 p-0"
|
||||
>
|
||||
<Tooltip>
|
||||
<span slot="data" class="flex flex-row items-center">
|
||||
<Ruler size="18" class="mr-1" />
|
||||
<Tooltip label={$_('quantities.distance')}>
|
||||
<span class="flex flex-row items-center">
|
||||
<Ruler size="16" 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">
|
||||
<MoveUpRight size="18" class="mr-1" />
|
||||
<Tooltip label={$_('quantities.elevation_gain_loss')}>
|
||||
<span class="flex flex-row items-center">
|
||||
<MoveUpRight size="16" class="mr-1" />
|
||||
<WithUnits value={statistics.global.elevation.gain} type="elevation" />
|
||||
<MoveDownRight size="18" class="mx-1" />
|
||||
<MoveDownRight size="16" 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">
|
||||
<Zap size="18" class="mr-1" />
|
||||
<WithUnits value={statistics.global.speed.moving} type="speed" showUnits={false} />
|
||||
<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="16" 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">
|
||||
<Timer size="18" class="mr-1" />
|
||||
<Tooltip
|
||||
class={orientation === 'horizontal' ? 'hidden md:block' : ''}
|
||||
label="{$_('quantities.time')} ({$_('quantities.moving')} / {$_(
|
||||
'quantities.total'
|
||||
)})"
|
||||
>
|
||||
<span class="flex flex-row items-center">
|
||||
<Timer size="16" 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';
|
||||
|
@ -7,36 +8,44 @@
|
|||
|
||||
let selected = {
|
||||
value: '',
|
||||
label: ''
|
||||
label: '',
|
||||
};
|
||||
|
||||
$: if ($locale) {
|
||||
selected = {
|
||||
value: $locale,
|
||||
label: languages[$locale]
|
||||
label: languages[$locale],
|
||||
};
|
||||
}
|
||||
</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)}>
|
||||
{#if $page.url.pathname.includes('404')}
|
||||
<a href={getURLForLanguage(lang, '/')}>
|
||||
<Select.Item value={lang}>{label}</Select.Item>
|
||||
</a>
|
||||
{:else}
|
||||
<a href={getURLForLanguage(lang, $page.url.pathname)}>
|
||||
<Select.Item value={lang}>{label}</Select.Item>
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
|
||||
<!-- hidden links for svelte crawling -->
|
||||
<div class="hidden">
|
||||
{#if !$page.url.pathname.includes('404')}
|
||||
{#each Object.entries(languages) as [lang, label]}
|
||||
<a href={getURLForLanguage(lang)}>
|
||||
<a href={getURLForLanguage(lang, $page.url.pathname)}>
|
||||
{label}
|
||||
</a>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -22,16 +22,17 @@
|
|||
mapboxgl.accessToken = accessToken;
|
||||
|
||||
let webgl2Supported = true;
|
||||
let embeddedApp = false;
|
||||
let fitBoundsOptions: mapboxgl.FitBoundsOptions = {
|
||||
maxZoom: 15,
|
||||
linear: true,
|
||||
easing: () => 1
|
||||
easing: () => 1,
|
||||
};
|
||||
|
||||
const { distanceUnits, elevationProfile, verticalFileView, bottomPanelSize, rightPanelSize } =
|
||||
const { distanceUnits, elevationProfile, treeFileView, bottomPanelSize, rightPanelSize } =
|
||||
settings;
|
||||
let scaleControl = new mapboxgl.ScaleControl({
|
||||
unit: $distanceUnits
|
||||
unit: $distanceUnits,
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
|
@ -40,6 +41,10 @@
|
|||
webgl2Supported = false;
|
||||
return;
|
||||
}
|
||||
if (window.top !== window.self && !$page.route.id?.includes('embed')) {
|
||||
embeddedApp = true;
|
||||
return;
|
||||
}
|
||||
|
||||
let language = $page.params.language;
|
||||
if (language === 'zh') {
|
||||
|
@ -52,52 +57,111 @@
|
|||
|
||||
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: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
projection: 'globe',
|
||||
zoom: 0,
|
||||
hash: hash,
|
||||
language,
|
||||
attributionControl: false,
|
||||
logoPosition: 'bottom-right',
|
||||
boxZoom: false
|
||||
boxZoom: false,
|
||||
});
|
||||
newMap.on('load', () => {
|
||||
$map = newMap; // only set the store after the map has loaded
|
||||
window._map = newMap; // entry point for extensions
|
||||
scaleControl.setUnit($distanceUnits);
|
||||
});
|
||||
|
||||
newMap.addControl(
|
||||
new mapboxgl.AttributionControl({
|
||||
compact: true
|
||||
compact: true,
|
||||
})
|
||||
);
|
||||
|
||||
newMap.addControl(
|
||||
new mapboxgl.NavigationControl({
|
||||
visualizePitch: true
|
||||
visualizePitch: true,
|
||||
})
|
||||
);
|
||||
|
||||
if (geocoder) {
|
||||
newMap.addControl(
|
||||
new MapboxGeocoder({
|
||||
accessToken: mapboxgl.accessToken,
|
||||
let geocoder = new MapboxGeocoder({
|
||||
mapboxgl: mapboxgl,
|
||||
enableEventLogging: false,
|
||||
collapsed: true,
|
||||
flyTo: fitBoundsOptions,
|
||||
language
|
||||
})
|
||||
);
|
||||
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) {
|
||||
newMap.addControl(
|
||||
new mapboxgl.GeolocateControl({
|
||||
positionOptions: {
|
||||
enableHighAccuracy: true
|
||||
enableHighAccuracy: true,
|
||||
},
|
||||
fitBoundsOptions,
|
||||
trackUserLocation: true,
|
||||
showUserHeading: true
|
||||
showUserHeading: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
@ -109,37 +173,28 @@
|
|||
type: 'raster-dem',
|
||||
url: 'mapbox://mapbox.mapbox-terrain-dem-v1',
|
||||
tileSize: 512,
|
||||
maxzoom: 14
|
||||
maxzoom: 14,
|
||||
});
|
||||
if (newMap.getPitch() > 0) {
|
||||
newMap.setTerrain({
|
||||
source: 'mapbox-dem',
|
||||
exaggeration: newMap.getPitch() > 0 ? 1 : 0
|
||||
exaggeration: 1,
|
||||
});
|
||||
}
|
||||
newMap.setFog({
|
||||
color: 'rgb(186, 210, 235)',
|
||||
'high-color': 'rgb(36, 92, 223)',
|
||||
'horizon-blend': 0.1,
|
||||
'space-color': 'rgb(156, 240, 255)'
|
||||
'space-color': 'rgb(156, 240, 255)',
|
||||
});
|
||||
newMap.on('pitch', () => {
|
||||
if (newMap.getPitch() > 0) {
|
||||
newMap.setTerrain({
|
||||
source: 'mapbox-dem',
|
||||
exaggeration: 1
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
@ -152,23 +207,30 @@
|
|||
}
|
||||
});
|
||||
|
||||
$: if (
|
||||
$map &&
|
||||
(!$verticalFileView || !$elevationProfile || $bottomPanelSize || $rightPanelSize)
|
||||
) {
|
||||
$: if ($map && (!$treeFileView || !$elevationProfile || $bottomPanelSize || $rightPanelSize)) {
|
||||
$map.resize();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div {...$$restProps}>
|
||||
<div id="map" class="h-full {webgl2Supported ? '' : 'hidden'}"></div>
|
||||
<div id="map" class="h-full {webgl2Supported && !embeddedApp ? '' : 'hidden'}"></div>
|
||||
<div
|
||||
class="flex flex-col items-center justify-center gap-3 h-full {webgl2Supported ? 'hidden' : ''}"
|
||||
class="flex flex-col items-center justify-center gap-3 h-full {webgl2Supported &&
|
||||
!embeddedApp
|
||||
? 'hidden'
|
||||
: ''} {embeddedApp ? 'z-30' : ''}"
|
||||
>
|
||||
{#if !webgl2Supported}
|
||||
<p>{$_('webgl2_required')}</p>
|
||||
<Button href="https://get.webgl.org/webgl2/" target="_blank">
|
||||
{$_('enable_webgl2')}
|
||||
</Button>
|
||||
{:else if embeddedApp}
|
||||
<p>The app cannot be embedded in an iframe.</p>
|
||||
<Button href="https://gpx.studio/help/integration" target="_blank">
|
||||
Learn how to create a map for your website
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -285,7 +347,7 @@
|
|||
|
||||
div :global(.mapboxgl-popup) {
|
||||
@apply w-fit;
|
||||
@apply z-20;
|
||||
@apply z-50;
|
||||
}
|
||||
|
||||
div :global(.mapboxgl-popup-content) {
|
||||
|
|
25
website/src/lib/components/MapPopup.svelte
Normal file
25
website/src/lib/components/MapPopup.svelte
Normal file
|
@ -0,0 +1,25 @@
|
|||
<svelte:options accessors />
|
||||
|
||||
<script lang="ts">
|
||||
import { TrackPoint, Waypoint } from 'gpx';
|
||||
import type { Writable } from 'svelte/store';
|
||||
import WaypointPopup from '$lib/components/gpx-layer/WaypointPopup.svelte';
|
||||
import TrackpointPopup from '$lib/components/gpx-layer/TrackpointPopup.svelte';
|
||||
import OverpassPopup from '$lib/components/layer-control/OverpassPopup.svelte';
|
||||
import type { PopupItem } from './MapPopup';
|
||||
|
||||
export let item: Writable<PopupItem | null>;
|
||||
export let container: HTMLDivElement | null = null;
|
||||
</script>
|
||||
|
||||
<div bind:this={container}>
|
||||
{#if $item}
|
||||
{#if $item.item instanceof Waypoint}
|
||||
<WaypointPopup waypoint={$item} />
|
||||
{:else if $item.item instanceof TrackPoint}
|
||||
<TrackpointPopup trackpoint={$item} />
|
||||
{:else}
|
||||
<OverpassPopup poi={$item} />
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
82
website/src/lib/components/MapPopup.ts
Normal file
82
website/src/lib/components/MapPopup.ts
Normal file
|
@ -0,0 +1,82 @@
|
|||
import { TrackPoint, Waypoint } from 'gpx';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import { tick } from 'svelte';
|
||||
import { get, writable, type Writable } from 'svelte/store';
|
||||
import MapPopupComponent from './MapPopup.svelte';
|
||||
|
||||
export type PopupItem<T = Waypoint | TrackPoint | any> = {
|
||||
item: T;
|
||||
fileId?: string;
|
||||
hide?: () => void;
|
||||
};
|
||||
|
||||
export class MapPopup {
|
||||
map: mapboxgl.Map;
|
||||
popup: mapboxgl.Popup;
|
||||
item: Writable<PopupItem | null> = writable(null);
|
||||
maybeHideBinded = this.maybeHide.bind(this);
|
||||
|
||||
constructor(map: mapboxgl.Map, options?: mapboxgl.PopupOptions) {
|
||||
this.map = map;
|
||||
this.popup = new mapboxgl.Popup(options);
|
||||
|
||||
let component = new MapPopupComponent({
|
||||
target: document.body,
|
||||
props: {
|
||||
item: this.item,
|
||||
},
|
||||
});
|
||||
|
||||
tick().then(() => this.popup.setDOMContent(component.container));
|
||||
}
|
||||
|
||||
setItem(item: PopupItem | null) {
|
||||
if (item) item.hide = () => this.hide();
|
||||
this.item.set(item);
|
||||
if (item === null) {
|
||||
this.hide();
|
||||
} else {
|
||||
tick().then(() => this.show());
|
||||
}
|
||||
}
|
||||
|
||||
show() {
|
||||
const i = get(this.item);
|
||||
if (i === null) {
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
this.popup.setLngLat(this.getCoordinates()).addTo(this.map);
|
||||
this.map.on('mousemove', this.maybeHideBinded);
|
||||
}
|
||||
|
||||
maybeHide(e: mapboxgl.MapMouseEvent) {
|
||||
const i = get(this.item);
|
||||
if (i === null) {
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
if (this.map.project(this.getCoordinates()).dist(this.map.project(e.lngLat)) > 60) {
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.popup.remove();
|
||||
this.map.off('mousemove', this.maybeHideBinded);
|
||||
}
|
||||
|
||||
remove() {
|
||||
this.popup.remove();
|
||||
}
|
||||
|
||||
getCoordinates() {
|
||||
const i = get(this.item);
|
||||
if (i === null) {
|
||||
return new mapboxgl.LngLat(0, 0);
|
||||
}
|
||||
return i.item instanceof Waypoint || i.item instanceof TrackPoint
|
||||
? i.item.getCoordinates()
|
||||
: new mapboxgl.LngLat(i.item.lon, i.item.lat);
|
||||
}
|
||||
}
|
|
@ -21,8 +21,8 @@
|
|||
Thermometer,
|
||||
Sun,
|
||||
Moon,
|
||||
Layers3,
|
||||
GalleryVertical,
|
||||
Layers,
|
||||
ListTree,
|
||||
Languages,
|
||||
Settings,
|
||||
Info,
|
||||
|
@ -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,
|
||||
|
@ -62,7 +64,7 @@
|
|||
cutSelection,
|
||||
pasteSelection,
|
||||
selectAll,
|
||||
selection
|
||||
selection,
|
||||
} from '$lib/components/file-list/Selection';
|
||||
import { derived } from 'svelte/store';
|
||||
import { canUndo, canRedo, dbUtils, fileObservers, settings } from '$lib/db';
|
||||
|
@ -81,7 +83,7 @@
|
|||
velocityUnits,
|
||||
temperatureUnits,
|
||||
elevationProfile,
|
||||
verticalFileView,
|
||||
treeFileView,
|
||||
currentBasemap,
|
||||
previousBasemap,
|
||||
currentOverlays,
|
||||
|
@ -89,7 +91,7 @@
|
|||
distanceMarkers,
|
||||
directionMarkers,
|
||||
streetViewSource,
|
||||
routing
|
||||
routing,
|
||||
} = settings;
|
||||
|
||||
let undoDisabled = derived(canUndo, ($canUndo) => !$canUndo);
|
||||
|
@ -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={getURLForLanguage($locale, '/')} 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>
|
||||
|
@ -149,18 +151,27 @@
|
|||
<Shortcut key="O" ctrl={true} />
|
||||
</Menubar.Item>
|
||||
<Menubar.Separator />
|
||||
<Menubar.Item on:click={dbUtils.duplicateSelection} disabled={$selection.size == 0}>
|
||||
<Menubar.Item
|
||||
on:click={dbUtils.duplicateSelection}
|
||||
disabled={$selection.size == 0}
|
||||
>
|
||||
<Copy size="16" class="mr-1" />
|
||||
{$_('menu.duplicate')}
|
||||
<Shortcut key="D" ctrl={true} />
|
||||
</Menubar.Item>
|
||||
<Menubar.Separator />
|
||||
<Menubar.Item on:click={dbUtils.deleteSelectedFiles} disabled={$selection.size == 0}>
|
||||
<Menubar.Item
|
||||
on:click={dbUtils.deleteSelectedFiles}
|
||||
disabled={$selection.size == 0}
|
||||
>
|
||||
<FileX size="16" class="mr-1" />
|
||||
{$_('menu.close')}
|
||||
<Shortcut key="⌫" ctrl={true} />
|
||||
</Menubar.Item>
|
||||
<Menubar.Item on:click={dbUtils.deleteAllFiles} disabled={$fileObservers.size == 0}>
|
||||
<Menubar.Item
|
||||
on:click={dbUtils.deleteAllFiles}
|
||||
disabled={$fileObservers.size == 0}
|
||||
>
|
||||
<FileX size="16" class="mr-1" />
|
||||
{$_('menu.close_all')}
|
||||
<Shortcut key="⌫" ctrl={true} shift={true} />
|
||||
|
@ -185,7 +196,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>
|
||||
|
@ -205,7 +216,11 @@
|
|||
disabled={$selection.size !== 1 ||
|
||||
!$selection
|
||||
.getSelected()
|
||||
.every((item) => item instanceof ListFileItem || item instanceof ListTrackItem)}
|
||||
.every(
|
||||
(item) =>
|
||||
item instanceof ListFileItem ||
|
||||
item instanceof ListTrackItem
|
||||
)}
|
||||
on:click={() => ($editMetadata = true)}
|
||||
>
|
||||
<Info size="16" class="mr-1" />
|
||||
|
@ -216,7 +231,11 @@
|
|||
disabled={$selection.size === 0 ||
|
||||
!$selection
|
||||
.getSelected()
|
||||
.every((item) => item instanceof ListFileItem || item instanceof ListTrackItem)}
|
||||
.every(
|
||||
(item) =>
|
||||
item instanceof ListFileItem ||
|
||||
item instanceof ListTrackItem
|
||||
)}
|
||||
on:click={() => ($editStyle = true)}
|
||||
>
|
||||
<PaintBucket size="16" class="mr-1" />
|
||||
|
@ -241,13 +260,51 @@
|
|||
{/if}
|
||||
<Shortcut key="H" ctrl={true} />
|
||||
</Menubar.Item>
|
||||
{#if $treeFileView}
|
||||
{#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>
|
||||
{#if $verticalFileView}
|
||||
<Menubar.Item
|
||||
on:click={() => {
|
||||
if ($selection.size > 0) {
|
||||
centerMapOnSelection();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Maximize size="16" class="mr-1" />
|
||||
{$_('menu.center')}
|
||||
<Shortcut key="⏎" ctrl={true} />
|
||||
</Menubar.Item>
|
||||
{#if $treeFileView}
|
||||
<Menubar.Separator />
|
||||
<Menubar.Item on:click={copySelection} disabled={$selection.size === 0}>
|
||||
<ClipboardCopy size="16" class="mr-1" />
|
||||
|
@ -263,7 +320,9 @@
|
|||
disabled={$copied === undefined ||
|
||||
$copied.length === 0 ||
|
||||
($selection.size > 0 &&
|
||||
!allowedPastes[$copied[0].level].includes($selection.getSelected().pop()?.level))}
|
||||
!allowedPastes[$copied[0].level].includes(
|
||||
$selection.getSelected().pop()?.level
|
||||
))}
|
||||
on:click={pasteSelection}
|
||||
>
|
||||
<ClipboardPaste size="16" class="mr-1" />
|
||||
|
@ -272,7 +331,10 @@
|
|||
</Menubar.Item>
|
||||
{/if}
|
||||
<Menubar.Separator />
|
||||
<Menubar.Item on:click={dbUtils.deleteSelection} disabled={$selection.size == 0}>
|
||||
<Menubar.Item
|
||||
on:click={dbUtils.deleteSelection}
|
||||
disabled={$selection.size == 0}
|
||||
>
|
||||
<Trash2 size="16" class="mr-1" />
|
||||
{$_('menu.delete')}
|
||||
<Shortcut key="⌫" ctrl={true} />
|
||||
|
@ -280,7 +342,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>
|
||||
|
@ -290,24 +352,32 @@
|
|||
{$_('menu.elevation_profile')}
|
||||
<Shortcut key="P" ctrl={true} />
|
||||
</Menubar.CheckboxItem>
|
||||
<Menubar.CheckboxItem bind:checked={$verticalFileView}>
|
||||
<GalleryVertical size="16" class="mr-1" />
|
||||
{$_('menu.vertical_file_view')}
|
||||
<Menubar.CheckboxItem bind:checked={$treeFileView}>
|
||||
<ListTree size="16" class="mr-1" />
|
||||
{$_('menu.tree_file_view')}
|
||||
<Shortcut key="L" ctrl={true} />
|
||||
</Menubar.CheckboxItem>
|
||||
<Menubar.Separator />
|
||||
<Menubar.Item inset on:click={switchBasemaps}>
|
||||
<Map size="16" class="mr-1" />{$_('menu.switch_basemap')}<Shortcut key="F1" />
|
||||
<Map size="16" class="mr-1" />{$_('menu.switch_basemap')}<Shortcut
|
||||
key="F1"
|
||||
/>
|
||||
</Menubar.Item>
|
||||
<Menubar.Item inset on:click={toggleOverlays}>
|
||||
<Layers2 size="16" class="mr-1" />{$_('menu.toggle_overlays')}<Shortcut key="F2" />
|
||||
<Layers2 size="16" class="mr-1" />{$_('menu.toggle_overlays')}<Shortcut
|
||||
key="F2"
|
||||
/>
|
||||
</Menubar.Item>
|
||||
<Menubar.Separator />
|
||||
<Menubar.CheckboxItem bind:checked={$distanceMarkers}>
|
||||
<Coins size="16" class="mr-1" />{$_('menu.distance_markers')}<Shortcut key="F3" />
|
||||
<Coins size="16" class="mr-1" />{$_('menu.distance_markers')}<Shortcut
|
||||
key="F3"
|
||||
/>
|
||||
</Menubar.CheckboxItem>
|
||||
<Menubar.CheckboxItem bind:checked={$directionMarkers}>
|
||||
<Milestone size="16" class="mr-1" />{$_('menu.direction_markers')}<Shortcut key="F4" />
|
||||
<Milestone size="16" class="mr-1" />{$_('menu.direction_markers')}<Shortcut
|
||||
key="F4"
|
||||
/>
|
||||
</Menubar.CheckboxItem>
|
||||
<Menubar.Separator />
|
||||
<Menubar.Item inset on:click={toggle3D}>
|
||||
|
@ -318,32 +388,43 @@
|
|||
</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>
|
||||
<Menubar.SubContent>
|
||||
<Menubar.RadioGroup bind:value={$distanceUnits}>
|
||||
<Menubar.RadioItem value="metric">{$_('menu.metric')}</Menubar.RadioItem>
|
||||
<Menubar.RadioItem value="imperial">{$_('menu.imperial')}</Menubar.RadioItem>
|
||||
<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>
|
||||
<Menubar.RadioItem value="pace">{$_('quantities.pace')}</Menubar.RadioItem>
|
||||
<Menubar.RadioItem value="speed"
|
||||
>{$_('quantities.speed')}</Menubar.RadioItem
|
||||
>
|
||||
<Menubar.RadioItem value="pace"
|
||||
>{$_('quantities.pace')}</Menubar.RadioItem
|
||||
>
|
||||
</Menubar.RadioGroup>
|
||||
</Menubar.SubContent>
|
||||
</Menubar.Sub>
|
||||
|
@ -353,8 +434,12 @@
|
|||
</Menubar.SubTrigger>
|
||||
<Menubar.SubContent>
|
||||
<Menubar.RadioGroup bind:value={$temperatureUnits}>
|
||||
<Menubar.RadioItem value="celsius">{$_('menu.celsius')}</Menubar.RadioItem>
|
||||
<Menubar.RadioItem value="fahrenheit">{$_('menu.fahrenheit')}</Menubar.RadioItem>
|
||||
<Menubar.RadioItem value="celsius"
|
||||
>{$_('menu.celsius')}</Menubar.RadioItem
|
||||
>
|
||||
<Menubar.RadioItem value="fahrenheit"
|
||||
>{$_('menu.fahrenheit')}</Menubar.RadioItem
|
||||
>
|
||||
</Menubar.RadioGroup>
|
||||
</Menubar.SubContent>
|
||||
</Menubar.Sub>
|
||||
|
@ -367,7 +452,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}
|
||||
|
@ -390,8 +475,11 @@
|
|||
setMode(value);
|
||||
}}
|
||||
>
|
||||
<Menubar.RadioItem value="light">{$_('menu.light')}</Menubar.RadioItem>
|
||||
<Menubar.RadioItem value="dark">{$_('menu.dark')}</Menubar.RadioItem>
|
||||
<Menubar.RadioItem value="light"
|
||||
>{$_('menu.light')}</Menubar.RadioItem
|
||||
>
|
||||
<Menubar.RadioItem value="dark">{$_('menu.dark')}</Menubar.RadioItem
|
||||
>
|
||||
</Menubar.RadioGroup>
|
||||
</Menubar.SubContent>
|
||||
</Menubar.Sub>
|
||||
|
@ -403,13 +491,17 @@
|
|||
</Menubar.SubTrigger>
|
||||
<Menubar.SubContent>
|
||||
<Menubar.RadioGroup bind:value={$streetViewSource}>
|
||||
<Menubar.RadioItem value="mapillary">{$_('menu.mapillary')}</Menubar.RadioItem>
|
||||
<Menubar.RadioItem value="google">{$_('menu.google')}</Menubar.RadioItem>
|
||||
<Menubar.RadioItem value="mapillary"
|
||||
>{$_('menu.mapillary')}</Menubar.RadioItem
|
||||
>
|
||||
<Menubar.RadioItem value="google"
|
||||
>{$_('menu.google')}</Menubar.RadioItem
|
||||
>
|
||||
</Menubar.RadioGroup>
|
||||
</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 +513,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 +525,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 +592,16 @@
|
|||
} else {
|
||||
dbUtils.undo();
|
||||
}
|
||||
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();
|
||||
|
@ -524,7 +621,7 @@
|
|||
$elevationProfile = !$elevationProfile;
|
||||
e.preventDefault();
|
||||
} else if (e.key === 'l' && (e.metaKey || e.ctrlKey)) {
|
||||
$verticalFileView = !$verticalFileView;
|
||||
$treeFileView = !$treeFileView;
|
||||
e.preventDefault();
|
||||
} else if (e.key === 'h' && (e.metaKey || e.ctrlKey)) {
|
||||
if ($allHidden) {
|
||||
|
@ -533,6 +630,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>
|
||||
|
|
|
@ -12,7 +12,8 @@
|
|||
|
||||
const handleMouseMove = (event: PointerEvent) => {
|
||||
const newAfter =
|
||||
startAfter + (orientation === 'col' ? startX - event.clientX : startY - event.clientY);
|
||||
startAfter +
|
||||
(orientation === 'col' ? startX - event.clientX : startY - event.clientY);
|
||||
if (newAfter >= minAfter && newAfter <= maxAfter) {
|
||||
after = newAfter;
|
||||
} else if (newAfter < minAfter && after !== minAfter) {
|
||||
|
|
|
@ -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>
|
||||
{#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>
|
||||
<span>{click ? $_('menu.click') : ''}</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,10 +2,13 @@
|
|||
import { settings } from '$lib/db';
|
||||
import {
|
||||
celsiusToFahrenheit,
|
||||
distancePerHourToSecondsPerDistance,
|
||||
kilometersToMiles,
|
||||
metersToFeet,
|
||||
secondsToHHMMSS
|
||||
getConvertedDistance,
|
||||
getConvertedElevation,
|
||||
getConvertedVelocity,
|
||||
getDistanceUnits,
|
||||
getElevationUnits,
|
||||
getVelocityUnits,
|
||||
secondsToHHMMSS,
|
||||
} from '$lib/units';
|
||||
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
@ -20,31 +23,18 @@
|
|||
|
||||
<span class={$$props.class}>
|
||||
{#if type === 'distance'}
|
||||
{#if $distanceUnits === 'metric'}
|
||||
{value.toFixed(decimals ?? 2)} {showUnits ? $_('units.kilometers') : ''}
|
||||
{:else}
|
||||
{kilometersToMiles(value).toFixed(decimals ?? 2)} {showUnits ? $_('units.miles') : ''}
|
||||
{/if}
|
||||
{getConvertedDistance(value, $distanceUnits).toFixed(decimals ?? 2)}
|
||||
{showUnits ? getDistanceUnits($distanceUnits) : ''}
|
||||
{:else if type === 'elevation'}
|
||||
{#if $distanceUnits === 'metric'}
|
||||
{value.toFixed(decimals ?? 0)} {showUnits ? $_('units.meters') : ''}
|
||||
{:else}
|
||||
{metersToFeet(value).toFixed(decimals ?? 0)} {showUnits ? $_('units.feet') : ''}
|
||||
{/if}
|
||||
{getConvertedElevation(value, $distanceUnits).toFixed(decimals ?? 0)}
|
||||
{showUnits ? getElevationUnits($distanceUnits) : ''}
|
||||
{:else if type === 'speed'}
|
||||
{#if $distanceUnits === 'metric'}
|
||||
{#if $velocityUnits === 'speed'}
|
||||
{value.toFixed(decimals ?? 2)} {showUnits ? $_('units.kilometers_per_hour') : ''}
|
||||
{getConvertedVelocity(value, $velocityUnits, $distanceUnits).toFixed(decimals ?? 2)}
|
||||
{showUnits ? getVelocityUnits($velocityUnits, $distanceUnits) : ''}
|
||||
{:else}
|
||||
{secondsToHHMMSS(distancePerHourToSecondsPerDistance(value))}
|
||||
{showUnits ? $_('units.minutes_per_kilometer') : ''}
|
||||
{/if}
|
||||
{:else if $velocityUnits === 'speed'}
|
||||
{kilometersToMiles(value).toFixed(decimals ?? 2)}
|
||||
{showUnits ? $_('units.miles_per_hour') : ''}
|
||||
{:else}
|
||||
{secondsToHHMMSS(distancePerHourToSecondsPerDistance(kilometersToMiles(value)))}
|
||||
{showUnits ? $_('units.minutes_per_mile') : ''}
|
||||
{secondsToHHMMSS(getConvertedVelocity(value, $velocityUnits, $distanceUnits))}
|
||||
{showUnits ? getVelocityUnits($velocityUnits, $distanceUnits) : ''}
|
||||
{/if}
|
||||
{:else if type === 'temperature'}
|
||||
{#if $temperatureUnits === 'celsius'}
|
||||
|
|
82
website/src/lib/components/docs/DocsContainer.svelte
Normal file
82
website/src/lib/components/docs/DocsContainer.svelte
Normal file
|
@ -0,0 +1,82 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
export let module;
|
||||
</script>
|
||||
|
||||
<div class="markdown flex flex-col gap-3">
|
||||
<svelte:component this={module} />
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
:global(.markdown) {
|
||||
@apply text-muted-foreground;
|
||||
}
|
||||
|
||||
:global(.markdown h1) {
|
||||
@apply text-foreground;
|
||||
@apply text-3xl;
|
||||
@apply font-semibold;
|
||||
@apply mb-3 pt-6;
|
||||
}
|
||||
|
||||
:global(.markdown h2) {
|
||||
@apply text-foreground;
|
||||
@apply text-2xl;
|
||||
@apply font-semibold;
|
||||
@apply pt-3;
|
||||
}
|
||||
|
||||
:global(.markdown h3) {
|
||||
@apply text-foreground;
|
||||
@apply text-lg;
|
||||
@apply font-semibold;
|
||||
@apply pt-1.5;
|
||||
}
|
||||
|
||||
:global(.markdown p > button, .markdown li > button) {
|
||||
@apply border;
|
||||
@apply rounded-md;
|
||||
@apply px-1;
|
||||
}
|
||||
|
||||
:global(.markdown > a) {
|
||||
@apply text-link;
|
||||
@apply hover:underline;
|
||||
}
|
||||
|
||||
:global(.markdown p > a) {
|
||||
@apply text-link;
|
||||
@apply hover:underline;
|
||||
}
|
||||
|
||||
:global(.markdown li > a) {
|
||||
@apply text-link;
|
||||
@apply hover:underline;
|
||||
}
|
||||
|
||||
:global(.markdown kbd) {
|
||||
@apply p-1;
|
||||
@apply rounded-md;
|
||||
@apply border;
|
||||
}
|
||||
|
||||
:global(.markdown ul) {
|
||||
@apply list-disc;
|
||||
@apply pl-4;
|
||||
}
|
||||
|
||||
:global(.markdown ol) {
|
||||
@apply list-decimal;
|
||||
@apply pl-4;
|
||||
}
|
||||
|
||||
:global(.markdown li) {
|
||||
@apply mt-1;
|
||||
@apply first:mt-0;
|
||||
}
|
||||
|
||||
:global(.markdown hr) {
|
||||
@apply my-5;
|
||||
}
|
||||
</style>
|
|
@ -1,11 +1,29 @@
|
|||
<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>
|
||||
|
|
|
@ -1,112 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
import { base } from '$app/paths';
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
|
||||
export let path: string;
|
||||
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`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if module !== undefined}
|
||||
{#if titleOnly}
|
||||
{metadata.title}
|
||||
{:else}
|
||||
<div class="markdown flex flex-col gap-3">
|
||||
<svelte:component this={module} />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style lang="postcss">
|
||||
:global(.markdown) {
|
||||
@apply text-muted-foreground;
|
||||
}
|
||||
|
||||
:global(.markdown h1) {
|
||||
@apply text-foreground;
|
||||
@apply text-3xl;
|
||||
@apply font-semibold;
|
||||
@apply mb-3 pt-6;
|
||||
}
|
||||
|
||||
:global(.markdown h2) {
|
||||
@apply text-foreground;
|
||||
@apply text-2xl;
|
||||
@apply font-semibold;
|
||||
@apply pt-3;
|
||||
}
|
||||
|
||||
:global(.markdown h3) {
|
||||
@apply text-foreground;
|
||||
@apply text-lg;
|
||||
@apply font-semibold;
|
||||
@apply pt-1.5;
|
||||
}
|
||||
|
||||
:global(.markdown p > button) {
|
||||
@apply border;
|
||||
@apply rounded-md;
|
||||
@apply px-1;
|
||||
}
|
||||
|
||||
:global(.markdown > a) {
|
||||
@apply text-blue-500;
|
||||
@apply hover:underline;
|
||||
}
|
||||
|
||||
:global(.markdown p > a) {
|
||||
@apply text-blue-500;
|
||||
@apply hover:underline;
|
||||
}
|
||||
|
||||
:global(.markdown li > a) {
|
||||
@apply text-blue-500;
|
||||
@apply hover:underline;
|
||||
}
|
||||
|
||||
:global(.markdown kbd) {
|
||||
@apply p-1;
|
||||
@apply rounded-md;
|
||||
@apply border;
|
||||
}
|
||||
|
||||
:global(.markdown ul) {
|
||||
@apply list-disc;
|
||||
@apply pl-4;
|
||||
}
|
||||
|
||||
:global(.markdown ol) {
|
||||
@apply list-decimal;
|
||||
@apply pl-4;
|
||||
}
|
||||
|
||||
:global(.markdown li) {
|
||||
@apply mt-1;
|
||||
@apply first:mt-0;
|
||||
}
|
||||
|
||||
:global(.markdown hr) {
|
||||
@apply my-5;
|
||||
}
|
||||
</style>
|
|
@ -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,36 +1,64 @@
|
|||
import { File, FilePen, View, type Icon, Settings, Pencil, MapPin, Scissors, CalendarClock, Group, Ungroup, Filter, SquareDashedMousePointer } from "lucide-svelte";
|
||||
import type { ComponentType } from "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': [],
|
||||
gpx: [],
|
||||
integration: [],
|
||||
faq: [],
|
||||
};
|
||||
|
||||
export const guideIcons: Record<string, string | ComponentType<Icon>> = {
|
||||
"getting-started": "🚀",
|
||||
"menu": "📂 ⚙️",
|
||||
"file": File,
|
||||
"edit": FilePen,
|
||||
"view": View,
|
||||
"settings": Settings,
|
||||
"files-and-stats": "🗂 📈",
|
||||
"toolbar": "🧰",
|
||||
"routing": Pencil,
|
||||
"poi": MapPin,
|
||||
"scissors": Scissors,
|
||||
"time": CalendarClock,
|
||||
"merge": Group,
|
||||
"extract": Ungroup,
|
||||
"minify": Filter,
|
||||
"clean": SquareDashedMousePointer,
|
||||
"map-controls": "🗺",
|
||||
"gpx": "💾",
|
||||
"integration": "{ 👩💻 }",
|
||||
'getting-started': '🚀',
|
||||
menu: '📂 ⚙️',
|
||||
file: File,
|
||||
edit: FilePen,
|
||||
view: View,
|
||||
settings: Settings,
|
||||
'files-and-stats': '🗂 📈',
|
||||
toolbar: '🧰',
|
||||
routing: Pencil,
|
||||
poi: MapPin,
|
||||
scissors: Scissors,
|
||||
time: CalendarClock,
|
||||
merge: Group,
|
||||
extract: Ungroup,
|
||||
elevation: MountainSnow,
|
||||
minify: Filter,
|
||||
clean: SquareDashedMousePointer,
|
||||
'map-controls': '🗺',
|
||||
gpx: '💾',
|
||||
integration: '{ 👩💻 }',
|
||||
faq: '🔮',
|
||||
};
|
||||
|
||||
export function getPreviousGuide(currentGuide: string): string | undefined {
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
embedding,
|
||||
loadFile,
|
||||
map,
|
||||
updateGPXData
|
||||
updateGPXData,
|
||||
} from '$lib/stores';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { fileObservers, settings, GPXStatisticsTree } from '$lib/db';
|
||||
|
@ -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;
|
||||
|
||||
|
@ -32,7 +37,7 @@
|
|||
temperatureUnits,
|
||||
fileOrder,
|
||||
distanceMarkers,
|
||||
directionMarkers
|
||||
directionMarkers,
|
||||
} = settings;
|
||||
|
||||
export let useHash = true;
|
||||
|
@ -45,7 +50,7 @@
|
|||
distanceUnits: 'metric',
|
||||
velocityUnits: 'speed',
|
||||
temperatureUnits: 'celsius',
|
||||
theme: 'system'
|
||||
theme: 'system',
|
||||
};
|
||||
|
||||
function applyOptions() {
|
||||
|
@ -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())
|
||||
|
@ -69,12 +74,12 @@
|
|||
let bounds = {
|
||||
southWest: {
|
||||
lat: 90,
|
||||
lon: 180
|
||||
lon: 180,
|
||||
},
|
||||
northEast: {
|
||||
lat: -90,
|
||||
lon: -180
|
||||
}
|
||||
lon: -180,
|
||||
},
|
||||
};
|
||||
|
||||
fileObservers.update(($fileObservers) => {
|
||||
|
@ -91,12 +96,13 @@
|
|||
id,
|
||||
readable({
|
||||
file,
|
||||
statistics
|
||||
statistics,
|
||||
})
|
||||
);
|
||||
|
||||
ids.push(id);
|
||||
let fileBounds = statistics.getStatisticsFor(new ListFileItem(id)).global.bounds;
|
||||
let fileBounds = statistics.getStatisticsFor(new ListFileItem(id)).global
|
||||
.bounds;
|
||||
|
||||
bounds.southWest.lat = Math.min(bounds.southWest.lat, fileBounds.southWest.lat);
|
||||
bounds.southWest.lon = Math.min(bounds.southWest.lon, fileBounds.southWest.lon);
|
||||
|
@ -125,12 +131,12 @@
|
|||
bounds.southWest.lon,
|
||||
bounds.southWest.lat,
|
||||
bounds.northEast.lon,
|
||||
bounds.northEast.lat
|
||||
bounds.northEast.lat,
|
||||
],
|
||||
{
|
||||
padding: 80,
|
||||
linear: true,
|
||||
easing: () => 1
|
||||
easing: () => 1,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
@ -138,7 +144,10 @@
|
|||
}
|
||||
});
|
||||
|
||||
if (options.basemap !== $currentBasemap && allowedEmbeddingBasemaps.includes(options.basemap)) {
|
||||
if (
|
||||
options.basemap !== $currentBasemap &&
|
||||
allowedEmbeddingBasemaps.includes(options.basemap)
|
||||
) {
|
||||
$currentBasemap = options.basemap;
|
||||
}
|
||||
|
||||
|
@ -176,7 +185,7 @@
|
|||
prevSettings.theme = $mode ?? 'system';
|
||||
});
|
||||
|
||||
$: if (options) {
|
||||
$: if (browser && options) {
|
||||
applyOptions();
|
||||
}
|
||||
|
||||
|
@ -224,7 +233,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}
|
||||
|
@ -252,12 +261,10 @@
|
|||
options.elevation.hr ? 'hr' : null,
|
||||
options.elevation.cad ? 'cad' : null,
|
||||
options.elevation.temp ? 'temp' : null,
|
||||
options.elevation.power ? 'power' : null
|
||||
options.elevation.power ? 'power' : null,
|
||||
].filter((dataset) => dataset !== null)}
|
||||
elevationFill={options.elevation.fill}
|
||||
panelSize={options.elevation.height}
|
||||
showControls={options.elevation.controls}
|
||||
class="py-2"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
@ -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' | 'highway' | 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,
|
||||
|
@ -50,10 +53,17 @@ 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])) {
|
||||
if (
|
||||
typeof options[key] === 'object' &&
|
||||
options[key] !== null &&
|
||||
!Array.isArray(options[key])
|
||||
) {
|
||||
mergedOptions[key] = getMergedEmbeddingOptions(options[key], defaultOptions[key]);
|
||||
} else {
|
||||
mergedOptions[key] = options[key];
|
||||
|
@ -62,11 +72,21 @@ 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])) {
|
||||
cleanedOptions[key] = getCleanedEmbeddingOptions(cleanedOptions[key], defaultOptions[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 +97,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;
|
||||
}
|
||||
|
|
|
@ -13,13 +13,13 @@
|
|||
SquareActivity,
|
||||
Coins,
|
||||
Milestone,
|
||||
Video
|
||||
Video,
|
||||
} from 'lucide-svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import {
|
||||
allowedEmbeddingBasemaps,
|
||||
getCleanedEmbeddingOptions,
|
||||
getDefaultEmbeddingOptions
|
||||
getDefaultEmbeddingOptions,
|
||||
} from './Embedding';
|
||||
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
|
||||
import Embedding from './Embedding.svelte';
|
||||
|
@ -30,17 +30,25 @@
|
|||
let options = getDefaultEmbeddingOptions();
|
||||
options.token = 'YOUR_MAPBOX_TOKEN';
|
||||
options.files = [
|
||||
'https://raw.githubusercontent.com/gpxstudio/gpx.studio/main/gpx/test-data/simple.gpx'
|
||||
'https://raw.githubusercontent.com/gpxstudio/gpx.studio/main/gpx/test-data/simple.gpx',
|
||||
];
|
||||
|
||||
let files = options.files[0];
|
||||
$: if (files) {
|
||||
$: {
|
||||
let urls = files.split(',');
|
||||
urls = urls.filter((url) => url.length > 0);
|
||||
if (JSON.stringify(urls) !== JSON.stringify(options.files)) {
|
||||
options.files = urls;
|
||||
}
|
||||
}
|
||||
let 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}`) }}
|
||||
|
@ -120,7 +130,11 @@
|
|||
<div class="grid grid-cols-2 gap-x-6 gap-y-3 rounded-md border p-3 mt-1">
|
||||
<Label class="flex flex-row items-center gap-2">
|
||||
{$_('embedding.height')}
|
||||
<Input type="number" bind:value={options.elevation.height} class="h-8 w-20" />
|
||||
<Input
|
||||
type="number"
|
||||
bind:value={options.elevation.height}
|
||||
class="h-8 w-20"
|
||||
/>
|
||||
</Label>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<span class="shrink-0">
|
||||
|
@ -132,7 +146,11 @@
|
|||
let value = selected?.value;
|
||||
if (value === 'none') {
|
||||
options.elevation.fill = undefined;
|
||||
} else if (value === 'slope' || value === 'surface') {
|
||||
} else if (
|
||||
value === 'slope' ||
|
||||
value === 'surface' ||
|
||||
value === 'highway'
|
||||
) {
|
||||
options.elevation.fill = value;
|
||||
}
|
||||
}}
|
||||
|
@ -142,7 +160,10 @@
|
|||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="slope">{$_('quantities.slope')}</Select.Item>
|
||||
<Select.Item value="surface">{$_('quantities.surface')}</Select.Item>
|
||||
<Select.Item value="surface">{$_('quantities.surface')}</Select.Item
|
||||
>
|
||||
<Select.Item value="highway">{$_('quantities.highway')}</Select.Item
|
||||
>
|
||||
<Select.Item value="none">{$_('embedding.none')}</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
|
@ -155,35 +176,35 @@
|
|||
<Checkbox id="show-speed" bind:checked={options.elevation.speed} />
|
||||
<Label for="show-speed" class="flex flex-row items-center gap-1">
|
||||
<Zap size="16" />
|
||||
{$_('chart.show_speed')}
|
||||
{$_('quantities.speed')}
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Checkbox id="show-hr" bind:checked={options.elevation.hr} />
|
||||
<Label for="show-hr" class="flex flex-row items-center gap-1">
|
||||
<HeartPulse size="16" />
|
||||
{$_('chart.show_heartrate')}
|
||||
{$_('quantities.heartrate')}
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Checkbox id="show-cad" bind:checked={options.elevation.cad} />
|
||||
<Label for="show-cad" class="flex flex-row items-center gap-1">
|
||||
<Orbit size="16" />
|
||||
{$_('chart.show_cadence')}
|
||||
{$_('quantities.cadence')}
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Checkbox id="show-temp" bind:checked={options.elevation.temp} />
|
||||
<Label for="show-temp" class="flex flex-row items-center gap-1">
|
||||
<Thermometer size="16" />
|
||||
{$_('chart.show_temperature')}
|
||||
{$_('quantities.temperature')}
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<Checkbox id="show-power" bind:checked={options.elevation.power} />
|
||||
<Label for="show-power" class="flex flex-row items-center gap-1">
|
||||
<SquareActivity size="16" />
|
||||
{$_('chart.show_power')}
|
||||
{$_('quantities.power')}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -214,6 +235,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">
|
||||
|
@ -303,7 +328,8 @@
|
|||
<Label>
|
||||
{$_('embedding.code')}
|
||||
</Label>
|
||||
<pre class="bg-primary text-primary-foreground p-3 rounded-md whitespace-normal break-all">
|
||||
<pre
|
||||
class="bg-primary text-primary-foreground p-3 rounded-md whitespace-normal break-all">
|
||||
<code class="language-html">
|
||||
{`<iframe src="https://gpx.studio${base}/embed?options=${encodeURIComponent(JSON.stringify(getCleanedEmbeddingOptions(options)))}${hash}" width="100%" height="600px" frameborder="0" style="outline: none;"/>`}
|
||||
</code>
|
||||
|
|
|
@ -5,12 +5,17 @@
|
|||
import { _, locale } from 'svelte-i18n';
|
||||
|
||||
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))}"
|
||||
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')}
|
||||
|
|
|
@ -17,9 +17,9 @@
|
|||
setContext('orientation', orientation);
|
||||
setContext('recursive', recursive);
|
||||
|
||||
const { verticalFileView } = settings;
|
||||
const { treeFileView } = settings;
|
||||
|
||||
verticalFileView.subscribe(($vertical) => {
|
||||
treeFileView.subscribe(($vertical) => {
|
||||
if ($vertical) {
|
||||
selection.update(($selection) => {
|
||||
$selection.forEach((item) => {
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { dbUtils, getFile } from "$lib/db";
|
||||
import { freeze } from "immer";
|
||||
import { GPXFile, Track, TrackSegment, Waypoint } from "gpx";
|
||||
import { selection } from "./Selection";
|
||||
import { newGPXFile } from "$lib/stores";
|
||||
import { dbUtils, getFile } from '$lib/db';
|
||||
import { freeze } from 'immer';
|
||||
import { GPXFile, Track, TrackSegment, Waypoint } from 'gpx';
|
||||
import { selection } from './Selection';
|
||||
import { newGPXFile } from '$lib/stores';
|
||||
|
||||
export enum ListLevel {
|
||||
ROOT,
|
||||
|
@ -10,7 +10,7 @@ export enum ListLevel {
|
|||
TRACK,
|
||||
SEGMENT,
|
||||
WAYPOINTS,
|
||||
WAYPOINT
|
||||
WAYPOINT,
|
||||
}
|
||||
|
||||
export const allowedMoves: Record<ListLevel, ListLevel[]> = {
|
||||
|
@ -19,7 +19,7 @@ export const allowedMoves: Record<ListLevel, ListLevel[]> = {
|
|||
[ListLevel.TRACK]: [ListLevel.FILE, ListLevel.TRACK],
|
||||
[ListLevel.SEGMENT]: [ListLevel.FILE, ListLevel.TRACK, ListLevel.SEGMENT],
|
||||
[ListLevel.WAYPOINTS]: [ListLevel.WAYPOINTS],
|
||||
[ListLevel.WAYPOINT]: [ListLevel.WAYPOINTS, ListLevel.WAYPOINT]
|
||||
[ListLevel.WAYPOINT]: [ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
|
||||
};
|
||||
|
||||
export const allowedPastes: Record<ListLevel, ListLevel[]> = {
|
||||
|
@ -28,7 +28,7 @@ export const allowedPastes: Record<ListLevel, ListLevel[]> = {
|
|||
[ListLevel.TRACK]: [ListLevel.ROOT, ListLevel.FILE, ListLevel.TRACK],
|
||||
[ListLevel.SEGMENT]: [ListLevel.ROOT, ListLevel.FILE, ListLevel.TRACK, ListLevel.SEGMENT],
|
||||
[ListLevel.WAYPOINTS]: [ListLevel.FILE, ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
|
||||
[ListLevel.WAYPOINT]: [ListLevel.FILE, ListLevel.WAYPOINTS, ListLevel.WAYPOINT]
|
||||
[ListLevel.WAYPOINT]: [ListLevel.FILE, ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
|
||||
};
|
||||
|
||||
export abstract class ListItem {
|
||||
|
@ -322,7 +322,13 @@ export function sortItems(items: ListItem[], reverse: boolean = false) {
|
|||
}
|
||||
}
|
||||
|
||||
export function moveItems(fromParent: ListItem, toParent: ListItem, fromItems: ListItem[], toItems: ListItem[], remove: boolean = true) {
|
||||
export function moveItems(
|
||||
fromParent: ListItem,
|
||||
toParent: ListItem,
|
||||
fromItems: ListItem[],
|
||||
toItems: ListItem[],
|
||||
remove: boolean = true
|
||||
) {
|
||||
if (fromItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
@ -338,11 +344,18 @@ export function moveItems(fromParent: ListItem, toParent: ListItem, fromItems: L
|
|||
context.push(file.clone());
|
||||
} else if (item instanceof ListTrackItem && item.getTrackIndex() < file.trk.length) {
|
||||
context.push(file.trk[item.getTrackIndex()].clone());
|
||||
} else if (item instanceof ListTrackSegmentItem && item.getTrackIndex() < file.trk.length && item.getSegmentIndex() < file.trk[item.getTrackIndex()].trkseg.length) {
|
||||
} else if (
|
||||
item instanceof ListTrackSegmentItem &&
|
||||
item.getTrackIndex() < file.trk.length &&
|
||||
item.getSegmentIndex() < file.trk[item.getTrackIndex()].trkseg.length
|
||||
) {
|
||||
context.push(file.trk[item.getTrackIndex()].trkseg[item.getSegmentIndex()].clone());
|
||||
} else if (item instanceof ListWaypointsItem) {
|
||||
context.push(file.wpt.map((wpt) => wpt.clone()));
|
||||
} else if (item instanceof ListWaypointItem && item.getWaypointIndex() < file.wpt.length) {
|
||||
} else if (
|
||||
item instanceof ListWaypointItem &&
|
||||
item.getWaypointIndex() < file.wpt.length
|
||||
) {
|
||||
context.push(file.wpt[item.getWaypointIndex()].clone());
|
||||
}
|
||||
}
|
||||
|
@ -359,7 +372,12 @@ export function moveItems(fromParent: ListItem, toParent: ListItem, fromItems: L
|
|||
if (item instanceof ListTrackItem) {
|
||||
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex(), []);
|
||||
} else if (item instanceof ListTrackSegmentItem) {
|
||||
file.replaceTrackSegments(item.getTrackIndex(), item.getSegmentIndex(), item.getSegmentIndex(), []);
|
||||
file.replaceTrackSegments(
|
||||
item.getTrackIndex(),
|
||||
item.getSegmentIndex(),
|
||||
item.getSegmentIndex(),
|
||||
[]
|
||||
);
|
||||
} else if (item instanceof ListWaypointsItem) {
|
||||
file.replaceWaypoints(0, file.wpt.length - 1, []);
|
||||
} else if (item instanceof ListWaypointItem) {
|
||||
|
@ -371,25 +389,43 @@ export function moveItems(fromParent: ListItem, toParent: ListItem, fromItems: L
|
|||
toItems.forEach((item, i) => {
|
||||
if (item instanceof ListTrackItem) {
|
||||
if (context[i] instanceof Track) {
|
||||
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex() - 1, [context[i]]);
|
||||
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex() - 1, [
|
||||
context[i],
|
||||
]);
|
||||
} else if (context[i] instanceof TrackSegment) {
|
||||
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex() - 1, [new Track({
|
||||
trkseg: [context[i]]
|
||||
})]);
|
||||
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex() - 1, [
|
||||
new Track({
|
||||
trkseg: [context[i]],
|
||||
}),
|
||||
]);
|
||||
}
|
||||
} else if (item instanceof ListTrackSegmentItem && context[i] instanceof TrackSegment) {
|
||||
file.replaceTrackSegments(item.getTrackIndex(), item.getSegmentIndex(), item.getSegmentIndex() - 1, [context[i]]);
|
||||
} else if (
|
||||
item instanceof ListTrackSegmentItem &&
|
||||
context[i] instanceof TrackSegment
|
||||
) {
|
||||
file.replaceTrackSegments(
|
||||
item.getTrackIndex(),
|
||||
item.getSegmentIndex(),
|
||||
item.getSegmentIndex() - 1,
|
||||
[context[i]]
|
||||
);
|
||||
} else if (item instanceof ListWaypointsItem) {
|
||||
if (Array.isArray(context[i]) && context[i].length > 0 && context[i][0] instanceof Waypoint) {
|
||||
if (
|
||||
Array.isArray(context[i]) &&
|
||||
context[i].length > 0 &&
|
||||
context[i][0] instanceof Waypoint
|
||||
) {
|
||||
file.replaceWaypoints(file.wpt.length, file.wpt.length - 1, context[i]);
|
||||
} else if (context[i] instanceof Waypoint) {
|
||||
file.replaceWaypoints(file.wpt.length, file.wpt.length - 1, [context[i]]);
|
||||
}
|
||||
} else if (item instanceof ListWaypointItem && context[i] instanceof Waypoint) {
|
||||
file.replaceWaypoints(item.getWaypointIndex(), item.getWaypointIndex() - 1, [context[i]]);
|
||||
file.replaceWaypoints(item.getWaypointIndex(), item.getWaypointIndex() - 1, [
|
||||
context[i],
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
if (fromParent instanceof ListRootItem) {
|
||||
|
@ -400,7 +436,10 @@ export function moveItems(fromParent: ListItem, toParent: ListItem, fromItems: L
|
|||
callbacks.splice(0, 1);
|
||||
}
|
||||
|
||||
dbUtils.applyEachToFilesAndGlobal(files, callbacks, (files, context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[]) => {
|
||||
dbUtils.applyEachToFilesAndGlobal(
|
||||
files,
|
||||
callbacks,
|
||||
(files, context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[]) => {
|
||||
toItems.forEach((item, i) => {
|
||||
if (item instanceof ListFileItem) {
|
||||
if (context[i] instanceof GPXFile) {
|
||||
|
@ -421,14 +460,18 @@ export function moveItems(fromParent: ListItem, toParent: ListItem, fromItems: L
|
|||
} else if (context[i] instanceof TrackSegment) {
|
||||
let newFile = newGPXFile();
|
||||
newFile._data.id = item.getFileId();
|
||||
newFile.replaceTracks(0, 0, [new Track({
|
||||
trkseg: [context[i]]
|
||||
})]);
|
||||
newFile.replaceTracks(0, 0, [
|
||||
new Track({
|
||||
trkseg: [context[i]],
|
||||
}),
|
||||
]);
|
||||
files.set(item.getFileId(), freeze(newFile));
|
||||
}
|
||||
}
|
||||
});
|
||||
}, context);
|
||||
},
|
||||
context
|
||||
);
|
||||
|
||||
selection.update(($selection) => {
|
||||
$selection.clear();
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
TrackSegment,
|
||||
Waypoint,
|
||||
type AnyGPXTreeElement,
|
||||
type GPXTreeElement
|
||||
type GPXTreeElement,
|
||||
} from 'gpx';
|
||||
import { CollapsibleTreeNode } from '$lib/components/collapsible-tree/index';
|
||||
import { settings, type GPXFileWithStatistics } from '$lib/db';
|
||||
|
@ -19,7 +19,7 @@
|
|||
ListWaypointItem,
|
||||
ListWaypointsItem,
|
||||
type ListItem,
|
||||
type ListTrackItem
|
||||
type ListTrackItem,
|
||||
} from './FileList';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { selection } from './Selection';
|
||||
|
@ -39,19 +39,20 @@
|
|||
node instanceof GPXFile && item instanceof ListFileItem
|
||||
? node.metadata.name
|
||||
: node instanceof Track
|
||||
? node.name ?? `${$_('gpx.track')} ${(item as ListTrackItem).trackIndex + 1}`
|
||||
? (node.name ?? `${$_('gpx.track')} ${(item as ListTrackItem).trackIndex + 1}`)
|
||||
: node instanceof TrackSegment
|
||||
? `${$_('gpx.segment')} ${(item as ListTrackSegmentItem).segmentIndex + 1}`
|
||||
: node instanceof Waypoint
|
||||
? node.name ?? `${$_('gpx.waypoint')} ${(item as ListWaypointItem).waypointIndex + 1}`
|
||||
? (node.name ??
|
||||
`${$_('gpx.waypoint')} ${(item as ListWaypointItem).waypointIndex + 1}`)
|
||||
: node instanceof GPXFile && item instanceof ListWaypointsItem
|
||||
? $_('gpx.waypoints')
|
||||
: '';
|
||||
|
||||
const { verticalFileView } = settings;
|
||||
const { treeFileView } = settings;
|
||||
|
||||
function openIfSelectedChild() {
|
||||
if (collapsible && get(verticalFileView) && $selection.hasAnyChildren(item, false)) {
|
||||
if (collapsible && get(treeFileView) && $selection.hasAnyChildren(item, false)) {
|
||||
collapsible.openNode();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,9 +19,10 @@
|
|||
ListWaypointsItem,
|
||||
allowedMoves,
|
||||
moveItems,
|
||||
type ListItem
|
||||
type ListItem,
|
||||
} from './FileList';
|
||||
import { selection } from './Selection';
|
||||
import { isMac } from '$lib/utils';
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
export let node:
|
||||
|
@ -77,8 +78,13 @@
|
|||
|
||||
if (
|
||||
e.originalEvent &&
|
||||
!(e.originalEvent.ctrlKey || e.originalEvent.metaKey || e.originalEvent.shiftKey) &&
|
||||
($selection.size > 1 || !$selection.has(item.extend(getRealId(changed[0]))))
|
||||
!(
|
||||
e.originalEvent.ctrlKey ||
|
||||
e.originalEvent.metaKey ||
|
||||
e.originalEvent.shiftKey
|
||||
) &&
|
||||
($selection.size > 1 ||
|
||||
!$selection.has(item.extend(getRealId(changed[0]))))
|
||||
) {
|
||||
// Fix bug that sometimes causes a single select to be treated as a multi-select
|
||||
$selection.clear();
|
||||
|
@ -107,7 +113,7 @@
|
|||
Sortable.utils.select(element);
|
||||
element.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest'
|
||||
block: 'nearest',
|
||||
});
|
||||
} else {
|
||||
Sortable.utils.deselect(element);
|
||||
|
@ -149,12 +155,12 @@
|
|||
group: {
|
||||
name: sortableLevel,
|
||||
pull: allowedMoves[sortableLevel],
|
||||
put: true
|
||||
put: true,
|
||||
},
|
||||
direction: orientation,
|
||||
forceAutoScrollFallback: true,
|
||||
multiDrag: true,
|
||||
multiDragKey: 'Meta',
|
||||
multiDragKey: isMac() ? 'Meta' : 'Ctrl',
|
||||
avoidImplicitDeselect: true,
|
||||
onSelect: updateToSelection,
|
||||
onDeselect: updateToSelection,
|
||||
|
@ -191,7 +197,9 @@
|
|||
fromItems = [fromItem.extend('waypoints')];
|
||||
} else {
|
||||
let oldIndices: number[] =
|
||||
e.oldIndicies.length > 0 ? e.oldIndicies.map((i) => i.index) : [e.oldIndex];
|
||||
e.oldIndicies.length > 0
|
||||
? e.oldIndicies.map((i) => i.index)
|
||||
: [e.oldIndex];
|
||||
oldIndices = oldIndices.filter((i) => i >= 0);
|
||||
oldIndices.sort((a, b) => a - b);
|
||||
|
||||
|
@ -206,7 +214,9 @@
|
|||
}
|
||||
|
||||
let newIndices: number[] =
|
||||
e.newIndicies.length > 0 ? e.newIndicies.map((i) => i.index) : [e.newIndex];
|
||||
e.newIndicies.length > 0
|
||||
? e.newIndicies.map((i) => i.index)
|
||||
: [e.newIndex];
|
||||
newIndices = newIndices.filter((i) => i >= 0);
|
||||
newIndices.sort((a, b) => a - b);
|
||||
|
||||
|
@ -223,16 +233,16 @@
|
|||
|
||||
moveItems(fromItem, toItem, fromItems, toItems);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
Object.defineProperty(sortable, '_item', {
|
||||
value: item,
|
||||
writable: true
|
||||
writable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(sortable, '_waypointRoot', {
|
||||
value: waypointRoot,
|
||||
writable: true
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -15,9 +15,10 @@
|
|||
EyeOff,
|
||||
ClipboardCopy,
|
||||
ClipboardPaste,
|
||||
Maximize,
|
||||
Scissors,
|
||||
FileStack,
|
||||
FileX
|
||||
FileX,
|
||||
} from 'lucide-svelte';
|
||||
import {
|
||||
ListFileItem,
|
||||
|
@ -25,7 +26,7 @@
|
|||
ListTrackItem,
|
||||
ListWaypointItem,
|
||||
allowedPastes,
|
||||
type ListItem
|
||||
type ListItem,
|
||||
} from './FileList';
|
||||
import {
|
||||
copied,
|
||||
|
@ -35,22 +36,25 @@
|
|||
pasteSelection,
|
||||
selectAll,
|
||||
selectItem,
|
||||
selection
|
||||
selection,
|
||||
} from './Selection';
|
||||
import { getContext } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import { allHidden, editMetadata, editStyle, embedding, gpxLayers, map } from '$lib/stores';
|
||||
import {
|
||||
GPXTreeElement,
|
||||
Track,
|
||||
TrackSegment,
|
||||
type AnyGPXTreeElement,
|
||||
Waypoint,
|
||||
GPXFile
|
||||
} from 'gpx';
|
||||
allHidden,
|
||||
editMetadata,
|
||||
editStyle,
|
||||
embedding,
|
||||
centerMapOnSelection,
|
||||
gpxLayers,
|
||||
map,
|
||||
} from '$lib/stores';
|
||||
import { GPXTreeElement, Track, type AnyGPXTreeElement, Waypoint, GPXFile } from 'gpx';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import MetadataDialog from './MetadataDialog.svelte';
|
||||
import StyleDialog from './StyleDialog.svelte';
|
||||
import { waypointPopup } from '$lib/components/gpx-layer/GPXLayerPopup';
|
||||
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
||||
|
||||
export let node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint;
|
||||
export let item: ListItem;
|
||||
|
@ -66,13 +70,14 @@
|
|||
nodeColors = [];
|
||||
|
||||
if (node instanceof GPXFile) {
|
||||
let style = node.getStyle();
|
||||
let defaultColor = undefined;
|
||||
|
||||
let layer = gpxLayers.get(item.getFileId());
|
||||
if (layer) {
|
||||
style.color.push(layer.layerColor);
|
||||
defaultColor = layer.layerColor;
|
||||
}
|
||||
|
||||
let style = node.getStyle(defaultColor);
|
||||
style.color.forEach((c) => {
|
||||
if (!nodeColors.includes(c)) {
|
||||
nodeColors.push(c);
|
||||
|
@ -81,8 +86,8 @@
|
|||
} else if (node instanceof Track) {
|
||||
let style = node.getStyle();
|
||||
if (style) {
|
||||
if (style.color && !nodeColors.includes(style.color)) {
|
||||
nodeColors.push(style.color);
|
||||
if (style['gpx_style:color'] && !nodeColors.includes(style['gpx_style:color'])) {
|
||||
nodeColors.push(style['gpx_style:color']);
|
||||
}
|
||||
}
|
||||
if (nodeColors.length === 0) {
|
||||
|
@ -94,6 +99,8 @@
|
|||
}
|
||||
}
|
||||
|
||||
$: symbolKey = node instanceof Waypoint ? getSymbolKey(node.sym) : undefined;
|
||||
|
||||
let openEditMetadata: boolean = false;
|
||||
let openEditStyle: boolean = false;
|
||||
|
||||
|
@ -170,7 +177,10 @@
|
|||
if (layer && file) {
|
||||
let waypoint = file.wpt[item.getWaypointIndex()];
|
||||
if (waypoint) {
|
||||
layer.showWaypointPopup(waypoint);
|
||||
waypointPopup?.setItem({
|
||||
item: waypoint,
|
||||
fileId: item.getFileId(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -179,7 +189,7 @@
|
|||
if (item instanceof ListWaypointItem) {
|
||||
let layer = gpxLayers.get(item.getFileId());
|
||||
if (layer) {
|
||||
layer.hideWaypointPopup();
|
||||
waypointPopup?.setItem(null);
|
||||
}
|
||||
}
|
||||
}}
|
||||
|
@ -187,16 +197,30 @@
|
|||
{#if item.level === ListLevel.SEGMENT}
|
||||
<Waypoints size="16" class="mr-1 shrink-0" />
|
||||
{:else if item.level === ListLevel.WAYPOINT}
|
||||
{#if symbolKey && symbols[symbolKey].icon}
|
||||
<svelte:component
|
||||
this={symbols[symbolKey].icon}
|
||||
size="16"
|
||||
class="mr-1 shrink-0"
|
||||
/>
|
||||
{:else}
|
||||
<MapPin size="16" class="mr-1 shrink-0" />
|
||||
{/if}
|
||||
<span class="grow select-none truncate {orientation === 'vertical' ? 'last:mr-2' : ''}">
|
||||
{/if}
|
||||
<span
|
||||
class="grow select-none truncate {orientation === 'vertical'
|
||||
? 'last:mr-2'
|
||||
: ''}"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{#if hidden}
|
||||
<EyeOff
|
||||
size="12"
|
||||
class="shrink-0 mt-1 ml-1 {orientation === 'vertical' ? 'mr-2' : ''} {item.level ===
|
||||
ListLevel.SEGMENT || item.level === ListLevel.WAYPOINT
|
||||
class="shrink-0 mt-1 ml-1 {orientation === 'vertical'
|
||||
? 'mr-2'
|
||||
: ''} {item.level === ListLevel.SEGMENT ||
|
||||
item.level === ListLevel.WAYPOINT
|
||||
? 'mr-3'
|
||||
: ''}"
|
||||
/>
|
||||
|
@ -239,10 +263,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 +272,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,9 +286,13 @@
|
|||
{$_('menu.select_all')}
|
||||
<Shortcut key="A" ctrl={true} />
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
{/if}
|
||||
{#if orientation === 'vertical'}
|
||||
<ContextMenu.Item on:click={centerMapOnSelection}>
|
||||
<Maximize size="16" class="mr-1" />
|
||||
{$_('menu.center')}
|
||||
<Shortcut key="⏎" ctrl={true} />
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Item on:click={dbUtils.duplicateSelection}>
|
||||
<Copy size="16" class="mr-1" />
|
||||
{$_('menu.duplicate')}
|
||||
|
@ -306,7 +321,6 @@
|
|||
</ContextMenu.Item>
|
||||
{/if}
|
||||
<ContextMenu.Separator />
|
||||
{/if}
|
||||
<ContextMenu.Item on:click={dbUtils.deleteSelection}>
|
||||
{#if item instanceof ListFileItem}
|
||||
<FileX size="16" class="mr-1" />
|
||||
|
|
|
@ -17,15 +17,15 @@
|
|||
|
||||
let name: string =
|
||||
node instanceof GPXFile
|
||||
? node.metadata.name ?? ''
|
||||
? (node.metadata.name ?? '')
|
||||
: node instanceof Track
|
||||
? node.name ?? ''
|
||||
? (node.name ?? '')
|
||||
: '';
|
||||
let description: string =
|
||||
node instanceof GPXFile
|
||||
? node.metadata.desc ?? ''
|
||||
? (node.metadata.desc ?? '')
|
||||
: node instanceof Track
|
||||
? node.desc ?? ''
|
||||
? (node.desc ?? '')
|
||||
: '';
|
||||
|
||||
$: if (!open) {
|
||||
|
@ -47,6 +47,9 @@
|
|||
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;
|
||||
|
|
|
@ -1,12 +1,23 @@
|
|||
import { get, writable } from "svelte/store";
|
||||
import { ListFileItem, ListItem, ListRootItem, ListTrackItem, ListTrackSegmentItem, ListWaypointItem, ListLevel, sortItems, ListWaypointsItem, moveItems } from "./FileList";
|
||||
import { fileObservers, getFile, getFileIds, settings } from "$lib/db";
|
||||
import { get, writable } from 'svelte/store';
|
||||
import {
|
||||
ListFileItem,
|
||||
ListItem,
|
||||
ListRootItem,
|
||||
ListTrackItem,
|
||||
ListTrackSegmentItem,
|
||||
ListWaypointItem,
|
||||
ListLevel,
|
||||
sortItems,
|
||||
ListWaypointsItem,
|
||||
moveItems,
|
||||
} from './FileList';
|
||||
import { fileObservers, getFile, getFileIds, settings } from '$lib/db';
|
||||
|
||||
export class SelectionTreeType {
|
||||
item: ListItem;
|
||||
selected: boolean;
|
||||
children: {
|
||||
[key: string | number]: SelectionTreeType
|
||||
[key: string | number]: SelectionTreeType;
|
||||
};
|
||||
size: number = 0;
|
||||
|
||||
|
@ -67,7 +78,11 @@ export class SelectionTreeType {
|
|||
}
|
||||
|
||||
hasAnyParent(item: ListItem, self: boolean = true): boolean {
|
||||
if (this.selected && this.item.level <= item.level && (self || this.item.level < item.level)) {
|
||||
if (
|
||||
this.selected &&
|
||||
this.item.level <= item.level &&
|
||||
(self || this.item.level < item.level)
|
||||
) {
|
||||
return this.selected;
|
||||
}
|
||||
let id = item.getIdAtLevel(this.item.level);
|
||||
|
@ -80,7 +95,11 @@ export class SelectionTreeType {
|
|||
}
|
||||
|
||||
hasAnyChildren(item: ListItem, self: boolean = true, ignoreIds?: (string | number)[]): boolean {
|
||||
if (this.selected && this.item.level >= item.level && (self || this.item.level > item.level)) {
|
||||
if (
|
||||
this.selected &&
|
||||
this.item.level >= item.level &&
|
||||
(self || this.item.level > item.level)
|
||||
) {
|
||||
return this.selected;
|
||||
}
|
||||
let id = item.getIdAtLevel(this.item.level);
|
||||
|
@ -131,7 +150,7 @@ export class SelectionTreeType {
|
|||
delete this.children[id];
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const selection = writable<SelectionTreeType>(new SelectionTreeType(new ListRootItem()));
|
||||
|
||||
|
@ -181,7 +200,10 @@ export function selectAll() {
|
|||
let file = getFile(item.getFileId());
|
||||
if (file) {
|
||||
file.trk[item.getTrackIndex()].trkseg.forEach((_segment, segmentId) => {
|
||||
$selection.set(new ListTrackSegmentItem(item.getFileId(), item.getTrackIndex(), segmentId), true);
|
||||
$selection.set(
|
||||
new ListTrackSegmentItem(item.getFileId(), item.getTrackIndex(), segmentId),
|
||||
true
|
||||
);
|
||||
});
|
||||
}
|
||||
} else if (item instanceof ListWaypointItem) {
|
||||
|
@ -205,14 +227,24 @@ export function getOrderedSelection(reverse: boolean = false): ListItem[] {
|
|||
return selected;
|
||||
}
|
||||
|
||||
export function applyToOrderedItemsFromFile(selectedItems: ListItem[], callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void, reverse: boolean = true) {
|
||||
export function applyToOrderedItemsFromFile(
|
||||
selectedItems: ListItem[],
|
||||
callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void,
|
||||
reverse: boolean = true
|
||||
) {
|
||||
get(settings.fileOrder).forEach((fileId) => {
|
||||
let level: ListLevel | undefined = undefined;
|
||||
let items: ListItem[] = [];
|
||||
selectedItems.forEach((item) => {
|
||||
if (item.getFileId() === fileId) {
|
||||
level = item.level;
|
||||
if (item instanceof ListFileItem || item instanceof ListTrackItem || item instanceof ListTrackSegmentItem || item instanceof ListWaypointsItem || item instanceof ListWaypointItem) {
|
||||
if (
|
||||
item instanceof ListFileItem ||
|
||||
item instanceof ListTrackItem ||
|
||||
item instanceof ListTrackSegmentItem ||
|
||||
item instanceof ListWaypointsItem ||
|
||||
item instanceof ListWaypointItem
|
||||
) {
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
|
@ -225,7 +257,10 @@ export function applyToOrderedItemsFromFile(selectedItems: ListItem[], callback:
|
|||
});
|
||||
}
|
||||
|
||||
export function applyToOrderedSelectedItemsFromFile(callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void, reverse: boolean = true) {
|
||||
export function applyToOrderedSelectedItemsFromFile(
|
||||
callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void,
|
||||
reverse: boolean = true
|
||||
) {
|
||||
applyToOrderedItemsFromFile(get(selection).getSelected(), callback, reverse);
|
||||
}
|
||||
|
||||
|
@ -270,7 +305,11 @@ export function pasteSelection() {
|
|||
let startIndex: number | undefined = undefined;
|
||||
|
||||
if (fromItems[0].level === toParent.level) {
|
||||
if (toParent instanceof ListTrackItem || toParent instanceof ListTrackSegmentItem || toParent instanceof ListWaypointItem) {
|
||||
if (
|
||||
toParent instanceof ListTrackItem ||
|
||||
toParent instanceof ListTrackSegmentItem ||
|
||||
toParent instanceof ListWaypointItem
|
||||
) {
|
||||
startIndex = toParent.getId() + 1;
|
||||
}
|
||||
toParent = toParent.getParent();
|
||||
|
@ -288,20 +327,41 @@ export function pasteSelection() {
|
|||
fromItems.forEach((item, index) => {
|
||||
if (toParent instanceof ListFileItem) {
|
||||
if (item instanceof ListTrackItem || item instanceof ListTrackSegmentItem) {
|
||||
toItems.push(new ListTrackItem(toParent.getFileId(), (startIndex ?? toFile.trk.length) + index));
|
||||
toItems.push(
|
||||
new ListTrackItem(
|
||||
toParent.getFileId(),
|
||||
(startIndex ?? toFile.trk.length) + index
|
||||
)
|
||||
);
|
||||
} else if (item instanceof ListWaypointsItem) {
|
||||
toItems.push(new ListWaypointsItem(toParent.getFileId()));
|
||||
} else if (item instanceof ListWaypointItem) {
|
||||
toItems.push(new ListWaypointItem(toParent.getFileId(), (startIndex ?? toFile.wpt.length) + index));
|
||||
toItems.push(
|
||||
new ListWaypointItem(
|
||||
toParent.getFileId(),
|
||||
(startIndex ?? toFile.wpt.length) + index
|
||||
)
|
||||
);
|
||||
}
|
||||
} else if (toParent instanceof ListTrackItem) {
|
||||
if (item instanceof ListTrackSegmentItem) {
|
||||
let toTrackIndex = toParent.getTrackIndex();
|
||||
toItems.push(new ListTrackSegmentItem(toParent.getFileId(), toTrackIndex, (startIndex ?? toFile.trk[toTrackIndex].trkseg.length) + index));
|
||||
toItems.push(
|
||||
new ListTrackSegmentItem(
|
||||
toParent.getFileId(),
|
||||
toTrackIndex,
|
||||
(startIndex ?? toFile.trk[toTrackIndex].trkseg.length) + index
|
||||
)
|
||||
);
|
||||
}
|
||||
} else if (toParent instanceof ListWaypointsItem) {
|
||||
if (item instanceof ListWaypointItem) {
|
||||
toItems.push(new ListWaypointItem(toParent.getFileId(), (startIndex ?? toFile.wpt.length) + index));
|
||||
toItems.push(
|
||||
new ListWaypointItem(
|
||||
toParent.getFileId(),
|
||||
(startIndex ?? toFile.wpt.length) + index
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
@ -14,20 +14,20 @@
|
|||
export let item: ListItem;
|
||||
export let open = false;
|
||||
|
||||
const { defaultOpacity, defaultWeight } = settings;
|
||||
const { defaultOpacity, defaultWidth } = settings;
|
||||
|
||||
let colors: string[] = [];
|
||||
let color: string | undefined = undefined;
|
||||
let opacity: number[] = [];
|
||||
let weight: number[] = [];
|
||||
let width: number[] = [];
|
||||
let colorChanged = false;
|
||||
let opacityChanged = false;
|
||||
let weightChanged = false;
|
||||
let widthChanged = false;
|
||||
|
||||
function setStyleInputs() {
|
||||
colors = [];
|
||||
opacity = [];
|
||||
weight = [];
|
||||
width = [];
|
||||
|
||||
$selection.forEach((item) => {
|
||||
if (item instanceof ListFileItem) {
|
||||
|
@ -47,9 +47,9 @@
|
|||
opacity.push(o);
|
||||
}
|
||||
});
|
||||
style.weight.forEach((w) => {
|
||||
if (!weight.includes(w)) {
|
||||
weight.push(w);
|
||||
style.width.forEach((w) => {
|
||||
if (!width.includes(w)) {
|
||||
width.push(w);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -60,14 +60,20 @@
|
|||
let track = file.trk[item.getTrackIndex()];
|
||||
let style = track.getStyle();
|
||||
if (style) {
|
||||
if (style.color && !colors.includes(style.color)) {
|
||||
colors.push(style.color);
|
||||
if (
|
||||
style['gpx_style:color'] &&
|
||||
!colors.includes(style['gpx_style:color'])
|
||||
) {
|
||||
colors.push(style['gpx_style:color']);
|
||||
}
|
||||
if (style.opacity && !opacity.includes(style.opacity)) {
|
||||
opacity.push(style.opacity);
|
||||
if (
|
||||
style['gpx_style:opacity'] &&
|
||||
!opacity.includes(style['gpx_style:opacity'])
|
||||
) {
|
||||
opacity.push(style['gpx_style:opacity']);
|
||||
}
|
||||
if (style.weight && !weight.includes(style.weight)) {
|
||||
weight.push(style.weight);
|
||||
if (style['gpx_style:width'] && !width.includes(style['gpx_style:width'])) {
|
||||
width.push(style['gpx_style:width']);
|
||||
}
|
||||
}
|
||||
if (!colors.includes(layer.layerColor)) {
|
||||
|
@ -79,11 +85,11 @@
|
|||
|
||||
color = colors[0];
|
||||
opacity = [opacity[0] ?? $defaultOpacity];
|
||||
weight = [weight[0] ?? $defaultWeight];
|
||||
width = [width[0] ?? $defaultWidth];
|
||||
|
||||
colorChanged = false;
|
||||
opacityChanged = false;
|
||||
weightChanged = false;
|
||||
widthChanged = false;
|
||||
}
|
||||
|
||||
$: if ($selection && open) {
|
||||
|
@ -123,37 +129,37 @@
|
|||
{$_('menu.style.width')}
|
||||
<div class="w-40 p-2">
|
||||
<Slider
|
||||
bind:value={weight}
|
||||
id="weight"
|
||||
bind:value={width}
|
||||
id="width"
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
onValueChange={() => (weightChanged = true)}
|
||||
onValueChange={() => (widthChanged = true)}
|
||||
/>
|
||||
</div>
|
||||
</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!colorChanged && !opacityChanged && !weightChanged}
|
||||
disabled={!colorChanged && !opacityChanged && !widthChanged}
|
||||
on:click={() => {
|
||||
let style = {};
|
||||
if (colorChanged) {
|
||||
style.color = color;
|
||||
style['gpx_style:color'] = color;
|
||||
}
|
||||
if (opacityChanged) {
|
||||
style.opacity = opacity[0];
|
||||
style['gpx_style:opacity'] = opacity[0];
|
||||
}
|
||||
if (weightChanged) {
|
||||
style.weight = weight[0];
|
||||
if (widthChanged) {
|
||||
style['gpx_style:width'] = width[0];
|
||||
}
|
||||
dbUtils.setStyleToSelection(style);
|
||||
|
||||
if (item instanceof ListFileItem && $selection.size === gpxLayers.size) {
|
||||
if (style.opacity) {
|
||||
$defaultOpacity = style.opacity;
|
||||
if (style['gpx_style:opacity']) {
|
||||
$defaultOpacity = style['gpx_style:opacity'];
|
||||
}
|
||||
if (style.weight) {
|
||||
$defaultWeight = style.weight;
|
||||
if (style['gpx_style:width']) {
|
||||
$defaultWidth = style['gpx_style:width'];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
23
website/src/lib/components/gpx-layer/CopyCoordinates.svelte
Normal file
23
website/src/lib/components/gpx-layer/CopyCoordinates.svelte
Normal file
|
@ -0,0 +1,23 @@
|
|||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { ClipboardCopy } from 'lucide-svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import type { Coordinates } from 'gpx';
|
||||
|
||||
export let coordinates: Coordinates;
|
||||
export let onCopy: () => void = () => {};
|
||||
</script>
|
||||
|
||||
<Button
|
||||
class="w-full px-2 py-1 h-8 justify-start {$$props.class}"
|
||||
variant="outline"
|
||||
on:click={() => {
|
||||
navigator.clipboard.writeText(
|
||||
`${coordinates.lat.toFixed(6)}, ${coordinates.lon.toFixed(6)}`
|
||||
);
|
||||
onCopy();
|
||||
}}
|
||||
>
|
||||
<ClipboardCopy size="16" class="mr-1" />
|
||||
{$_('menu.copy_coordinates')}
|
||||
</Button>
|
|
@ -1,10 +1,17 @@
|
|||
import { settings } from '$lib/db';
|
||||
import { gpxStatistics } from '$lib/stores';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
import { font } from "$lib/assets/layers";
|
||||
import { settings } from "$lib/db";
|
||||
import { gpxStatistics } from "$lib/stores";
|
||||
import { get } from "svelte/store";
|
||||
const { distanceMarkers, distanceUnits } = settings;
|
||||
|
||||
const { distanceMarkers, distanceUnits, currentBasemap } = settings;
|
||||
const stops = [
|
||||
[100, 0],
|
||||
[50, 7],
|
||||
[25, 8, 10],
|
||||
[10, 10],
|
||||
[5, 11],
|
||||
[1, 13],
|
||||
];
|
||||
|
||||
export class DistanceMarkers {
|
||||
map: mapboxgl.Map;
|
||||
|
@ -17,7 +24,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() {
|
||||
|
@ -29,41 +36,55 @@ export class DistanceMarkers {
|
|||
} else {
|
||||
this.map.addSource('distance-markers', {
|
||||
type: 'geojson',
|
||||
data: this.getDistanceMarkersGeoJSON()
|
||||
data: this.getDistanceMarkersGeoJSON(),
|
||||
});
|
||||
}
|
||||
if (!this.map.getLayer('distance-markers')) {
|
||||
stops.forEach(([d, minzoom, maxzoom]) => {
|
||||
if (!this.map.getLayer(`distance-markers-${d}`)) {
|
||||
this.map.addLayer({
|
||||
id: 'distance-markers',
|
||||
id: `distance-markers-${d}`,
|
||||
type: 'symbol',
|
||||
source: 'distance-markers',
|
||||
filter:
|
||||
d === 5
|
||||
? [
|
||||
'any',
|
||||
['==', ['get', 'level'], 5],
|
||||
['==', ['get', 'level'], 25],
|
||||
]
|
||||
: ['==', ['get', 'level'], d],
|
||||
minzoom: minzoom,
|
||||
maxzoom: maxzoom ?? 24,
|
||||
layout: {
|
||||
'text-field': ['get', 'distance'],
|
||||
'text-size': 14,
|
||||
'text-font': [font[get(currentBasemap)] ?? 'Open Sans Bold'],
|
||||
'text-padding': 20,
|
||||
'text-font': ['Open Sans Bold'],
|
||||
},
|
||||
paint: {
|
||||
'text-color': 'black',
|
||||
'text-halo-width': 2,
|
||||
'text-halo-color': 'white',
|
||||
},
|
||||
});
|
||||
} else {
|
||||
this.map.moveLayer(`distance-markers-${d}`);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.map.moveLayer('distance-markers');
|
||||
stops.forEach(([d]) => {
|
||||
if (this.map.getLayer(`distance-markers-${d}`)) {
|
||||
this.map.removeLayer(`distance-markers-${d}`);
|
||||
}
|
||||
} else {
|
||||
if (this.map.getLayer('distance-markers')) {
|
||||
this.map.removeLayer('distance-markers');
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) { // No reliable way to check if the map is ready to add sources and layers
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to add sources and layers
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
remove() {
|
||||
this.unsubscribes.forEach(unsubscribe => unsubscribe());
|
||||
this.unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
}
|
||||
|
||||
getDistanceMarkersGeoJSON(): GeoJSON.FeatureCollection {
|
||||
|
@ -72,17 +93,28 @@ export class DistanceMarkers {
|
|||
let features = [];
|
||||
let currentTargetDistance = 1;
|
||||
for (let i = 0; i < statistics.local.distance.total.length; i++) {
|
||||
if (statistics.local.distance.total[i] >= currentTargetDistance * (get(distanceUnits) === 'metric' ? 1 : 1.60934)) {
|
||||
if (
|
||||
statistics.local.distance.total[i] >=
|
||||
currentTargetDistance * (get(distanceUnits) === 'metric' ? 1 : 1.60934)
|
||||
) {
|
||||
let distance = currentTargetDistance.toFixed(0);
|
||||
let [level, minzoom] = stops.find(([d]) => currentTargetDistance % d === 0) ?? [
|
||||
0, 0,
|
||||
];
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [statistics.local.points[i].getLongitude(), statistics.local.points[i].getLatitude()]
|
||||
coordinates: [
|
||||
statistics.local.points[i].getLongitude(),
|
||||
statistics.local.points[i].getLatitude(),
|
||||
],
|
||||
},
|
||||
properties: {
|
||||
distance,
|
||||
}
|
||||
level,
|
||||
minzoom,
|
||||
},
|
||||
} as GeoJSON.Feature);
|
||||
currentTargetDistance += 1;
|
||||
}
|
||||
|
@ -90,7 +122,7 @@ export class DistanceMarkers {
|
|||
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features
|
||||
features,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -1,16 +1,28 @@
|
|||
import { currentTool, map, Tool } from "$lib/stores";
|
||||
import { settings, type GPXFileWithStatistics, dbUtils } from "$lib/db";
|
||||
import { get, type Readable } from "svelte/store";
|
||||
import mapboxgl from "mapbox-gl";
|
||||
import { currentPopupWaypoint, deleteWaypoint, waypointPopup } from "./WaypointPopup";
|
||||
import { addSelectItem, selectItem, selection } from "$lib/components/file-list/Selection";
|
||||
import { ListTrackSegmentItem, ListWaypointItem, ListWaypointsItem, ListTrackItem, ListFileItem, ListRootItem } from "$lib/components/file-list/FileList";
|
||||
import type { Waypoint } from "gpx";
|
||||
import { getElevation, resetCursor, 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";
|
||||
import { currentTool, map, Tool } from '$lib/stores';
|
||||
import { settings, type GPXFileWithStatistics, dbUtils } from '$lib/db';
|
||||
import { get, type Readable } from 'svelte/store';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import { waypointPopup, deleteWaypoint, trackpointPopup } from './GPXLayerPopup';
|
||||
import { addSelectItem, selectItem, selection } from '$lib/components/file-list/Selection';
|
||||
import {
|
||||
ListTrackSegmentItem,
|
||||
ListWaypointItem,
|
||||
ListWaypointsItem,
|
||||
ListTrackItem,
|
||||
ListFileItem,
|
||||
ListRootItem,
|
||||
} from '$lib/components/file-list/FileList';
|
||||
import {
|
||||
getClosestLinePoint,
|
||||
getElevation,
|
||||
resetCursor,
|
||||
setGrabbingCursor,
|
||||
setPointerCursor,
|
||||
setScissorsCursor,
|
||||
} from '$lib/utils';
|
||||
import { selectedWaypoint } from '$lib/components/toolbar/tools/Waypoint.svelte';
|
||||
import { MapPin, Square } from 'lucide-static';
|
||||
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
||||
|
||||
const colors = [
|
||||
'#ff0000',
|
||||
|
@ -23,7 +35,7 @@ const colors = [
|
|||
'#288228',
|
||||
'#9933ff',
|
||||
'#50f0be',
|
||||
'#8c645a'
|
||||
'#8c645a',
|
||||
];
|
||||
|
||||
const colorCount: { [key: string]: number } = {};
|
||||
|
@ -44,29 +56,58 @@ function decrementColor(color: string) {
|
|||
}
|
||||
}
|
||||
|
||||
const inspectKey = 'Shift';
|
||||
let inspectKeyDown: KeyDown | null = null;
|
||||
class KeyDown {
|
||||
key: string;
|
||||
down: boolean = false;
|
||||
constructor(key: string) {
|
||||
this.key = key;
|
||||
document.addEventListener('keydown', this.onKeyDown);
|
||||
document.addEventListener('keyup', this.onKeyUp);
|
||||
}
|
||||
onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === this.key) {
|
||||
this.down = true;
|
||||
}
|
||||
};
|
||||
onKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === this.key) {
|
||||
this.down = false;
|
||||
}
|
||||
};
|
||||
isDown() {
|
||||
return this.down;
|
||||
}
|
||||
}
|
||||
|
||||
function getMarkerForSymbol(symbol: string | undefined, layerColor: string) {
|
||||
let symbolSvg = symbol ? symbols[symbol]?.iconSvg : undefined;
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
${Square
|
||||
.replace('width="24"', 'width="12"')
|
||||
${Square.replace('width="24"', 'width="12"')
|
||||
.replace('height="24"', 'height="12"')
|
||||
.replace('stroke="currentColor"', 'stroke="SteelBlue"')
|
||||
.replace('stroke-width="2"', 'stroke-width="1.5" x="9.6" y="0.4"')
|
||||
.replace('fill="none"', `fill="${layerColor}"`)}
|
||||
${MapPin
|
||||
.replace('width="24"', '')
|
||||
${MapPin.replace('width="24"', '')
|
||||
.replace('height="24"', '')
|
||||
.replace('stroke="currentColor"', '')
|
||||
.replace('path', `path fill="#3fb1ce" stroke="SteelBlue" stroke-width="1"`)
|
||||
.replace('circle', `circle fill="${symbolSvg ? 'none' : 'white'}" stroke="${symbolSvg ? 'none' : 'white'}" stroke-width="2"`)}
|
||||
${symbolSvg?.replace('width="24"', 'width="10"')
|
||||
.replace(
|
||||
'circle',
|
||||
`circle fill="${symbolSvg ? 'none' : 'white'}" stroke="${symbolSvg ? 'none' : 'white'}" stroke-width="2"`
|
||||
)}
|
||||
${
|
||||
symbolSvg
|
||||
?.replace('width="24"', 'width="10"')
|
||||
.replace('height="24"', 'height="10"')
|
||||
.replace('stroke="currentColor"', 'stroke="white"')
|
||||
.replace('stroke-width="2"', 'stroke-width="2.5" x="7" y="5"') ?? ''}
|
||||
.replace('stroke-width="2"', 'stroke-width="2.5" x="7" y="5"') ?? ''
|
||||
}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
const { directionMarkers, verticalFileView, currentBasemap, defaultOpacity, defaultWeight } = settings;
|
||||
const { directionMarkers, treeFileView, defaultOpacity, defaultWidth } = settings;
|
||||
|
||||
export class GPXLayer {
|
||||
map: mapboxgl.Map;
|
||||
|
@ -81,16 +122,22 @@ export class GPXLayer {
|
|||
updateBinded: () => void = this.update.bind(this);
|
||||
layerOnMouseEnterBinded: (e: any) => void = this.layerOnMouseEnter.bind(this);
|
||||
layerOnMouseLeaveBinded: () => void = this.layerOnMouseLeave.bind(this);
|
||||
layerOnMouseMoveBinded: (e: any) => void = this.layerOnMouseMove.bind(this);
|
||||
layerOnClickBinded: (e: any) => void = this.layerOnClick.bind(this);
|
||||
maybeHideWaypointPopupBinded: (e: any) => void = this.maybeHideWaypointPopup.bind(this);
|
||||
layerOnContextMenuBinded: (e: any) => void = this.layerOnContextMenu.bind(this);
|
||||
|
||||
constructor(map: mapboxgl.Map, fileId: string, file: Readable<GPXFileWithStatistics | undefined>) {
|
||||
constructor(
|
||||
map: mapboxgl.Map,
|
||||
fileId: string,
|
||||
file: Readable<GPXFileWithStatistics | undefined>
|
||||
) {
|
||||
this.map = map;
|
||||
this.fileId = fileId;
|
||||
this.file = file;
|
||||
this.layerColor = getColor();
|
||||
this.unsubscribe.push(file.subscribe(this.updateBinded));
|
||||
this.unsubscribe.push(selection.subscribe($selection => {
|
||||
this.unsubscribe.push(
|
||||
selection.subscribe(($selection) => {
|
||||
let newSelected = $selection.hasAnyChildren(new ListFileItem(this.fileId));
|
||||
if (this.selected || newSelected) {
|
||||
this.selected = newSelected;
|
||||
|
@ -99,20 +146,27 @@ export class GPXLayer {
|
|||
if (newSelected) {
|
||||
this.moveToFront();
|
||||
}
|
||||
}));
|
||||
})
|
||||
);
|
||||
this.unsubscribe.push(directionMarkers.subscribe(this.updateBinded));
|
||||
this.unsubscribe.push(currentTool.subscribe(tool => {
|
||||
this.unsubscribe.push(
|
||||
currentTool.subscribe((tool) => {
|
||||
if (tool === Tool.WAYPOINT && !this.draggable) {
|
||||
this.draggable = true;
|
||||
this.markers.forEach(marker => marker.setDraggable(true));
|
||||
this.markers.forEach((marker) => marker.setDraggable(true));
|
||||
} else if (tool !== Tool.WAYPOINT && this.draggable) {
|
||||
this.draggable = false;
|
||||
this.markers.forEach(marker => marker.setDraggable(false));
|
||||
this.markers.forEach((marker) => marker.setDraggable(false));
|
||||
}
|
||||
}));
|
||||
})
|
||||
);
|
||||
this.draggable = get(currentTool) === Tool.WAYPOINT;
|
||||
|
||||
this.map.on('style.load', this.updateBinded);
|
||||
this.map.on('style.import.load', this.updateBinded);
|
||||
|
||||
if (inspectKeyDown === null) {
|
||||
inspectKeyDown = new KeyDown(inspectKey);
|
||||
}
|
||||
}
|
||||
|
||||
update() {
|
||||
|
@ -121,7 +175,11 @@ export class GPXLayer {
|
|||
return;
|
||||
}
|
||||
|
||||
if (file._data.style && file._data.style.color && this.layerColor !== `#${file._data.style.color}`) {
|
||||
if (
|
||||
file._data.style &&
|
||||
file._data.style.color &&
|
||||
this.layerColor !== `#${file._data.style.color}`
|
||||
) {
|
||||
decrementColor(this.layerColor);
|
||||
this.layerColor = `#${file._data.style.color}`;
|
||||
}
|
||||
|
@ -133,7 +191,7 @@ export class GPXLayer {
|
|||
} else {
|
||||
this.map.addSource(this.fileId, {
|
||||
type: 'geojson',
|
||||
data: this.getGeoJSON()
|
||||
data: this.getGeoJSON(),
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -144,23 +202,26 @@ export class GPXLayer {
|
|||
source: this.fileId,
|
||||
layout: {
|
||||
'line-join': 'round',
|
||||
'line-cap': 'round'
|
||||
'line-cap': 'round',
|
||||
},
|
||||
paint: {
|
||||
'line-color': ['get', 'color'],
|
||||
'line-width': ['get', 'weight'],
|
||||
'line-opacity': ['get', 'opacity']
|
||||
}
|
||||
'line-width': ['get', 'width'],
|
||||
'line-opacity': ['get', 'opacity'],
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
this.map.on('mousemove', this.fileId, this.layerOnMouseMoveBinded);
|
||||
}
|
||||
|
||||
if (get(directionMarkers)) {
|
||||
if (!this.map.getLayer(this.fileId + '-direction')) {
|
||||
this.map.addLayer({
|
||||
this.map.addLayer(
|
||||
{
|
||||
id: this.fileId + '-direction',
|
||||
type: 'symbol',
|
||||
source: this.fileId,
|
||||
|
@ -170,7 +231,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,
|
||||
},
|
||||
|
@ -178,9 +239,11 @@ export class GPXLayer {
|
|||
'text-color': 'white',
|
||||
'text-opacity': 0.7,
|
||||
'text-halo-width': 0.2,
|
||||
'text-halo-color': 'white'
|
||||
}
|
||||
}, this.map.getLayer('distance-markers') ? 'distance-markers' : undefined);
|
||||
'text-halo-color': 'white',
|
||||
},
|
||||
},
|
||||
this.map.getLayer('distance-markers') ? 'distance-markers' : undefined
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (this.map.getLayer(this.fileId + '-direction')) {
|
||||
|
@ -195,23 +258,53 @@ export class GPXLayer {
|
|||
}
|
||||
});
|
||||
|
||||
this.map.setFilter(this.fileId, ['any', ...visibleItems.map(([trackIndex, segmentIndex]) => ['all', ['==', 'trackIndex', trackIndex], ['==', 'segmentIndex', segmentIndex]])], { validate: false });
|
||||
this.map.setFilter(
|
||||
this.fileId,
|
||||
[
|
||||
'any',
|
||||
...visibleItems.map(([trackIndex, segmentIndex]) => [
|
||||
'all',
|
||||
['==', 'trackIndex', trackIndex],
|
||||
['==', 'segmentIndex', segmentIndex],
|
||||
]),
|
||||
],
|
||||
{ validate: false }
|
||||
);
|
||||
if (this.map.getLayer(this.fileId + '-direction')) {
|
||||
this.map.setFilter(this.fileId + '-direction', ['any', ...visibleItems.map(([trackIndex, segmentIndex]) => ['all', ['==', 'trackIndex', trackIndex], ['==', 'segmentIndex', segmentIndex]])], { validate: false });
|
||||
this.map.setFilter(
|
||||
this.fileId + '-direction',
|
||||
[
|
||||
'any',
|
||||
...visibleItems.map(([trackIndex, segmentIndex]) => [
|
||||
'all',
|
||||
['==', 'trackIndex', trackIndex],
|
||||
['==', 'segmentIndex', segmentIndex],
|
||||
]),
|
||||
],
|
||||
{ validate: false }
|
||||
);
|
||||
}
|
||||
} catch (e) { // No reliable way to check if the map is ready to add sources and layers
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to add sources and layers
|
||||
return;
|
||||
}
|
||||
|
||||
let markerIndex = 0;
|
||||
|
||||
if (get(selection).hasAnyChildren(new ListFileItem(this.fileId))) {
|
||||
file.wpt.forEach((waypoint) => { // Update markers
|
||||
file.wpt.forEach((waypoint) => {
|
||||
// Update markers
|
||||
let symbolKey = getSymbolKey(waypoint.sym);
|
||||
if (markerIndex < this.markers.length) {
|
||||
this.markers[markerIndex].getElement().innerHTML = getMarkerForSymbol(symbolKey, this.layerColor);
|
||||
this.markers[markerIndex].getElement().innerHTML = getMarkerForSymbol(
|
||||
symbolKey,
|
||||
this.layerColor
|
||||
);
|
||||
this.markers[markerIndex].setLngLat(waypoint.getCoordinates());
|
||||
Object.defineProperty(this.markers[markerIndex], '_waypoint', { value: waypoint, writable: true });
|
||||
Object.defineProperty(this.markers[markerIndex], '_waypoint', {
|
||||
value: waypoint,
|
||||
writable: true,
|
||||
});
|
||||
} else {
|
||||
let element = document.createElement('div');
|
||||
element.classList.add('w-8', 'h-8', 'drop-shadow-xl');
|
||||
|
@ -219,15 +312,15 @@ export class GPXLayer {
|
|||
let marker = new mapboxgl.Marker({
|
||||
draggable: this.draggable,
|
||||
element,
|
||||
anchor: 'bottom'
|
||||
anchor: 'bottom',
|
||||
}).setLngLat(waypoint.getCoordinates());
|
||||
Object.defineProperty(marker, '_waypoint', { value: waypoint, writable: true });
|
||||
let dragEndTimestamp = 0;
|
||||
marker.getElement().addEventListener('mouseover', (e) => {
|
||||
marker.getElement().addEventListener('mousemove', (e) => {
|
||||
if (marker._isDragging) {
|
||||
return;
|
||||
}
|
||||
this.showWaypointPopup(marker._waypoint);
|
||||
waypointPopup?.setItem({ item: marker._waypoint, fileId: this.fileId });
|
||||
e.stopPropagation();
|
||||
});
|
||||
marker.getElement().addEventListener('click', (e) => {
|
||||
|
@ -241,37 +334,49 @@ export class GPXLayer {
|
|||
return;
|
||||
}
|
||||
|
||||
if (get(verticalFileView)) {
|
||||
if ((e.ctrlKey || e.metaKey) && get(selection).hasAnyChildren(new ListWaypointsItem(this.fileId), false)) {
|
||||
addSelectItem(new ListWaypointItem(this.fileId, marker._waypoint._data.index));
|
||||
if (get(treeFileView)) {
|
||||
if (
|
||||
(e.ctrlKey || e.metaKey) &&
|
||||
get(selection).hasAnyChildren(
|
||||
new ListWaypointsItem(this.fileId),
|
||||
false
|
||||
)
|
||||
) {
|
||||
addSelectItem(
|
||||
new ListWaypointItem(this.fileId, marker._waypoint._data.index)
|
||||
);
|
||||
} else {
|
||||
selectItem(new ListWaypointItem(this.fileId, marker._waypoint._data.index));
|
||||
selectItem(
|
||||
new ListWaypointItem(this.fileId, marker._waypoint._data.index)
|
||||
);
|
||||
}
|
||||
} else if (get(currentTool) === Tool.WAYPOINT) {
|
||||
selectedWaypoint.set([marker._waypoint, this.fileId]);
|
||||
} else {
|
||||
this.showWaypointPopup(marker._waypoint);
|
||||
waypointPopup?.setItem({ item: marker._waypoint, fileId: this.fileId });
|
||||
}
|
||||
e.stopPropagation();
|
||||
});
|
||||
marker.on('dragstart', () => {
|
||||
setGrabbingCursor();
|
||||
marker.getElement().style.cursor = 'grabbing';
|
||||
this.hideWaypointPopup();
|
||||
waypointPopup?.hide();
|
||||
});
|
||||
marker.on('dragend', (e) => {
|
||||
resetCursor();
|
||||
marker.getElement().style.cursor = '';
|
||||
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
|
||||
lon: latLng.lng,
|
||||
});
|
||||
wpt.ele = getElevation(this.map, wpt.getCoordinates());
|
||||
wpt.ele = ele[0];
|
||||
});
|
||||
dragEndTimestamp = Date.now()
|
||||
});
|
||||
dragEndTimestamp = Date.now();
|
||||
});
|
||||
this.markers.push(marker);
|
||||
}
|
||||
|
@ -279,7 +384,8 @@ export class GPXLayer {
|
|||
});
|
||||
}
|
||||
|
||||
while (markerIndex < this.markers.length) { // Remove extra markers
|
||||
while (markerIndex < this.markers.length) {
|
||||
// Remove extra markers
|
||||
this.markers.pop()?.remove();
|
||||
}
|
||||
|
||||
|
@ -294,16 +400,18 @@ 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('mousemove', this.fileId, this.layerOnMouseMoveBinded);
|
||||
this.map.off('style.import.load', this.updateBinded);
|
||||
|
||||
if (this.map.getLayer(this.fileId + '-direction')) {
|
||||
this.map.removeLayer(this.fileId + '-direction');
|
||||
|
@ -330,7 +438,10 @@ export class GPXLayer {
|
|||
this.map.moveLayer(this.fileId);
|
||||
}
|
||||
if (this.map.getLayer(this.fileId + '-direction')) {
|
||||
this.map.moveLayer(this.fileId + '-direction', this.map.getLayer('distance-markers') ? 'distance-markers' : undefined);
|
||||
this.map.moveLayer(
|
||||
this.fileId + '-direction',
|
||||
this.map.getLayer('distance-markers') ? 'distance-markers' : undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -338,7 +449,12 @@ export class GPXLayer {
|
|||
let trackIndex = e.features[0].properties.trackIndex;
|
||||
let segmentIndex = e.features[0].properties.segmentIndex;
|
||||
|
||||
if (get(currentTool) === Tool.SCISSORS && get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex))) {
|
||||
if (
|
||||
get(currentTool) === Tool.SCISSORS &&
|
||||
get(selection).hasAnyParent(
|
||||
new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
|
||||
)
|
||||
) {
|
||||
setScissorsCursor();
|
||||
} else {
|
||||
setPointerCursor();
|
||||
|
@ -349,16 +465,43 @@ export class GPXLayer {
|
|||
resetCursor();
|
||||
}
|
||||
|
||||
layerOnMouseMove(e: any) {
|
||||
if (inspectKeyDown?.isDown()) {
|
||||
let trackIndex = e.features[0].properties.trackIndex;
|
||||
let segmentIndex = e.features[0].properties.segmentIndex;
|
||||
|
||||
const file = get(this.file)?.file;
|
||||
if (file) {
|
||||
const closest = getClosestLinePoint(
|
||||
file.trk[trackIndex].trkseg[segmentIndex].trkpt,
|
||||
{ lat: e.lngLat.lat, lon: e.lngLat.lng }
|
||||
);
|
||||
trackpointPopup?.setItem({ item: closest, fileId: this.fileId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
layerOnClick(e: any) {
|
||||
if (get(currentTool) === Tool.ROUTING && get(selection).hasAnyChildren(new ListRootItem(), true, ['waypoints'])) {
|
||||
if (
|
||||
get(currentTool) === Tool.ROUTING &&
|
||||
get(selection).hasAnyChildren(new ListRootItem(), true, ['waypoints'])
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let trackIndex = e.features[0].properties.trackIndex;
|
||||
let segmentIndex = e.features[0].properties.segmentIndex;
|
||||
|
||||
if (get(currentTool) === Tool.SCISSORS && get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex))) {
|
||||
dbUtils.split(this.fileId, trackIndex, segmentIndex, { lat: e.lngLat.lat, lon: e.lngLat.lng });
|
||||
if (
|
||||
get(currentTool) === Tool.SCISSORS &&
|
||||
get(selection).hasAnyParent(
|
||||
new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
|
||||
)
|
||||
) {
|
||||
dbUtils.split(this.fileId, trackIndex, segmentIndex, {
|
||||
lat: e.lngLat.lat,
|
||||
lon: e.lngLat.lng,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -368,8 +511,12 @@ export class GPXLayer {
|
|||
}
|
||||
|
||||
let item = undefined;
|
||||
if (get(verticalFileView) && file.getSegments().length > 1) { // Select inner item
|
||||
item = file.children[trackIndex].children.length > 1 ? new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex) : new ListTrackItem(this.fileId, trackIndex);
|
||||
if (get(treeFileView) && file.getSegments().length > 1) {
|
||||
// Select inner item
|
||||
item =
|
||||
file.children[trackIndex].children.length > 1
|
||||
? new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
|
||||
: new ListTrackItem(this.fileId, trackIndex);
|
||||
} else {
|
||||
item = new ListFileItem(this.fileId);
|
||||
}
|
||||
|
@ -381,40 +528,9 @@ export class GPXLayer {
|
|||
}
|
||||
}
|
||||
|
||||
showWaypointPopup(waypoint: Waypoint) {
|
||||
if (get(currentPopupWaypoint) !== null) {
|
||||
this.hideWaypointPopup();
|
||||
}
|
||||
let marker = this.markers[waypoint._data.index];
|
||||
if (marker) {
|
||||
currentPopupWaypoint.set([waypoint, this.fileId]);
|
||||
marker.setPopup(waypointPopup);
|
||||
marker.togglePopup();
|
||||
this.map.on('mousemove', this.maybeHideWaypointPopupBinded);
|
||||
}
|
||||
}
|
||||
|
||||
maybeHideWaypointPopup(e: any) {
|
||||
let waypoint = get(currentPopupWaypoint)?.[0];
|
||||
if (waypoint) {
|
||||
let marker = this.markers[waypoint._data.index];
|
||||
if (marker) {
|
||||
if (this.map.project(marker.getLngLat()).dist(this.map.project(e.lngLat)) > 100) {
|
||||
this.hideWaypointPopup();
|
||||
}
|
||||
} else {
|
||||
this.hideWaypointPopup();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hideWaypointPopup() {
|
||||
let waypoint = get(currentPopupWaypoint)?.[0];
|
||||
if (waypoint) {
|
||||
let marker = this.markers[waypoint._data.index];
|
||||
marker?.getPopup()?.remove();
|
||||
currentPopupWaypoint.set(null);
|
||||
this.map.off('mousemove', this.maybeHideWaypointPopupBinded);
|
||||
layerOnContextMenu(e: any) {
|
||||
if (e.originalEvent.ctrlKey) {
|
||||
this.layerOnClick(e);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -423,13 +539,14 @@ export class GPXLayer {
|
|||
if (!file) {
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: []
|
||||
features: [],
|
||||
};
|
||||
}
|
||||
|
||||
let data = file.toGeoJSON();
|
||||
|
||||
let trackIndex = 0, segmentIndex = 0;
|
||||
let trackIndex = 0,
|
||||
segmentIndex = 0;
|
||||
for (let feature of data.features) {
|
||||
if (!feature.properties) {
|
||||
feature.properties = {};
|
||||
|
@ -437,14 +554,19 @@ export class GPXLayer {
|
|||
if (!feature.properties.color) {
|
||||
feature.properties.color = this.layerColor;
|
||||
}
|
||||
if (!feature.properties.weight) {
|
||||
feature.properties.weight = get(defaultWeight);
|
||||
}
|
||||
if (!feature.properties.opacity) {
|
||||
feature.properties.opacity = get(defaultOpacity);
|
||||
}
|
||||
if (get(selection).hasAnyParent(new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)) || get(selection).hasAnyChildren(new ListWaypointsItem(this.fileId), true)) {
|
||||
feature.properties.weight = feature.properties.weight + 2;
|
||||
if (!feature.properties.width) {
|
||||
feature.properties.width = get(defaultWidth);
|
||||
}
|
||||
if (
|
||||
get(selection).hasAnyParent(
|
||||
new ListTrackSegmentItem(this.fileId, trackIndex, segmentIndex)
|
||||
) ||
|
||||
get(selection).hasAnyChildren(new ListWaypointsItem(this.fileId), true)
|
||||
) {
|
||||
feature.properties.width = feature.properties.width + 2;
|
||||
feature.properties.opacity = Math.min(1, feature.properties.opacity + 0.1);
|
||||
}
|
||||
feature.properties.trackIndex = trackIndex;
|
||||
|
|
44
website/src/lib/components/gpx-layer/GPXLayerPopup.ts
Normal file
44
website/src/lib/components/gpx-layer/GPXLayerPopup.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
import { dbUtils } from '$lib/db';
|
||||
import { MapPopup } from '$lib/components/MapPopup';
|
||||
|
||||
export let waypointPopup: MapPopup | null = null;
|
||||
export let trackpointPopup: MapPopup | null = null;
|
||||
|
||||
export function createPopups(map: mapboxgl.Map) {
|
||||
removePopups();
|
||||
waypointPopup = new MapPopup(map, {
|
||||
closeButton: false,
|
||||
focusAfterOpen: false,
|
||||
maxWidth: undefined,
|
||||
offset: {
|
||||
top: [0, 0],
|
||||
'top-left': [0, 0],
|
||||
'top-right': [0, 0],
|
||||
bottom: [0, -30],
|
||||
'bottom-left': [0, -30],
|
||||
'bottom-right': [0, -30],
|
||||
left: [10, -15],
|
||||
right: [-10, -15],
|
||||
},
|
||||
});
|
||||
trackpointPopup = new MapPopup(map, {
|
||||
closeButton: false,
|
||||
focusAfterOpen: false,
|
||||
maxWidth: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function removePopups() {
|
||||
if (waypointPopup !== null) {
|
||||
waypointPopup.remove();
|
||||
waypointPopup = null;
|
||||
}
|
||||
if (trackpointPopup !== null) {
|
||||
trackpointPopup.remove();
|
||||
trackpointPopup = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteWaypoint(fileId: string, waypointIndex: number) {
|
||||
dbUtils.applyToFile(fileId, (file) => file.replaceWaypoints(waypointIndex, waypointIndex, []));
|
||||
}
|
|
@ -1,11 +1,11 @@
|
|||
<script lang="ts">
|
||||
import { map, gpxLayers } from '$lib/stores';
|
||||
import { GPXLayer } from './GPXLayer';
|
||||
import WaypointPopup from './WaypointPopup.svelte';
|
||||
import { fileObservers } from '$lib/db';
|
||||
import { DistanceMarkers } from './DistanceMarkers';
|
||||
import { StartEndMarkers } from './StartEndMarkers';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { createPopups, removePopups } from './GPXLayerPopup';
|
||||
|
||||
let distanceMarkers: DistanceMarkers | undefined = undefined;
|
||||
let startEndMarkers: StartEndMarkers | undefined = undefined;
|
||||
|
@ -35,6 +35,7 @@
|
|||
if (startEndMarkers) {
|
||||
startEndMarkers.remove();
|
||||
}
|
||||
createPopups($map);
|
||||
distanceMarkers = new DistanceMarkers($map);
|
||||
startEndMarkers = new StartEndMarkers($map);
|
||||
}
|
||||
|
@ -42,17 +43,14 @@
|
|||
onDestroy(() => {
|
||||
gpxLayers.forEach((layer) => layer.remove());
|
||||
gpxLayers.clear();
|
||||
|
||||
removePopups();
|
||||
if (distanceMarkers) {
|
||||
distanceMarkers.remove();
|
||||
distanceMarkers = undefined;
|
||||
}
|
||||
|
||||
if (startEndMarkers) {
|
||||
startEndMarkers.remove();
|
||||
startEndMarkers = undefined;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<WaypointPopup />
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { gpxStatistics, slicedGPXStatistics, currentTool, Tool } from "$lib/stores";
|
||||
import mapboxgl from "mapbox-gl";
|
||||
import { get } from "svelte/store";
|
||||
import { gpxStatistics, slicedGPXStatistics, currentTool, Tool } from '$lib/stores';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
export class StartEndMarkers {
|
||||
map: mapboxgl.Map;
|
||||
|
@ -16,7 +16,8 @@ export class StartEndMarkers {
|
|||
let endElement = document.createElement('div');
|
||||
startElement.className = `h-4 w-4 rounded-full bg-green-500 border-2 border-white`;
|
||||
endElement.className = `h-4 w-4 rounded-full border-2 border-white`;
|
||||
endElement.style.background = 'repeating-conic-gradient(#fff 0 90deg, #000 0 180deg) 0 0/8px 8px round';
|
||||
endElement.style.background =
|
||||
'repeating-conic-gradient(#fff 0 90deg, #000 0 180deg) 0 0/8px 8px round';
|
||||
|
||||
this.start = new mapboxgl.Marker({ element: startElement });
|
||||
this.end = new mapboxgl.Marker({ element: endElement });
|
||||
|
@ -31,7 +32,11 @@ export class StartEndMarkers {
|
|||
let statistics = get(slicedGPXStatistics)?.[0] ?? get(gpxStatistics);
|
||||
if (statistics.local.points.length > 0 && tool !== Tool.ROUTING) {
|
||||
this.start.setLngLat(statistics.local.points[0].getCoordinates()).addTo(this.map);
|
||||
this.end.setLngLat(statistics.local.points[statistics.local.points.length - 1].getCoordinates()).addTo(this.map);
|
||||
this.end
|
||||
.setLngLat(
|
||||
statistics.local.points[statistics.local.points.length - 1].getCoordinates()
|
||||
)
|
||||
.addTo(this.map);
|
||||
} else {
|
||||
this.start.remove();
|
||||
this.end.remove();
|
||||
|
@ -39,7 +44,7 @@ export class StartEndMarkers {
|
|||
}
|
||||
|
||||
remove() {
|
||||
this.unsubscribes.forEach(unsubscribe => unsubscribe());
|
||||
this.unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
|
||||
this.start.remove();
|
||||
this.end.remove();
|
||||
|
|
43
website/src/lib/components/gpx-layer/TrackpointPopup.svelte
Normal file
43
website/src/lib/components/gpx-layer/TrackpointPopup.svelte
Normal file
|
@ -0,0 +1,43 @@
|
|||
<script lang="ts">
|
||||
import type { TrackPoint } from 'gpx';
|
||||
import type { PopupItem } from '$lib/components/MapPopup';
|
||||
import CopyCoordinates from '$lib/components/gpx-layer/CopyCoordinates.svelte';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import WithUnits from '$lib/components/WithUnits.svelte';
|
||||
import { Compass, Mountain, Timer } from 'lucide-svelte';
|
||||
import { df } from '$lib/utils';
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
export let trackpoint: PopupItem<TrackPoint>;
|
||||
</script>
|
||||
|
||||
<Card.Root class="border-none shadow-md text-base p-2">
|
||||
<Card.Header class="p-0">
|
||||
<Card.Title class="text-md"></Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-col p-0 text-xs gap-1">
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<Compass size="14" />
|
||||
{trackpoint.item.getLatitude().toFixed(6)}° {trackpoint.item
|
||||
.getLongitude()
|
||||
.toFixed(6)}°
|
||||
</div>
|
||||
{#if trackpoint.item.ele !== undefined}
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<Mountain size="14" />
|
||||
<WithUnits value={trackpoint.item.ele} type="elevation" />
|
||||
</div>
|
||||
{/if}
|
||||
{#if trackpoint.item.time}
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<Timer size="14" />
|
||||
{df.format(trackpoint.item.time)}
|
||||
</div>
|
||||
{/if}
|
||||
<CopyCoordinates
|
||||
coordinates={trackpoint.item.attributes}
|
||||
onCopy={() => trackpoint.hide?.()}
|
||||
class="mt-0.5"
|
||||
/>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
|
@ -2,54 +2,50 @@
|
|||
import * as Card from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import Shortcut from '$lib/components/Shortcut.svelte';
|
||||
import { waypointPopup, currentPopupWaypoint, deleteWaypoint } from './WaypointPopup';
|
||||
import CopyCoordinates from '$lib/components/gpx-layer/CopyCoordinates.svelte';
|
||||
import { deleteWaypoint } from './GPXLayerPopup';
|
||||
import WithUnits from '$lib/components/WithUnits.svelte';
|
||||
import { Dot, ExternalLink, Trash2 } from 'lucide-svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { Tool, currentTool } from '$lib/stores';
|
||||
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import type { Waypoint } from 'gpx';
|
||||
import type { PopupItem } from '$lib/components/MapPopup';
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||
|
||||
let popupElement: HTMLDivElement;
|
||||
export let waypoint: PopupItem<Waypoint>;
|
||||
|
||||
onMount(() => {
|
||||
waypointPopup.setDOMContent(popupElement);
|
||||
popupElement.classList.remove('hidden');
|
||||
});
|
||||
|
||||
$: symbolKey = $currentPopupWaypoint ? getSymbolKey($currentPopupWaypoint[0].sym) : undefined;
|
||||
$: symbolKey = waypoint ? getSymbolKey(waypoint.item.sym) : undefined;
|
||||
|
||||
function sanitize(text: string | undefined): string {
|
||||
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>
|
||||
|
||||
<div bind:this={popupElement} class="hidden">
|
||||
{#if $currentPopupWaypoint}
|
||||
<Card.Root class="border-none shadow-md text-base max-w-80 p-2">
|
||||
<Card.Root class="border-none shadow-md text-base p-2 max-w-[50dvw]">
|
||||
<Card.Header class="p-0">
|
||||
<Card.Title class="text-md">
|
||||
{#if $currentPopupWaypoint[0].link && $currentPopupWaypoint[0].link.attributes && $currentPopupWaypoint[0].link.attributes.href}
|
||||
<a href={$currentPopupWaypoint[0].link.attributes.href} target="_blank">
|
||||
{$currentPopupWaypoint[0].name ?? $currentPopupWaypoint[0].link.attributes.href}
|
||||
{#if waypoint.item.link && waypoint.item.link.attributes && waypoint.item.link.attributes.href}
|
||||
<a href={waypoint.item.link.attributes.href} target="_blank">
|
||||
{waypoint.item.name ?? waypoint.item.link.attributes.href}
|
||||
<ExternalLink size="12" class="inline-block mb-1.5" />
|
||||
</a>
|
||||
{:else}
|
||||
{$currentPopupWaypoint[0].name ?? $_('gpx.waypoint')}
|
||||
{waypoint.item.name ?? $_('gpx.waypoint')}
|
||||
{/if}
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-col p-0 text-sm">
|
||||
<Card.Content class="flex flex-col text-sm p-0">
|
||||
<div class="flex flex-row items-center text-muted-foreground text-xs whitespace-nowrap">
|
||||
{#if symbolKey}
|
||||
<span>
|
||||
|
@ -66,40 +62,47 @@
|
|||
</span>
|
||||
<Dot size="16" />
|
||||
{/if}
|
||||
{$currentPopupWaypoint[0].getLatitude().toFixed(6)}° {$currentPopupWaypoint[0]
|
||||
{waypoint.item.getLatitude().toFixed(6)}° {waypoint.item
|
||||
.getLongitude()
|
||||
.toFixed(6)}°
|
||||
{#if $currentPopupWaypoint[0].ele !== undefined}
|
||||
{#if waypoint.item.ele !== undefined}
|
||||
<Dot size="16" />
|
||||
<WithUnits value={$currentPopupWaypoint[0].ele} type="elevation" />
|
||||
<WithUnits value={waypoint.item.ele} type="elevation" />
|
||||
{/if}
|
||||
</div>
|
||||
{#if $currentPopupWaypoint[0].desc}
|
||||
<span class="whitespace-pre-wrap">{@html sanitize($currentPopupWaypoint[0].desc)}</span>
|
||||
<ScrollArea class="flex flex-col" viewportClasses="max-h-[30dvh]">
|
||||
{#if waypoint.item.desc}
|
||||
<span class="whitespace-pre-wrap">{@html sanitize(waypoint.item.desc)}</span>
|
||||
{/if}
|
||||
{#if $currentPopupWaypoint[0].cmt && $currentPopupWaypoint[0].cmt !== $currentPopupWaypoint[0].desc}
|
||||
<span class="whitespace-pre-wrap">{@html sanitize($currentPopupWaypoint[0].cmt)}</span>
|
||||
{#if waypoint.item.cmt && waypoint.item.cmt !== waypoint.item.desc}
|
||||
<span class="whitespace-pre-wrap">{@html sanitize(waypoint.item.cmt)}</span>
|
||||
{/if}
|
||||
</ScrollArea>
|
||||
<div class="mt-2 flex flex-col gap-1">
|
||||
<CopyCoordinates coordinates={waypoint.item.attributes} />
|
||||
{#if $currentTool === Tool.WAYPOINT}
|
||||
<Button
|
||||
class="mt-2 w-full px-2 py-1 h-8 justify-start"
|
||||
class="w-full px-2 py-1 h-8 justify-start"
|
||||
variant="outline"
|
||||
on:click={() =>
|
||||
deleteWaypoint($currentPopupWaypoint[1], $currentPopupWaypoint[0]._data.index)}
|
||||
on:click={() => deleteWaypoint(waypoint.fileId, waypoint.item._data.index)}
|
||||
>
|
||||
<Trash2 size="16" class="mr-1" />
|
||||
{$_('menu.delete')}
|
||||
<Shortcut key="" shift={true} click={true} />
|
||||
<Shortcut shift={true} click={true} />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
</div>
|
||||
</Card.Root>
|
||||
|
||||
<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,25 +0,0 @@
|
|||
import { dbUtils } from "$lib/db";
|
||||
import type { Waypoint } from "gpx";
|
||||
import mapboxgl from "mapbox-gl";
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
export const currentPopupWaypoint = writable<[Waypoint, string] | null>(null);
|
||||
|
||||
export const waypointPopup = new mapboxgl.Popup({
|
||||
closeButton: false,
|
||||
maxWidth: undefined,
|
||||
offset: {
|
||||
'top': [0, 0],
|
||||
'top-left': [0, 0],
|
||||
'top-right': [0, 0],
|
||||
'bottom': [0, -30],
|
||||
'bottom-left': [0, -30],
|
||||
'bottom-right': [0, -30],
|
||||
'left': [0, 0],
|
||||
'right': [0, 0]
|
||||
},
|
||||
});
|
||||
|
||||
export function deleteWaypoint(fileId: string, waypointIndex: number) {
|
||||
dbUtils.applyToFile(fileId, (file) => file.replaceWaypoints(waypointIndex, waypointIndex, []));
|
||||
}
|
|
@ -15,14 +15,15 @@
|
|||
Trash2,
|
||||
Move,
|
||||
Map,
|
||||
Layers2
|
||||
Layers2,
|
||||
} from 'lucide-svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { settings } from '$lib/db';
|
||||
import { defaultBasemap, extendBasemap, type CustomLayer } from '$lib/assets/layers';
|
||||
import { defaultBasemap, type CustomLayer } from '$lib/assets/layers';
|
||||
import { map } from '$lib/stores';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import Sortable from 'sortablejs/Sortable';
|
||||
import { customBasemapUpdate } from './utils';
|
||||
|
||||
const {
|
||||
customLayers,
|
||||
|
@ -33,7 +34,7 @@
|
|||
currentOverlays,
|
||||
previousOverlays,
|
||||
customBasemapOrder,
|
||||
customOverlayOrder
|
||||
customOverlayOrder,
|
||||
} = settings;
|
||||
|
||||
let name: string = '';
|
||||
|
@ -67,7 +68,7 @@
|
|||
acc[id] = true;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
},
|
||||
});
|
||||
overlaySortable = Sortable.create(overlayContainer, {
|
||||
onSort: (e) => {
|
||||
|
@ -76,7 +77,7 @@
|
|||
acc[id] = true;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
basemapSortable.sort($customBasemapOrder);
|
||||
|
@ -94,7 +95,6 @@
|
|||
(tileUrls[0].includes('api.mapbox.com/styles') && !tileUrls[0].includes('tiles'))
|
||||
) {
|
||||
resourceType = 'vector';
|
||||
layerType = 'basemap';
|
||||
} else {
|
||||
resourceType = 'raster';
|
||||
}
|
||||
|
@ -108,47 +108,41 @@
|
|||
if (typeof maxZoom === 'string') {
|
||||
maxZoom = parseInt(maxZoom);
|
||||
}
|
||||
let is512 = tileUrls.some((url) => url.includes('512'));
|
||||
|
||||
let layerId = selectedLayerId ?? getLayerId();
|
||||
let layer: CustomLayer = {
|
||||
id: layerId,
|
||||
name: name,
|
||||
tileUrls: tileUrls,
|
||||
tileUrls: tileUrls.map((url) => decodeURI(url.trim())),
|
||||
maxZoom: maxZoom,
|
||||
layerType: layerType,
|
||||
resourceType: resourceType,
|
||||
value: ''
|
||||
value: '',
|
||||
};
|
||||
|
||||
if (resourceType === 'vector') {
|
||||
layer.value = tileUrls[0];
|
||||
layer.value = layer.tileUrls[0];
|
||||
} else {
|
||||
if (layerType === 'basemap') {
|
||||
layer.value = extendBasemap({
|
||||
layer.value = {
|
||||
version: 8,
|
||||
sources: {
|
||||
[layerId]: {
|
||||
type: 'raster',
|
||||
tiles: tileUrls,
|
||||
maxzoom: maxZoom
|
||||
}
|
||||
tiles: layer.tileUrls,
|
||||
tileSize: is512 ? 512 : 256,
|
||||
maxzoom: maxZoom,
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: layerId,
|
||||
type: 'raster',
|
||||
source: layerId
|
||||
}
|
||||
]
|
||||
});
|
||||
} else {
|
||||
layer.value = {
|
||||
type: 'raster',
|
||||
tiles: tileUrls,
|
||||
maxzoom: maxZoom
|
||||
source: layerId,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
$customLayers[layerId] = layer;
|
||||
addLayer(layerId);
|
||||
selectedLayerId = undefined;
|
||||
|
@ -173,7 +167,11 @@
|
|||
return $tree;
|
||||
});
|
||||
|
||||
if ($currentBasemap === layerId) {
|
||||
$customBasemapUpdate++;
|
||||
} else {
|
||||
$currentBasemap = layerId;
|
||||
}
|
||||
|
||||
if (!$customBasemapOrder.includes(layerId)) {
|
||||
$customBasemapOrder = [...$customBasemapOrder, layerId];
|
||||
|
@ -187,12 +185,16 @@
|
|||
return $tree;
|
||||
});
|
||||
|
||||
if ($map && $map.getSource(layerId)) {
|
||||
// Reset source when updating an existing layer
|
||||
if ($map.getLayer(layerId)) {
|
||||
$map.removeLayer(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
|
||||
}
|
||||
$map.removeSource(layerId);
|
||||
}
|
||||
|
||||
if (!$currentOverlays.overlays.hasOwnProperty('custom')) {
|
||||
|
@ -228,7 +230,10 @@
|
|||
layerId
|
||||
);
|
||||
if (Object.keys($selectedBasemapTree.basemaps['custom']).length === 0) {
|
||||
$selectedBasemapTree.basemaps = tryDeleteLayer($selectedBasemapTree.basemaps, 'custom');
|
||||
$selectedBasemapTree.basemaps = tryDeleteLayer(
|
||||
$selectedBasemapTree.basemaps,
|
||||
'custom'
|
||||
);
|
||||
}
|
||||
$customBasemapOrder = $customBasemapOrder.filter((id) => id !== layerId);
|
||||
} else {
|
||||
|
@ -245,16 +250,22 @@
|
|||
layerId
|
||||
);
|
||||
if (Object.keys($selectedOverlayTree.overlays['custom']).length === 0) {
|
||||
$selectedOverlayTree.overlays = tryDeleteLayer($selectedOverlayTree.overlays, 'custom');
|
||||
$selectedOverlayTree.overlays = tryDeleteLayer(
|
||||
$selectedOverlayTree.overlays,
|
||||
'custom'
|
||||
);
|
||||
}
|
||||
$customOverlayOrder = $customOverlayOrder.filter((id) => id !== layerId);
|
||||
|
||||
if ($map) {
|
||||
if ($map.getLayer(layerId)) {
|
||||
$map.removeLayer(layerId);
|
||||
}
|
||||
if ($map.getSource(layerId)) {
|
||||
$map.removeSource(layerId);
|
||||
if (
|
||||
$currentOverlays.overlays['custom'] &&
|
||||
$currentOverlays.overlays['custom'][layerId] &&
|
||||
$map
|
||||
) {
|
||||
try {
|
||||
$map.removeImport(layerId);
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to remove sources and layers
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -362,7 +373,8 @@
|
|||
/>
|
||||
{#if tileUrls.length > 1}
|
||||
<Button
|
||||
on:click={() => (tileUrls = tileUrls.filter((_, index) => index !== i))}
|
||||
on:click={() =>
|
||||
(tileUrls = tileUrls.filter((_, index) => index !== i))}
|
||||
variant="outline"
|
||||
class="p-1 h-8"
|
||||
>
|
||||
|
@ -382,7 +394,14 @@
|
|||
{/each}
|
||||
{#if resourceType === 'raster'}
|
||||
<Label for="maxZoom">{$_('layers.custom_layers.max_zoom')}</Label>
|
||||
<Input type="number" bind:value={maxZoom} id="maxZoom" min={0} max={22} class="h-8" />
|
||||
<Input
|
||||
type="number"
|
||||
bind:value={maxZoom}
|
||||
id="maxZoom"
|
||||
min={0}
|
||||
max={22}
|
||||
class="h-8"
|
||||
/>
|
||||
{/if}
|
||||
<Label>{$_('layers.custom_layers.layer_type')}</Label>
|
||||
<RadioGroup.Root bind:value={layerType} class="flex flex-row">
|
||||
|
@ -391,7 +410,7 @@
|
|||
<Label for="basemap">{$_('layers.custom_layers.basemap')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value="overlay" id="overlay" disabled={resourceType === 'vector'} />
|
||||
<RadioGroup.Item value="overlay" id="overlay" />
|
||||
<Label for="overlay">{$_('layers.custom_layers.overlay')}</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
|
|
|
@ -11,9 +11,8 @@
|
|||
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';
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let overpassLayer: OverpassLayer;
|
||||
|
@ -27,42 +26,92 @@
|
|||
selectedOverlayTree,
|
||||
selectedOverpassTree,
|
||||
customLayers,
|
||||
opacities
|
||||
opacities,
|
||||
} = settings;
|
||||
|
||||
function setStyle() {
|
||||
if ($map) {
|
||||
let basemap = basemaps.hasOwnProperty($currentBasemap)
|
||||
? basemaps[$currentBasemap]
|
||||
: $customLayers[$currentBasemap]?.value ?? basemaps[defaultBasemap];
|
||||
$map.setStyle(basemap, {
|
||||
diff: false
|
||||
});
|
||||
: ($customLayers[$currentBasemap]?.value ?? basemaps[defaultBasemap]);
|
||||
$map.removeImport('basemap');
|
||||
if (typeof basemap === 'string') {
|
||||
$map.addImport({ id: 'basemap', url: basemap }, 'overlays');
|
||||
} else {
|
||||
$map.addImport(
|
||||
{
|
||||
id: 'basemap',
|
||||
data: basemap,
|
||||
},
|
||||
'overlays'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$: if ($map && $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 = {};
|
||||
}
|
||||
if (!$map.getLayer(id)) {
|
||||
addOverlayLayer[id]();
|
||||
$map.on('style.load', addOverlayLayer[id]);
|
||||
layer.paint['raster-opacity'] = $opacities[id];
|
||||
}
|
||||
} else if ($map.getLayer(id)) {
|
||||
$map.removeLayer(id);
|
||||
$map.off('style.load', addOverlayLayer[id]);
|
||||
return layer;
|
||||
}),
|
||||
};
|
||||
}
|
||||
$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 && $opacities) {
|
||||
let overlayLayers = getLayers($currentOverlays);
|
||||
try {
|
||||
let activeOverlays = $map.getStyle().imports.reduce((acc, i) => {
|
||||
if (!['basemap', 'overlays', 'glyphs-and-sprite'].includes(i.id)) {
|
||||
acc[i.id] = i;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
let toRemove = Object.keys(activeOverlays).filter((id) => !overlayLayers[id]);
|
||||
toRemove.forEach((id) => {
|
||||
$map.removeImport(id);
|
||||
});
|
||||
let toAdd = Object.entries(overlayLayers)
|
||||
.filter(([id, selected]) => selected && !activeOverlays.hasOwnProperty(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 && $opacities) {
|
||||
updateOverlays();
|
||||
}
|
||||
|
||||
$: if ($map) {
|
||||
if (overpassLayer) {
|
||||
|
@ -70,6 +119,7 @@
|
|||
}
|
||||
overpassLayer = new OverpassLayer($map);
|
||||
overpassLayer.add();
|
||||
$map.on('style.import.load', updateOverlays);
|
||||
}
|
||||
|
||||
let selectedBasemap = writable(get(currentBasemap));
|
||||
|
@ -82,40 +132,11 @@
|
|||
});
|
||||
currentBasemap.subscribe((value) => {
|
||||
// Updates coming from the database, or from the user swapping basemaps
|
||||
if (value !== get(selectedBasemap)) {
|
||||
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;
|
||||
|
@ -192,8 +213,6 @@
|
|||
</div>
|
||||
</CustomControl>
|
||||
|
||||
<OverpassPopup />
|
||||
|
||||
<svelte:window
|
||||
on:click={(e) => {
|
||||
if (open && !cancelEvents && !container.contains(e.target)) {
|
||||
|
|
|
@ -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,9 +28,10 @@
|
|||
selectedBasemapTree,
|
||||
selectedOverlayTree,
|
||||
selectedOverpassTree,
|
||||
currentBasemap,
|
||||
currentOverlays,
|
||||
customLayers,
|
||||
opacities
|
||||
opacities,
|
||||
} = settings;
|
||||
|
||||
export let open: boolean;
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -106,7 +137,9 @@
|
|||
<Select.Content class="h-fit max-h-[40dvh] overflow-y-auto">
|
||||
{#each Object.keys(overlays) as id}
|
||||
{#if isSelected($selectedOverlayTree, id)}
|
||||
<Select.Item value={id}>{$_(`layers.label.${id}`)}</Select.Item>
|
||||
<Select.Item value={id}
|
||||
>{$_(`layers.label.${id}`)}</Select.Item
|
||||
>
|
||||
{/if}
|
||||
{/each}
|
||||
{#each Object.entries($customLayers) as [id, layer]}
|
||||
|
@ -126,15 +159,22 @@
|
|||
max={1}
|
||||
step={0.1}
|
||||
disabled={$selectedOverlay === undefined}
|
||||
onValueChange={() => {
|
||||
onValueChange={(value) => {
|
||||
if ($selectedOverlay) {
|
||||
$opacities[$selectedOverlay.value] = $overlayOpacity[0];
|
||||
if ($map) {
|
||||
if ($map.getLayer($selectedOverlay.value)) {
|
||||
$map.removeLayer($selectedOverlay.value);
|
||||
$currentOverlays = $currentOverlays;
|
||||
if (
|
||||
$map &&
|
||||
isSelected(
|
||||
$currentOverlays,
|
||||
$selectedOverlay.value
|
||||
)
|
||||
) {
|
||||
try {
|
||||
$map.removeImport($selectedOverlay.value);
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to remove sources and layers
|
||||
}
|
||||
}
|
||||
$opacities[$selectedOverlay.value] = value[0];
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
|
|
@ -46,9 +46,16 @@
|
|||
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} />
|
||||
<input
|
||||
id="{name}-{id}"
|
||||
type="radio"
|
||||
{name}
|
||||
value={id}
|
||||
bind:group={selected}
|
||||
/>
|
||||
{/if}
|
||||
<Label for="{name}-{id}" class="flex flex-row items-center gap-1">
|
||||
{#if $customLayers.hasOwnProperty(id)}
|
||||
|
@ -63,7 +70,13 @@
|
|||
<CollapsibleTreeNode {id}>
|
||||
<span slot="trigger">{$_(`layers.label.${id}`)}</span>
|
||||
<div slot="content">
|
||||
<svelte:self node={node[id]} {name} bind:selected {multiple} bind:checked={checked[id]} />
|
||||
<svelte:self
|
||||
node={node[id]}
|
||||
{name}
|
||||
bind:selected
|
||||
{multiple}
|
||||
bind:checked={checked[id]}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleTreeNode>
|
||||
{/if}
|
||||
|
|
|
@ -1,27 +1,17 @@
|
|||
import SphericalMercator from "@mapbox/sphericalmercator";
|
||||
import { getLayers } from "./utils";
|
||||
import mapboxgl from "mapbox-gl";
|
||||
import { get, writable } from "svelte/store";
|
||||
import { liveQuery } from "dexie";
|
||||
import { db, settings } from "$lib/db";
|
||||
import { overpassQueryData } from "$lib/assets/layers";
|
||||
import SphericalMercator from '@mapbox/sphericalmercator';
|
||||
import { getLayers } from './utils';
|
||||
import { get, writable } from 'svelte/store';
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db, settings } from '$lib/db';
|
||||
import { overpassQueryData } from '$lib/assets/layers';
|
||||
import { MapPopup } from '$lib/components/MapPopup';
|
||||
|
||||
const {
|
||||
currentOverpassQueries
|
||||
} = settings;
|
||||
const { currentOverpassQueries } = settings;
|
||||
|
||||
const mercator = new SphericalMercator({
|
||||
size: 256,
|
||||
});
|
||||
|
||||
export const overpassPopupPOI = writable<Record<string, any> | null>(null);
|
||||
|
||||
export const overpassPopup = new mapboxgl.Popup({
|
||||
closeButton: false,
|
||||
maxWidth: undefined,
|
||||
offset: 15,
|
||||
});
|
||||
|
||||
let data = writable<GeoJSON.FeatureCollection>({ type: 'FeatureCollection', features: [] });
|
||||
|
||||
liveQuery(() => db.overpassdata.toArray()).subscribe((pois) => {
|
||||
|
@ -34,28 +24,36 @@ export class OverpassLayer {
|
|||
queryZoom = 12;
|
||||
expirationTime = 7 * 24 * 3600 * 1000;
|
||||
map: mapboxgl.Map;
|
||||
popup: MapPopup;
|
||||
|
||||
currentQueries: Set<string> = new Set();
|
||||
nextQueries: Map<string, { x: number, y: number, queries: string[] }> = new Map();
|
||||
nextQueries: Map<string, { x: number; y: number; queries: string[] }> = new Map();
|
||||
|
||||
unsubscribes: (() => void)[] = [];
|
||||
queryIfNeededBinded = this.queryIfNeeded.bind(this);
|
||||
updateBinded = this.update.bind(this);
|
||||
onHoverBinded = this.onHover.bind(this);
|
||||
maybeHidePopupBinded = this.maybeHidePopup.bind(this);
|
||||
|
||||
constructor(map: mapboxgl.Map) {
|
||||
this.map = map;
|
||||
this.popup = new MapPopup(map, {
|
||||
closeButton: false,
|
||||
focusAfterOpen: false,
|
||||
maxWidth: undefined,
|
||||
offset: 15,
|
||||
});
|
||||
}
|
||||
|
||||
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.unsubscribes.push(
|
||||
currentOverpassQueries.subscribe(() => {
|
||||
this.updateBinded();
|
||||
this.queryIfNeededBinded();
|
||||
}));
|
||||
})
|
||||
);
|
||||
|
||||
this.update();
|
||||
}
|
||||
|
@ -108,9 +106,10 @@ export class OverpassLayer {
|
|||
|
||||
remove() {
|
||||
this.map.off('moveend', this.queryIfNeededBinded);
|
||||
this.map.off('style.load', this.updateBinded);
|
||||
this.map.off('style.import.load', this.updateBinded);
|
||||
this.unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
|
||||
try {
|
||||
if (this.map.getLayer('overpass')) {
|
||||
this.map.removeLayer('overpass');
|
||||
}
|
||||
|
@ -118,30 +117,18 @@ export class OverpassLayer {
|
|||
if (this.map.getSource('overpass')) {
|
||||
this.map.removeSource('overpass');
|
||||
}
|
||||
} catch (e) {
|
||||
// No reliable way to check if the map is ready to remove sources and layers
|
||||
}
|
||||
}
|
||||
|
||||
onHover(e: any) {
|
||||
overpassPopupPOI.set({
|
||||
this.popup.setItem({
|
||||
item: {
|
||||
...e.features[0].properties,
|
||||
sym: overpassQueryData[e.features[0].properties.query].symbol ?? ''
|
||||
sym: overpassQueryData[e.features[0].properties.query].symbol ?? '',
|
||||
},
|
||||
});
|
||||
overpassPopup.setLngLat(e.features[0].geometry.coordinates);
|
||||
overpassPopup.addTo(this.map);
|
||||
this.map.on('mousemove', this.maybeHidePopupBinded);
|
||||
}
|
||||
|
||||
maybeHidePopup(e: any) {
|
||||
let poi = get(overpassPopupPOI);
|
||||
if (poi && this.map.project([poi.lon, poi.lat]).dist(this.map.project(e.lngLat)) > 100) {
|
||||
this.hideWaypointPopup();
|
||||
}
|
||||
}
|
||||
|
||||
hideWaypointPopup() {
|
||||
overpassPopupPOI.set(null);
|
||||
overpassPopup.remove();
|
||||
|
||||
this.map.off('mousemove', this.maybeHidePopupBinded);
|
||||
}
|
||||
|
||||
query(bbox: [number, number, number, number]) {
|
||||
|
@ -159,8 +146,19 @@ export class OverpassLayer {
|
|||
continue;
|
||||
}
|
||||
|
||||
db.overpasstiles.where('[x+y]').equals([x, y]).toArray().then((querytiles) => {
|
||||
let missingQueries = queries.filter((query) => !querytiles.some((querytile) => querytile.query === query && time - querytile.time < this.expirationTime));
|
||||
db.overpasstiles
|
||||
.where('[x+y]')
|
||||
.equals([x, y])
|
||||
.toArray()
|
||||
.then((querytiles) => {
|
||||
let missingQueries = queries.filter(
|
||||
(query) =>
|
||||
!querytiles.some(
|
||||
(querytile) =>
|
||||
querytile.query === query &&
|
||||
time - querytile.time < this.expirationTime
|
||||
)
|
||||
);
|
||||
if (missingQueries.length > 0) {
|
||||
this.queryTile(x, y, missingQueries);
|
||||
}
|
||||
|
@ -178,13 +176,16 @@ export class OverpassLayer {
|
|||
|
||||
const bounds = mercator.bbox(x, y, this.queryZoom);
|
||||
fetch(`${this.overpassUrl}?data=${getQueryForBounds(bounds, queries)}`)
|
||||
.then((response) => {
|
||||
.then(
|
||||
(response) => {
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
}
|
||||
this.currentQueries.delete(`${x},${y}`);
|
||||
return Promise.reject();
|
||||
}, () => (this.currentQueries.delete(`${x},${y}`)))
|
||||
},
|
||||
() => this.currentQueries.delete(`${x},${y}`)
|
||||
)
|
||||
.then((data) => this.storeOverpassData(x, y, queries, data))
|
||||
.catch(() => this.currentQueries.delete(`${x},${y}`));
|
||||
}
|
||||
|
@ -192,7 +193,7 @@ export class OverpassLayer {
|
|||
storeOverpassData(x: number, y: number, queries: string[], data: any) {
|
||||
let time = Date.now();
|
||||
let queryTiles = queries.map((query) => ({ x, y, query, time }));
|
||||
let pois: { query: string, id: number, poi: GeoJSON.Feature }[] = [];
|
||||
let pois: { query: string; id: number; poi: GeoJSON.Feature }[] = [];
|
||||
|
||||
if (data.elements === undefined) {
|
||||
return;
|
||||
|
@ -208,7 +209,9 @@ export class OverpassLayer {
|
|||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: element.center ? [element.center.lon, element.center.lat] : [element.lon, element.lat],
|
||||
coordinates: element.center
|
||||
? [element.center.lon, element.center.lat]
|
||||
: [element.lon, element.lat],
|
||||
},
|
||||
properties: {
|
||||
id: element.id,
|
||||
|
@ -216,9 +219,9 @@ export class OverpassLayer {
|
|||
lon: element.center ? element.center.lon : element.lon,
|
||||
query: query,
|
||||
icon: `overpass-${query}`,
|
||||
tags: element.tags
|
||||
tags: element.tags,
|
||||
},
|
||||
},
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -241,11 +244,13 @@ export class OverpassLayer {
|
|||
if (!this.map.hasImage(`overpass-${query}`)) {
|
||||
this.map.addImage(`overpass-${query}`, icon);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Lucide icons are SVG files with a 24x24 viewBox
|
||||
// Create a new SVG with a 32x32 viewBox and center the icon in a circle
|
||||
icon.src = 'data:image/svg+xml,' + encodeURIComponent(`
|
||||
icon.src =
|
||||
'data:image/svg+xml,' +
|
||||
encodeURIComponent(`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
|
||||
<circle cx="20" cy="20" r="20" fill="${overpassQueryData[query].icon.color}" />
|
||||
<g transform="translate(8 8)">
|
||||
|
@ -277,9 +282,14 @@ function getQuery(query: string) {
|
|||
function getQueryItem(tags: Record<string, string | boolean | string[]>) {
|
||||
let arrayEntry = Object.entries(tags).find(([_, value]) => Array.isArray(value));
|
||||
if (arrayEntry !== undefined) {
|
||||
return arrayEntry[1].map((val) => `nwr${Object.entries(tags)
|
||||
return arrayEntry[1]
|
||||
.map(
|
||||
(val) =>
|
||||
`nwr${Object.entries(tags)
|
||||
.map(([tag, value]) => `[${tag}=${tag === arrayEntry[0] ? val : value}]`)
|
||||
.join('')};`).join('');
|
||||
.join('')};`
|
||||
)
|
||||
.join('');
|
||||
} else {
|
||||
return `nwr${Object.entries(tags)
|
||||
.map(([tag, value]) => `[${tag}=${value}]`)
|
||||
|
@ -296,8 +306,9 @@ function belongsToQuery(element: any, query: string) {
|
|||
}
|
||||
|
||||
function belongsToQueryItem(element: any, tags: Record<string, string | boolean | string[]>) {
|
||||
return Object.entries(tags)
|
||||
.every(([tag, value]) => Array.isArray(value) ? value.includes(element.tags[tag]) : element.tags[tag] === value);
|
||||
return Object.entries(tags).every(([tag, value]) =>
|
||||
Array.isArray(value) ? value.includes(element.tags[tag]) : element.tags[tag] === value
|
||||
);
|
||||
}
|
||||
|
||||
function getCurrentQueries() {
|
||||
|
@ -306,5 +317,7 @@ function getCurrentQueries() {
|
|||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(getLayers(currentQueries)).filter(([_, selected]) => selected).map(([query, _]) => query);
|
||||
return Object.entries(getLayers(currentQueries))
|
||||
.filter(([_, selected]) => selected)
|
||||
.map(([query, _]) => query);
|
||||
}
|
|
@ -1,48 +1,41 @@
|
|||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { overpassPopup, overpassPopupPOI } from './OverpassLayer';
|
||||
import { selection } from '$lib/components/file-list/Selection';
|
||||
import { PencilLine, MapPin } from 'lucide-svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { dbUtils } from '$lib/db';
|
||||
import type { PopupItem } from '$lib/components/MapPopup';
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||
|
||||
let popupElement: HTMLDivElement;
|
||||
|
||||
onMount(() => {
|
||||
overpassPopup.setDOMContent(popupElement);
|
||||
popupElement.classList.remove('hidden');
|
||||
});
|
||||
export let poi: PopupItem<any>;
|
||||
|
||||
let tags = {};
|
||||
let name = '';
|
||||
$: if ($overpassPopupPOI) {
|
||||
tags = JSON.parse($overpassPopupPOI.tags);
|
||||
$: if (poi) {
|
||||
tags = JSON.parse(poi.item.tags);
|
||||
if (tags.name !== undefined && tags.name !== '') {
|
||||
name = tags.name;
|
||||
} else {
|
||||
name = $_(`layers.label.${$overpassPopupPOI.query}`);
|
||||
name = $_(`layers.label.${poi.item.query}`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div bind:this={popupElement} class="hidden">
|
||||
{#if $overpassPopupPOI}
|
||||
<Card.Root class="border-none shadow-md text-base p-2 max-w-[50dvw]">
|
||||
<Card.Root class="border-none shadow-md text-base p-2 max-w-[50dvw]">
|
||||
<Card.Header class="p-0">
|
||||
<Card.Title class="text-md">
|
||||
<div class="flex flex-row gap-3">
|
||||
<div class="flex flex-col">
|
||||
{name}
|
||||
<div class="text-muted-foreground text-sm font-normal">
|
||||
{$overpassPopupPOI.lat.toFixed(6)}° {$overpassPopupPOI.lon.toFixed(6)}°
|
||||
{poi.item.lat.toFixed(6)}° {poi.item.lon.toFixed(6)}°
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
class="ml-auto p-1.5 h-8"
|
||||
variant="outline"
|
||||
href="https://www.openstreetmap.org/edit?editor=id&node={$overpassPopupPOI.id}"
|
||||
href="https://www.openstreetmap.org/edit?editor=id&node={poi.item.id}"
|
||||
target="_blank"
|
||||
>
|
||||
<PencilLine size="16" />
|
||||
|
@ -50,29 +43,31 @@
|
|||
</div>
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-col p-0 text-sm mt-1 whitespace-normal break-all">
|
||||
<ScrollArea class="flex flex-col" viewportClasses="max-h-[30dvh]">
|
||||
{#if tags.image || tags['image:0']}
|
||||
<div class="w-full rounded-md overflow-clip my-2 max-w-96 mx-auto">
|
||||
<!-- svelte-ignore a11y-missing-attribute -->
|
||||
<img src={tags.image ?? tags['image:0']} />
|
||||
</div>
|
||||
{/if}
|
||||
<Card.Content class="flex flex-col p-0 text-sm mt-1 whitespace-normal break-all">
|
||||
<div class="grid grid-cols-[auto_auto] gap-x-3">
|
||||
{#each Object.entries(tags) as [key, value]}
|
||||
{#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>
|
||||
{#if key === 'website' || key.startsWith('website:') || key === 'contact:website' || key === 'contact:facebook' || key === 'contact:instagram' || key === 'contact:twitter'}
|
||||
<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}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<Button
|
||||
class="mt-2"
|
||||
variant="outline"
|
||||
|
@ -83,13 +78,13 @@
|
|||
.join('\n');
|
||||
dbUtils.addOrUpdateWaypoint({
|
||||
attributes: {
|
||||
lat: $overpassPopupPOI.lat,
|
||||
lon: $overpassPopupPOI.lon
|
||||
lat: poi.item.lat,
|
||||
lon: poi.item.lon,
|
||||
},
|
||||
name: name,
|
||||
desc: desc,
|
||||
cmt: desc,
|
||||
sym: $overpassPopupPOI.sym
|
||||
sym: poi.item.sym,
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
@ -97,6 +92,4 @@
|
|||
{$_('toolbar.waypoint.add')}
|
||||
</Button>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
</div>
|
||||
</Card.Root>
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
import type { LayerTreeType } from "$lib/assets/layers";
|
||||
import type { LayerTreeType } from '$lib/assets/layers';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export function anySelectedLayer(node: LayerTreeType) {
|
||||
return Object.keys(node).find((id) => {
|
||||
if (typeof node[id] == "boolean") {
|
||||
return (
|
||||
Object.keys(node).find((id) => {
|
||||
if (typeof node[id] == 'boolean') {
|
||||
if (node[id]) {
|
||||
return true;
|
||||
}
|
||||
|
@ -12,12 +14,16 @@ export function anySelectedLayer(node: LayerTreeType) {
|
|||
}
|
||||
}
|
||||
return false;
|
||||
}) !== undefined;
|
||||
}) !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
export function getLayers(node: LayerTreeType, layers: { [key: string]: boolean } = {}): { [key: string]: boolean } {
|
||||
export function getLayers(
|
||||
node: LayerTreeType,
|
||||
layers: { [key: string]: boolean } = {}
|
||||
): { [key: string]: boolean } {
|
||||
Object.keys(node).forEach((id) => {
|
||||
if (typeof node[id] == "boolean") {
|
||||
if (typeof node[id] == 'boolean') {
|
||||
layers[id] = node[id];
|
||||
} else {
|
||||
getLayers(node[id], layers);
|
||||
|
@ -31,9 +37,22 @@ export function isSelected(node: LayerTreeType, id: string) {
|
|||
if (key === id) {
|
||||
return node[key];
|
||||
}
|
||||
if (typeof node[key] !== "boolean" && isSelected(node[key], id)) {
|
||||
if (typeof node[key] !== 'boolean' && isSelected(node[key], id)) {
|
||||
return true;
|
||||
}
|
||||
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,5 +1,5 @@
|
|||
import { resetCursor, setCrosshairCursor } from "$lib/utils";
|
||||
import type mapboxgl from "mapbox-gl";
|
||||
import { resetCursor, setCrosshairCursor } from '$lib/utils';
|
||||
import type mapboxgl from 'mapbox-gl';
|
||||
|
||||
export class GoogleRedirect {
|
||||
map: mapboxgl.Map;
|
||||
|
|
|
@ -1,16 +1,19 @@
|
|||
import mapboxgl from "mapbox-gl";
|
||||
import { Viewer } from 'mapillary-js/dist/mapillary.module';
|
||||
import mapboxgl, { type LayerSpecification, type VectorSourceSpecification } from 'mapbox-gl';
|
||||
import { Viewer, type ViewerBearingEvent } from 'mapillary-js/dist/mapillary.module';
|
||||
import 'mapillary-js/dist/mapillary.css';
|
||||
import { resetCursor, setPointerCursor } from "$lib/utils";
|
||||
import { resetCursor, setPointerCursor } from '$lib/utils';
|
||||
import type { Writable } from 'svelte/store';
|
||||
|
||||
const mapillarySource = {
|
||||
const mapillarySource: VectorSourceSpecification = {
|
||||
type: 'vector',
|
||||
tiles: ['https://tiles.mapillary.com/maps/vtp/mly1_computed_public/2/{z}/{x}/{y}?access_token=MLY|4381405525255083|3204871ec181638c3c31320490f03011'],
|
||||
tiles: [
|
||||
'https://tiles.mapillary.com/maps/vtp/mly1_computed_public/2/{z}/{x}/{y}?access_token=MLY|4381405525255083|3204871ec181638c3c31320490f03011',
|
||||
],
|
||||
minzoom: 6,
|
||||
maxzoom: 14,
|
||||
};
|
||||
|
||||
const mapillarySequenceLayer = {
|
||||
const mapillarySequenceLayer: LayerSpecification = {
|
||||
id: 'mapillary-sequence',
|
||||
type: 'line',
|
||||
source: 'mapillary',
|
||||
|
@ -26,7 +29,7 @@ const mapillarySequenceLayer = {
|
|||
},
|
||||
};
|
||||
|
||||
const mapillaryImageLayer = {
|
||||
const mapillaryImageLayer: LayerSpecification = {
|
||||
id: 'mapillary-image',
|
||||
type: 'circle',
|
||||
source: 'mapillary',
|
||||
|
@ -40,35 +43,56 @@ const mapillaryImageLayer = {
|
|||
|
||||
export class MapillaryLayer {
|
||||
map: mapboxgl.Map;
|
||||
popup: mapboxgl.Popup;
|
||||
marker: mapboxgl.Marker;
|
||||
viewer: Viewer;
|
||||
|
||||
active = false;
|
||||
popupOpen: Writable<boolean>;
|
||||
|
||||
addBinded = this.add.bind(this);
|
||||
onMouseEnterBinded = this.onMouseEnter.bind(this);
|
||||
onMouseLeaveBinded = this.onMouseLeave.bind(this);
|
||||
|
||||
constructor(map: mapboxgl.Map, container: HTMLElement) {
|
||||
constructor(map: mapboxgl.Map, container: HTMLElement, popupOpen: Writable<boolean>) {
|
||||
this.map = map;
|
||||
|
||||
this.viewer = new Viewer({
|
||||
accessToken: 'MLY|4381405525255083|3204871ec181638c3c31320490f03011',
|
||||
container,
|
||||
});
|
||||
container.classList.remove('hidden');
|
||||
|
||||
this.popup = new mapboxgl.Popup({
|
||||
closeButton: false,
|
||||
maxWidth: container.style.width,
|
||||
}).setDOMContent(container);
|
||||
const element = document.createElement('div');
|
||||
element.className = 'mapboxgl-user-location mapboxgl-user-location-show-heading';
|
||||
const dot = document.createElement('div');
|
||||
dot.className = 'mapboxgl-user-location-dot';
|
||||
const heading = document.createElement('div');
|
||||
heading.className = 'mapboxgl-user-location-heading';
|
||||
element.appendChild(dot);
|
||||
element.appendChild(heading);
|
||||
|
||||
this.marker = new mapboxgl.Marker({
|
||||
rotationAlignment: 'map',
|
||||
element,
|
||||
});
|
||||
|
||||
this.viewer.on('position', async () => {
|
||||
if (this.popup.isOpen()) {
|
||||
if (this.active) {
|
||||
popupOpen.set(true);
|
||||
let latLng = await this.viewer.getPosition();
|
||||
this.popup.setLngLat(latLng);
|
||||
if (!this.map.getBounds().contains(latLng)) {
|
||||
this.marker.setLngLat(latLng).addTo(this.map);
|
||||
if (!this.map.getBounds()?.contains(latLng)) {
|
||||
this.map.panTo(latLng);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.viewer.on('bearing', (e: ViewerBearingEvent) => {
|
||||
if (this.active) {
|
||||
this.marker.setRotation(e.bearing);
|
||||
}
|
||||
});
|
||||
|
||||
this.popupOpen = popupOpen;
|
||||
}
|
||||
|
||||
add() {
|
||||
|
@ -101,15 +125,19 @@ export class MapillaryLayer {
|
|||
this.map.removeSource('mapillary');
|
||||
}
|
||||
|
||||
this.popup.remove();
|
||||
this.marker.remove();
|
||||
this.popupOpen.set(false);
|
||||
}
|
||||
|
||||
closePopup() {
|
||||
this.popup.remove();
|
||||
this.active = false;
|
||||
this.marker.remove();
|
||||
this.popupOpen.set(false);
|
||||
}
|
||||
|
||||
onMouseEnter(e: mapboxgl.MapLayerMouseEvent) {
|
||||
this.popup.addTo(this.map).setLngLat(e.lngLat);
|
||||
onMouseEnter(e: mapboxgl.MapMouseEvent) {
|
||||
this.active = true;
|
||||
|
||||
this.viewer.resize();
|
||||
this.viewer.moveTo(e.features[0].properties.id);
|
||||
|
||||
|
|
|
@ -1,21 +1,25 @@
|
|||
<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';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
const { streetViewSource } = settings;
|
||||
|
||||
let googleRedirect: GoogleRedirect;
|
||||
let mapillaryLayer: MapillaryLayer;
|
||||
let mapillaryOpen = writable(false);
|
||||
let container: HTMLElement;
|
||||
|
||||
$: if ($map) {
|
||||
googleRedirect = new GoogleRedirect($map);
|
||||
mapillaryLayer = new MapillaryLayer($map, container);
|
||||
mapillaryLayer = new MapillaryLayer($map, container, mapillaryOpen);
|
||||
}
|
||||
|
||||
$: if (mapillaryLayer) {
|
||||
|
@ -38,14 +42,22 @@
|
|||
</script>
|
||||
|
||||
<CustomControl class="w-[29px] h-[29px] shrink-0">
|
||||
<Toggle bind:pressed={$streetViewEnabled} class="w-full h-full rounded p-0">
|
||||
<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
|
||||
bind:this={container}
|
||||
class="hidden relative w-[50vw] h-[40vh] rounded-md border-background border-2"
|
||||
class="{$mapillaryOpen
|
||||
? ''
|
||||
: 'hidden'} !absolute bottom-[44px] right-2.5 z-10 w-[40%] h-[40%] bg-background rounded-md overflow-hidden border-background border-2"
|
||||
>
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
|
|
|
@ -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';
|
||||
|
@ -23,7 +24,7 @@
|
|||
onMount(() => {
|
||||
popup = new mapboxgl.Popup({
|
||||
closeButton: false,
|
||||
maxWidth: undefined
|
||||
maxWidth: undefined,
|
||||
});
|
||||
popup.setDOMContent(popupElement);
|
||||
popupElement.classList.remove('hidden');
|
||||
|
@ -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}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts" context="module">
|
||||
enum CleanType {
|
||||
INSIDE = 'inside',
|
||||
OUTSIDE = 'outside'
|
||||
OUTSIDE = 'outside',
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -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';
|
||||
|
@ -41,10 +41,10 @@
|
|||
[rectangleCoordinates[1].lng, rectangleCoordinates[0].lat],
|
||||
[rectangleCoordinates[1].lng, rectangleCoordinates[1].lat],
|
||||
[rectangleCoordinates[0].lng, rectangleCoordinates[1].lat],
|
||||
[rectangleCoordinates[0].lng, rectangleCoordinates[0].lat]
|
||||
]
|
||||
]
|
||||
}
|
||||
[rectangleCoordinates[0].lng, rectangleCoordinates[0].lat],
|
||||
],
|
||||
],
|
||||
},
|
||||
};
|
||||
let source = $map.getSource('rectangle');
|
||||
if (source) {
|
||||
|
@ -52,7 +52,7 @@
|
|||
} else {
|
||||
$map.addSource('rectangle', {
|
||||
type: 'geojson',
|
||||
data: data
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
if (!$map.getLayer('rectangle')) {
|
||||
|
@ -62,8 +62,8 @@
|
|||
source: 'rectangle',
|
||||
paint: {
|
||||
'fill-color': 'SteelBlue',
|
||||
'fill-opacity': 0.5
|
||||
}
|
||||
'fill-opacity': 0.5,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -161,12 +161,12 @@
|
|||
[
|
||||
{
|
||||
lat: Math.min(rectangleCoordinates[0].lat, rectangleCoordinates[1].lat),
|
||||
lon: Math.min(rectangleCoordinates[0].lng, rectangleCoordinates[1].lng)
|
||||
lon: Math.min(rectangleCoordinates[0].lng, rectangleCoordinates[1].lng),
|
||||
},
|
||||
{
|
||||
lat: Math.max(rectangleCoordinates[0].lat, rectangleCoordinates[1].lat),
|
||||
lon: Math.max(rectangleCoordinates[0].lng, rectangleCoordinates[1].lng)
|
||||
}
|
||||
lon: Math.max(rectangleCoordinates[0].lng, rectangleCoordinates[1].lng),
|
||||
},
|
||||
],
|
||||
cleanType === CleanType.INSIDE,
|
||||
deleteTrackpoints,
|
||||
|
@ -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>
|
|
@ -7,11 +7,12 @@
|
|||
ListTrackItem,
|
||||
ListTrackSegmentItem,
|
||||
ListWaypointItem,
|
||||
ListWaypointsItem
|
||||
ListWaypointsItem,
|
||||
} 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}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts" context="module">
|
||||
enum MergeType {
|
||||
TRACES = 'traces',
|
||||
CONTENTS = 'contents'
|
||||
CONTENTS = 'contents',
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -11,13 +11,18 @@
|
|||
import { selection } from '$lib/components/file-list/Selection';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
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';
|
||||
import { gpxStatistics } from '$lib/stores';
|
||||
|
||||
let canMergeTraces = false;
|
||||
let canMergeContents = false;
|
||||
let removeGaps = false;
|
||||
|
||||
$: if ($selection.size > 1) {
|
||||
canMergeTraces = true;
|
||||
|
@ -54,35 +59,59 @@
|
|||
|
||||
<div class="flex flex-col gap-3 w-full max-w-80 {$$props.class ?? ''}">
|
||||
<RadioGroup.Root bind:value={mergeType}>
|
||||
<Label class="flex flex-row items-center gap-2 leading-5">
|
||||
<Label class="flex flex-row items-center gap-1.5 leading-5">
|
||||
<RadioGroup.Item value={MergeType.TRACES} />
|
||||
{$_('toolbar.merge.merge_traces')}
|
||||
</Label>
|
||||
<Label class="flex flex-row items-center gap-2 leading-5">
|
||||
<Label class="flex flex-row items-center gap-1.5 leading-5">
|
||||
<RadioGroup.Item value={MergeType.CONTENTS} />
|
||||
{$_('toolbar.merge.merge_contents')}
|
||||
</Label>
|
||||
</RadioGroup.Root>
|
||||
{#if mergeType === MergeType.TRACES && $gpxStatistics.global.time.total > 0}
|
||||
<div class="flex flex-row items-center gap-1.5">
|
||||
<Checkbox id="remove-gaps" bind:checked={removeGaps} />
|
||||
<Label for="remove-gaps">{$_('toolbar.merge.remove_gaps')}</Label>
|
||||
</div>
|
||||
{/if}
|
||||
<Button
|
||||
variant="outline"
|
||||
class="whitespace-normal h-fit"
|
||||
disabled={(mergeType === MergeType.TRACES && !canMergeTraces) ||
|
||||
(mergeType === MergeType.CONTENTS && !canMergeContents)}
|
||||
on:click={() => {
|
||||
dbUtils.mergeSelection(mergeType === MergeType.TRACES);
|
||||
dbUtils.mergeSelection(
|
||||
mergeType === MergeType.TRACES,
|
||||
mergeType === MergeType.TRACES && $gpxStatistics.global.time.total > 0 && removeGaps
|
||||
);
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue