mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2025-12-02 10:02:12 +00:00
Compare commits
457 Commits
2d232b3c4b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fe6565527 | ||
|
|
69b018022d | ||
|
|
467cb2e589 | ||
|
|
f13d8c1e22 | ||
|
|
e230d55b82 | ||
|
|
46fcdb4bb2 | ||
|
|
429212ef23 | ||
|
|
4ea0ea6a7a | ||
|
|
2e3ce83605 | ||
|
|
fda908dd0d | ||
|
|
cad77e2b10 | ||
|
|
3542a7c24d | ||
|
|
0d6d161e23 | ||
|
|
89a2e0086b | ||
|
|
cd443faf61 | ||
|
|
bfc56b02a8 | ||
|
|
25bafc6bf1 | ||
|
|
6387580626 | ||
|
|
09b8aa65fc | ||
|
|
6c15193f32 | ||
|
|
4442e29b66 | ||
|
|
b6f96d9f4d | ||
|
|
36b66100f9 | ||
|
|
49d8143cc6 | ||
|
|
fc279fecaf | ||
|
|
bd307daa57 | ||
|
|
7a72f44722 | ||
|
|
8e63fc6946 | ||
|
|
3a65f8dc16 | ||
|
|
d624352a0b | ||
|
|
3fb597a774 | ||
|
|
85e46bc524 | ||
|
|
d3790878b3 | ||
|
|
e648e50f1b | ||
|
|
cb6cede0b6 | ||
|
|
a0157ddc2a | ||
|
|
8f595fbc7b | ||
|
|
2e3b22c5fa | ||
|
|
42b968372b | ||
|
|
415e7b492f | ||
|
|
2ea8e46723 | ||
|
|
977c6c6dde | ||
|
|
1e5db9dc6c | ||
|
|
252dc10e61 | ||
|
|
f9e2315ba1 | ||
|
|
0eca588280 | ||
|
|
33523bbfb9 | ||
|
|
110f23bdf1 | ||
|
|
50a5cb23f5 | ||
|
|
30e72db5ea | ||
|
|
c4c64c8fe8 | ||
|
|
df39350d7d | ||
|
|
5daacd3ed4 | ||
|
|
0f7f64fb2f | ||
|
|
b09a1fdcb7 | ||
|
|
e5d45dee3a | ||
|
|
f3270e19df | ||
|
|
1b9ad41c87 | ||
|
|
8c3365ef24 | ||
|
|
db5cbffb70 | ||
|
|
c6586f0eed | ||
|
|
f40bdc8ed9 | ||
|
|
683ac4e118 | ||
|
|
88c9abb78e | ||
|
|
1729a2f734 | ||
|
|
e5ad8bbb70 | ||
|
|
7f6acbfdbc | ||
|
|
2e070529e0 | ||
|
|
f4b31e5f0a | ||
|
|
f7f093a464 | ||
|
|
95cc340de5 | ||
|
|
51a003c816 | ||
|
|
977152139f | ||
|
|
c6798dbcd5 | ||
|
|
78833df95e | ||
|
|
099d941d2e | ||
|
|
ea58f378a9 | ||
|
|
4060884909 | ||
|
|
d9277c11d2 | ||
|
|
bcbc90820a | ||
|
|
e9caa95673 | ||
|
|
9cd6703b05 | ||
|
|
4233bd7771 | ||
|
|
0e2db441f2 | ||
|
|
571b101ea4 | ||
|
|
0b9dca61ab | ||
|
|
d8fa76d076 | ||
|
|
6116eef513 | ||
|
|
fabe987f2c | ||
|
|
af20880f37 | ||
|
|
44eeab0d4b | ||
|
|
b331900158 | ||
|
|
d74380404c | ||
|
|
3833a9cd6b | ||
|
|
74fdd943c9 | ||
|
|
ad5b772502 | ||
|
|
9bc941aa31 | ||
|
|
705df43047 | ||
|
|
b31e3bb710 | ||
|
|
4082d0a368 | ||
|
|
ce85286cdf | ||
|
|
415cf1a777 | ||
|
|
f249919ec8 | ||
|
|
054c9787d3 | ||
|
|
c1e88e2b5a | ||
|
|
f5efeb16c4 | ||
|
|
59afae7bca | ||
|
|
92c6339064 | ||
|
|
582ae233f2 | ||
|
|
3c3016a211 | ||
|
|
e24e1d9d3c | ||
|
|
85fb564be7 | ||
|
|
18f7db9eee | ||
|
|
ea0770fd11 | ||
|
|
9c28fab3f9 | ||
|
|
bac332a8c4 | ||
|
|
86d2542bd9 | ||
|
|
5f63ec884f | ||
|
|
0f87b33354 | ||
|
|
c7dc99a12f | ||
|
|
05c3c3f8f3 | ||
|
|
2437a43471 | ||
|
|
16cd812ba0 | ||
|
|
c036128720 | ||
|
|
645db15848 | ||
|
|
21e142ffc3 | ||
|
|
23a6b3db72 | ||
|
|
b87d109625 | ||
|
|
651bd295b9 | ||
|
|
01607e92b9 | ||
|
|
f40d54adbb | ||
|
|
60fb387495 | ||
|
|
a7df18723c | ||
|
|
4e39cc937a | ||
|
|
fe513c17ab | ||
|
|
f7fb88ed3d | ||
|
|
b3247c7cbe | ||
|
|
d490dc0a8b | ||
|
|
97e79c23f6 | ||
|
|
7803a29875 | ||
|
|
08093beed1 | ||
|
|
ae46ae89bc | ||
|
|
f7977dca39 | ||
|
|
0b344f3e08 | ||
|
|
6058154eca | ||
|
|
941016f04b | ||
|
|
7a15898087 | ||
|
|
6b4effa90f | ||
|
|
77d56e4a4c | ||
|
|
c4dd622e7b | ||
|
|
9f24535142 | ||
|
|
a45161fcb8 | ||
|
|
4379763a73 | ||
|
|
a25b376dfd | ||
|
|
fb429f6777 | ||
|
|
ae33227c3a | ||
|
|
2fedc61d1e | ||
|
|
6988a36dd1 | ||
|
|
60e1124970 | ||
|
|
a007684006 | ||
|
|
090a709522 | ||
|
|
9e06655214 | ||
|
|
3651825e79 | ||
|
|
dcf3160b58 | ||
|
|
fd014f42cd | ||
|
|
a760f2f7fc | ||
|
|
262c114b7d | ||
|
|
c90bdd83bb | ||
|
|
fdb2d37b12 | ||
|
|
7951837ecf | ||
|
|
d2e112a672 | ||
|
|
90e86077ba | ||
|
|
f69dfcdefe | ||
|
|
5cff7c4e72 | ||
|
|
0eaa833cdf | ||
|
|
db015d925e | ||
|
|
69fd47601e | ||
|
|
17521574f0 | ||
|
|
56650a76ae | ||
|
|
984a98e792 | ||
|
|
49eb0cf202 | ||
|
|
8e24a6b036 | ||
|
|
d07bf6d699 | ||
|
|
877d12c676 | ||
|
|
ca629f625a | ||
|
|
087dd9a4b6 | ||
|
|
d9a967c072 | ||
|
|
6d522c82c3 | ||
|
|
867af98083 | ||
|
|
1be058d831 | ||
|
|
71bc044ae5 | ||
|
|
d660c50ade | ||
|
|
3fd733d903 | ||
|
|
7703b2361d | ||
|
|
68fdb9ebc6 | ||
|
|
b6513343be | ||
|
|
cc95ff1c55 | ||
|
|
c954ee0fde | ||
|
|
1ada5e5d18 | ||
|
|
f5244c3d93 | ||
|
|
1f17776cd4 | ||
|
|
ebd4f36f94 | ||
|
|
dbb9b5f254 | ||
|
|
4301472cb2 | ||
|
|
15e7954321 | ||
|
|
32d1de08e9 | ||
|
|
ea88663dce | ||
|
|
97aecaf890 | ||
|
|
b16688e1b7 | ||
|
|
5ac856251b | ||
|
|
62a9aacd85 | ||
|
|
dff0b55a8c | ||
|
|
3b21c75b13 | ||
|
|
2c7cc4b8e5 | ||
|
|
bcf8c0e35c | ||
|
|
11d2936fca | ||
|
|
5e84429e24 | ||
|
|
48c88b2d7e | ||
|
|
e5185c0b77 | ||
|
|
15773d3aba | ||
|
|
b577837446 | ||
|
|
324e234b2a | ||
|
|
d40fefb0ea | ||
|
|
7e05568549 | ||
|
|
0249a52d1c | ||
|
|
5df1c5b09b | ||
|
|
953ec8fe31 | ||
|
|
6054afebdc | ||
|
|
04f356e119 | ||
|
|
a6ebefbb30 | ||
|
|
9a1edbe1fa | ||
|
|
c46d74be54 | ||
|
|
72e949586a | ||
|
|
68dacad741 | ||
|
|
7e6505ca73 | ||
|
|
362d83f504 | ||
|
|
e4879736b7 | ||
|
|
68fe6628c7 | ||
|
|
f39ef569be | ||
|
|
42c199376a | ||
|
|
cd6b774a8c | ||
|
|
62598f94f8 | ||
|
|
aa3b46141f | ||
|
|
dcaa2aaeab | ||
|
|
e36f5e47da | ||
|
|
578d7b41b4 | ||
|
|
c4f81ce279 | ||
|
|
02a7dbea85 | ||
|
|
0305d3fe36 | ||
|
|
84fd034197 | ||
|
|
61b48e2048 | ||
|
|
c91f85389c | ||
|
|
f3683355a9 | ||
|
|
a86da886f4 | ||
|
|
7bf8d42eb8 | ||
|
|
88533e29b9 | ||
|
|
8299dcc881 | ||
|
|
0d0c250fea | ||
|
|
eb8d86616b | ||
|
|
1edc90810f | ||
|
|
c011d84a35 | ||
|
|
b97f62ac12 | ||
|
|
200a38bdc0 | ||
|
|
e12c53a90e | ||
|
|
7892916f56 | ||
|
|
5d681beab3 | ||
|
|
a54a2affb3 | ||
|
|
120062bb85 | ||
|
|
a0754d2bd7 | ||
|
|
f4b13a84d4 | ||
|
|
1c98e27e4d | ||
|
|
6067e25df8 | ||
|
|
304c50a247 | ||
|
|
274ad8eac2 | ||
|
|
9946a3fc0e | ||
|
|
622d6db968 | ||
|
|
d756ce0656 | ||
|
|
316e776355 | ||
|
|
f861b7ad99 | ||
|
|
01382e98f3 | ||
|
|
3371442bbc | ||
|
|
a81a804364 | ||
|
|
b24cf5c946 | ||
|
|
3d60213644 | ||
|
|
a23822c9df | ||
|
|
9fa69758f1 | ||
|
|
c892d3f134 | ||
|
|
73271501dc | ||
|
|
a62ea520e7 | ||
|
|
a2a0b3c71e | ||
|
|
51bedbe003 | ||
|
|
7062a3e657 | ||
|
|
209d95d5da | ||
|
|
95ee74ab2b | ||
|
|
9f6b268dce | ||
|
|
96e8ebcc10 | ||
|
|
ca261c7037 | ||
|
|
a95fe13b31 | ||
|
|
c3fe76adf9 | ||
|
|
e9b9d51a6e | ||
|
|
aff66205e9 | ||
|
|
aa0c9d65ae | ||
|
|
423bd2122f | ||
|
|
7533910114 | ||
|
|
0d4d377c22 | ||
|
|
3a576b3ea5 | ||
|
|
27f37744a0 | ||
|
|
012ecad0bb | ||
|
|
49b5d5e961 | ||
|
|
01ca48fe96 | ||
|
|
5abe5045b2 | ||
|
|
8c79a577b9 | ||
|
|
5b18f1016f | ||
|
|
bda58312bb | ||
|
|
eff352a003 | ||
|
|
3109d4ff82 | ||
|
|
c44eda3af6 | ||
|
|
33e8c41c6f | ||
|
|
e0b515ba2b | ||
|
|
c6b6ad41fb | ||
|
|
99576238cd | ||
|
|
db712650ef | ||
|
|
d77e7b07eb | ||
|
|
47b35fad3f | ||
|
|
720105ca92 | ||
|
|
ce656068e3 | ||
|
|
e69ba76e7d | ||
|
|
d1bab213a3 | ||
|
|
016a9341ad | ||
|
|
81f407ad5f | ||
|
|
37ff2f6bdb | ||
|
|
13cf1a9551 | ||
|
|
799aaac449 | ||
|
|
fbe430e4cf | ||
|
|
511746620e | ||
|
|
fed5b648f8 | ||
|
|
1f601743f6 | ||
|
|
db3ccf5da7 | ||
|
|
038898d580 | ||
|
|
dafbc6bc51 | ||
|
|
3d8271e7a1 | ||
|
|
e2ade95219 | ||
|
|
604971a576 | ||
|
|
76173103af | ||
|
|
3fcbc60232 | ||
|
|
835bdb39d0 | ||
|
|
200f09c5d2 | ||
|
|
99a008f122 | ||
|
|
9b71e89ba0 | ||
|
|
9955369cfa | ||
|
|
6ef6ffab66 | ||
|
|
0dd46f0f20 | ||
|
|
cce0d85dd4 | ||
|
|
1ed3eae038 | ||
|
|
2854c24197 | ||
|
|
4b90ddda2a | ||
|
|
552c39e583 | ||
|
|
5176e459b5 | ||
|
|
39515d0600 | ||
|
|
5314949394 | ||
|
|
59e5be749c | ||
|
|
da252a0070 | ||
|
|
b6199e430c | ||
|
|
e260a68c26 | ||
|
|
570cb2deaf | ||
|
|
7a36f03fb5 | ||
|
|
6c3058ba97 | ||
|
|
48a1034c12 | ||
|
|
aaddb50ab9 | ||
|
|
a947586cfe | ||
|
|
f3cfa14a59 | ||
|
|
a2c0a77c53 | ||
|
|
c078f9d5cb | ||
|
|
08eab8a157 | ||
|
|
9c36f234bc | ||
|
|
36b81d0e2a | ||
|
|
4432c14377 | ||
|
|
99e6dd5ca3 | ||
|
|
6327a25aec | ||
|
|
40989de7f5 | ||
|
|
58af44e795 | ||
|
|
4910cc05f8 | ||
|
|
164ee24d16 | ||
|
|
0ffda4ab7c | ||
|
|
4694a6271d | ||
|
|
2f50bc747a | ||
|
|
a13f621a81 | ||
|
|
0cc520cc67 | ||
|
|
c9451c3f2d | ||
|
|
8da53ffda2 | ||
|
|
4319761687 | ||
|
|
a1f3227cd9 | ||
|
|
b07f87c920 | ||
|
|
9c8f23eb64 | ||
|
|
36c6c623de | ||
|
|
e334419e24 | ||
|
|
01240c4f2a | ||
|
|
431a9ce827 | ||
|
|
20ab41c3b4 | ||
|
|
3f4ea27be2 | ||
|
|
5bb5b2f8c8 | ||
|
|
e9bb9e27bb | ||
|
|
ee1dd1fae7 | ||
|
|
738530a960 | ||
|
|
16023b0688 | ||
|
|
bce7b5984f | ||
|
|
4e5d7d391a | ||
|
|
0554a85e01 | ||
|
|
b2a5462372 | ||
|
|
9d61f51270 | ||
|
|
a0eb7d61cc | ||
|
|
9861b319f4 | ||
|
|
b04e0f10b2 | ||
|
|
e6d089b34b | ||
|
|
9df014e986 | ||
|
|
39b8d2e70d | ||
|
|
59710d2e1a | ||
|
|
ec3eb387e5 | ||
|
|
722cf58486 | ||
|
|
17e5347d55 | ||
|
|
2e828dfde3 | ||
|
|
1b035bcde3 | ||
|
|
30981130c9 | ||
|
|
6db8696a36 | ||
|
|
9c83dcafa7 | ||
|
|
1db9ecafef | ||
|
|
aa624e2c60 | ||
|
|
bde7e3e8aa | ||
|
|
b2b3e1b153 | ||
|
|
76b3d09320 | ||
|
|
899dcddd2e | ||
|
|
9edae7e1b8 | ||
|
|
d73b684999 | ||
|
|
a78bd8d7ca | ||
|
|
2ca53c1004 | ||
|
|
d621516d59 | ||
|
|
ef310cc3cc | ||
|
|
776c867c0b | ||
|
|
8abe0ec333 | ||
|
|
e57ced0ce7 | ||
|
|
117c46341b | ||
|
|
ba1ac69151 | ||
|
|
a8d3af35de | ||
|
|
307eed86e3 | ||
|
|
3f103323c7 | ||
|
|
05df3ca064 | ||
|
|
356884cf58 | ||
|
|
e68da7354e | ||
|
|
c59cd66141 | ||
|
|
9fa8fe5767 | ||
|
|
4ae0bc25c2 | ||
|
|
de81a8940e | ||
|
|
a73da0d81d | ||
|
|
0733562c0d | ||
|
|
1cc07901f6 | ||
|
|
f0230d4634 | ||
|
|
228ad1044e |
98
.github/workflows/deploy.yml
vendored
98
.github/workflows/deploy.yml
vendored
@@ -1,63 +1,63 @@
|
|||||||
name: Deploy to GitHub Pages
|
name: Deploy to GitHub Pages
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: 'main'
|
branches: 'main'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build_site:
|
build_site:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 24
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: |
|
cache-dependency-path: |
|
||||||
gpx/package-lock.json
|
gpx/package-lock.json
|
||||||
website/package-lock.json
|
website/package-lock.json
|
||||||
|
|
||||||
- name: Install dependencies for gpx
|
|
||||||
run: npm install --prefix gpx
|
|
||||||
|
|
||||||
- name: Build gpx
|
- name: Install dependencies for gpx
|
||||||
run: npm run build --prefix gpx
|
run: npm install --prefix gpx
|
||||||
|
|
||||||
- name: Install dependencies for website
|
- name: Build gpx
|
||||||
run: npm install --prefix website
|
run: npm run build --prefix gpx
|
||||||
|
|
||||||
- name: Create env file
|
- name: Install dependencies for website
|
||||||
run: |
|
run: npm install --prefix website
|
||||||
touch website/.env
|
|
||||||
echo PUBLIC_MAPBOX_TOKEN=${{ secrets.PUBLIC_MAPBOX_TOKEN }} >> website/.env
|
|
||||||
cat website/.env
|
|
||||||
|
|
||||||
- name: Build website
|
- name: Create env file
|
||||||
env:
|
run: |
|
||||||
BASE_PATH: ''
|
touch website/.env
|
||||||
run: |
|
echo PUBLIC_MAPBOX_TOKEN=${{ secrets.PUBLIC_MAPBOX_TOKEN }} >> website/.env
|
||||||
npm run build --prefix website
|
cat website/.env
|
||||||
|
|
||||||
- name: Upload Artifacts
|
- name: Build website
|
||||||
uses: actions/upload-pages-artifact@v3
|
env:
|
||||||
with:
|
BASE_PATH: ''
|
||||||
path: 'website/build/'
|
run: |
|
||||||
|
npm run build --prefix website
|
||||||
|
|
||||||
deploy:
|
- name: Upload Artifacts
|
||||||
needs: build_site
|
uses: actions/upload-pages-artifact@v4
|
||||||
runs-on: ubuntu-latest
|
with:
|
||||||
|
path: 'website/build/'
|
||||||
|
|
||||||
permissions:
|
deploy:
|
||||||
pages: write
|
needs: build_site
|
||||||
id-token: write
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
environment:
|
permissions:
|
||||||
name: github-pages
|
pages: write
|
||||||
url: ${{ steps.deployment.outputs.page_url }}
|
id-token: write
|
||||||
|
|
||||||
steps:
|
environment:
|
||||||
- name: Deploy
|
name: github-pages
|
||||||
id: deployment
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
uses: actions/deploy-pages@v4
|
|
||||||
|
steps:
|
||||||
|
- name: Deploy
|
||||||
|
id: deployment
|
||||||
|
uses: actions/deploy-pages@v4
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"files": "**/*.svelte",
|
"files": "**/*.svelte",
|
||||||
"options": {
|
"options": {
|
||||||
"plugins": ["prettier-plugin-svelte"],
|
"plugins": ["prettier-plugin-svelte"],
|
||||||
"parser": "svelte"
|
"parser": "svelte"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2024 gpx.studio
|
Copyright (c) 2025 gpx.studio
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
44
README.md
44
README.md
@@ -26,8 +26,9 @@ Any help is greatly appreciated!
|
|||||||
## Development
|
## Development
|
||||||
|
|
||||||
The code is split into two parts:
|
The code is split into two parts:
|
||||||
- `gpx`: a Typescript library for parsing and manipulating GPX files,
|
|
||||||
- `website`: the website itself, which is a [SvelteKit](https://kit.svelte.dev/) application.
|
- `gpx`: a Typescript library for parsing and manipulating GPX files,
|
||||||
|
- `website`: the website itself, which is a [SvelteKit](https://kit.svelte.dev/) application.
|
||||||
|
|
||||||
You will need [Node.js](https://nodejs.org/) to build and run these two parts.
|
You will need [Node.js](https://nodejs.org/) to build and run these two parts.
|
||||||
|
|
||||||
@@ -54,26 +55,25 @@ npm run dev
|
|||||||
|
|
||||||
This project has been made possible thanks to the following open source projects:
|
This project has been made possible thanks to the following open source projects:
|
||||||
|
|
||||||
- Development:
|
- Development:
|
||||||
- [Svelte](https://github.com/sveltejs/svelte) and [SvelteKit](https://github.com/sveltejs/kit) — seamless development experience
|
- [Svelte](https://github.com/sveltejs/svelte) and [SvelteKit](https://github.com/sveltejs/kit) — seamless development experience
|
||||||
- [MDsveX](https://github.com/pngwn/MDsveX) — allowing a Markdown-based documentation
|
- [MDsveX](https://github.com/pngwn/MDsveX) — allowing a Markdown-based documentation
|
||||||
- [svelte-i18n](https://github.com/kaisermann/svelte-i18n) — easy localization
|
- Design:
|
||||||
- Design:
|
- [shadcn-svelte](https://github.com/huntabyte/shadcn-svelte) — beautiful components
|
||||||
- [shadcn-svelte](https://github.com/huntabyte/shadcn-svelte) — beautiful components
|
- [@lucide/svelte](https://github.com/lucide-icons/lucide/tree/main/packages/svelte) — beautiful icons
|
||||||
- [lucide-svelte](https://github.com/lucide-icons/lucide/tree/main/packages/lucide-svelte) — beautiful icons
|
- [tailwindcss](https://github.com/tailwindlabs/tailwindcss) — easy styling
|
||||||
- [tailwindcss](https://github.com/tailwindlabs/tailwindcss) — easy styling
|
- [Chart.js](https://github.com/chartjs/Chart.js) — beautiful and fast charts
|
||||||
- [Chart.js](https://github.com/chartjs/Chart.js) — beautiful and fast charts
|
- Logic:
|
||||||
- Logic:
|
- [immer](https://github.com/immerjs/immer) — complex state management
|
||||||
- [immer](https://github.com/immerjs/immer) — complex state management
|
- [Dexie.js](https://github.com/dexie/Dexie.js) — IndexedDB wrapper
|
||||||
- [Dexie.js](https://github.com/dexie/Dexie.js) — IndexedDB wrapper
|
- [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) — fast GPX file parsing
|
||||||
- [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) — fast GPX file parsing
|
- [SortableJS](https://github.com/SortableJS/Sortable) — creating a sortable file tree
|
||||||
- [SortableJS](https://github.com/SortableJS/Sortable) — creating a sortable file tree
|
- Mapping:
|
||||||
- Mapping:
|
- [Mapbox GL JS](https://github.com/mapbox/mapbox-gl-js) — beautiful and fast interactive maps
|
||||||
- [Mapbox GL JS](https://github.com/mapbox/mapbox-gl-js) — beautiful and fast interactive maps
|
- [brouter](https://github.com/abrensch/brouter) — routing engine
|
||||||
- [brouter](https://github.com/abrensch/brouter) — routing engine
|
- [OpenStreetMap](https://www.openstreetmap.org) — map data used by Mapbox and brouter
|
||||||
- [OpenStreetMap](https://www.openstreetmap.org) — map data used by Mapbox and brouter
|
- Search:
|
||||||
- Search:
|
- [DocSearch](https://github.com/algolia/docsearch) — search engine for the documentation
|
||||||
- [DocSearch](https://github.com/algolia/docsearch) — search engine for the documentation
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
package-lock.json
|
|
||||||
246
gpx/src/gpx.ts
246
gpx/src/gpx.ts
@@ -17,6 +17,9 @@ import {
|
|||||||
import { immerable, isDraft, original, freeze } from 'immer';
|
import { immerable, isDraft, original, freeze } from 'immer';
|
||||||
|
|
||||||
function cloneJSON<T>(obj: T): T {
|
function cloneJSON<T>(obj: T): T {
|
||||||
|
if (obj === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
if (obj === null || typeof obj !== 'object') {
|
if (obj === null || typeof obj !== 'object') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -818,9 +821,6 @@ export class TrackSegment extends GPXTreeLeaf {
|
|||||||
|
|
||||||
statistics.local.points = this.trkpt.map((point) => point);
|
statistics.local.points = this.trkpt.map((point) => point);
|
||||||
|
|
||||||
statistics.local.elevation.smoothed = this._computeSmoothedElevation();
|
|
||||||
statistics.local.slope.at = this._computeSlope();
|
|
||||||
|
|
||||||
const points = this.trkpt;
|
const points = this.trkpt;
|
||||||
for (let i = 0; i < points.length; i++) {
|
for (let i = 0; i < points.length; i++) {
|
||||||
points[i]._data['index'] = i;
|
points[i]._data['index'] = i;
|
||||||
@@ -835,21 +835,6 @@ export class TrackSegment extends GPXTreeLeaf {
|
|||||||
|
|
||||||
statistics.local.distance.total.push(statistics.global.distance.total);
|
statistics.local.distance.total.push(statistics.global.distance.total);
|
||||||
|
|
||||||
// elevation
|
|
||||||
if (i > 0) {
|
|
||||||
const ele =
|
|
||||||
statistics.local.elevation.smoothed[i] -
|
|
||||||
statistics.local.elevation.smoothed[i - 1];
|
|
||||||
if (ele > 0) {
|
|
||||||
statistics.global.elevation.gain += ele;
|
|
||||||
} else if (ele < 0) {
|
|
||||||
statistics.global.elevation.loss -= ele;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
statistics.local.elevation.gain.push(statistics.global.elevation.gain);
|
|
||||||
statistics.local.elevation.loss.push(statistics.global.elevation.loss);
|
|
||||||
|
|
||||||
// time
|
// time
|
||||||
if (points[i].time === undefined) {
|
if (points[i].time === undefined) {
|
||||||
statistics.local.time.total.push(0);
|
statistics.local.time.total.push(0);
|
||||||
@@ -960,8 +945,7 @@ export class TrackSegment extends GPXTreeLeaf {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[statistics.local.slope.segment, statistics.local.slope.length] =
|
this._elevationComputation(statistics);
|
||||||
this._computeSlopeSegments(statistics);
|
|
||||||
|
|
||||||
statistics.global.time.total =
|
statistics.global.time.total =
|
||||||
statistics.global.time.start && statistics.global.time.end
|
statistics.global.time.start && statistics.global.time.end
|
||||||
@@ -977,59 +961,63 @@ export class TrackSegment extends GPXTreeLeaf {
|
|||||||
? statistics.global.distance.moving / (statistics.global.time.moving / 3600)
|
? statistics.global.distance.moving / (statistics.global.time.moving / 3600)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
statistics.local.speed = distanceWindowSmoothingWithDistanceAccumulator(
|
statistics.local.speed = timeWindowSmoothing(points, 10000, (start, end) =>
|
||||||
points,
|
points[start].time && points[end].time
|
||||||
200,
|
? (3600 *
|
||||||
(accumulated, start, end) =>
|
(statistics.local.distance.total[end] -
|
||||||
points[start].time && points[end].time
|
statistics.local.distance.total[start])) /
|
||||||
? (3600 * accumulated) /
|
Math.max((points[end].time.getTime() - points[start].time.getTime()) / 1000, 1)
|
||||||
(points[end].time.getTime() - points[start].time.getTime())
|
: undefined
|
||||||
: undefined
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return statistics;
|
return statistics;
|
||||||
}
|
}
|
||||||
|
|
||||||
_computeSmoothedElevation(): number[] {
|
_elevationComputation(statistics: GPXStatistics) {
|
||||||
const points = this.trkpt;
|
|
||||||
|
|
||||||
let smoothed = distanceWindowSmoothing(
|
|
||||||
points,
|
|
||||||
100,
|
|
||||||
(index) => points[index].ele ?? 0,
|
|
||||||
(accumulated, start, end) => accumulated / (end - start + 1)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (points.length > 0) {
|
|
||||||
smoothed[0] = points[0].ele ?? 0;
|
|
||||||
smoothed[points.length - 1] = points[points.length - 1].ele ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return smoothed;
|
|
||||||
}
|
|
||||||
|
|
||||||
_computeSlope(): number[] {
|
|
||||||
const points = this.trkpt;
|
|
||||||
|
|
||||||
return distanceWindowSmoothingWithDistanceAccumulator(
|
|
||||||
points,
|
|
||||||
50,
|
|
||||||
(accumulated, start, end) =>
|
|
||||||
(100 * ((points[end].ele ?? 0) - (points[start].ele ?? 0))) /
|
|
||||||
(accumulated > 0 ? accumulated : 1)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
_computeSlopeSegments(statistics: GPXStatistics): [number[], number[]] {
|
|
||||||
let simplified = ramerDouglasPeucker(
|
let simplified = ramerDouglasPeucker(
|
||||||
this.trkpt,
|
this.trkpt,
|
||||||
20,
|
20,
|
||||||
getElevationDistanceFunction(statistics)
|
getElevationDistanceFunction(statistics)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
for (let i = 0; i < simplified.length - 1; i++) {
|
||||||
|
let start = simplified[i].point._data.index;
|
||||||
|
let end = simplified[i + 1].point._data.index;
|
||||||
|
|
||||||
|
let cumulEle = 0;
|
||||||
|
let currentStart = start;
|
||||||
|
let currentEnd = start;
|
||||||
|
let smoothedEle = distanceWindowSmoothing(start, end + 1, statistics, 0.1, (s, e) => {
|
||||||
|
for (let i = currentStart; i < s; i++) {
|
||||||
|
cumulEle -= this.trkpt[i].ele ?? 0;
|
||||||
|
}
|
||||||
|
for (let i = currentEnd; i <= e; i++) {
|
||||||
|
cumulEle += this.trkpt[i].ele ?? 0;
|
||||||
|
}
|
||||||
|
currentStart = s;
|
||||||
|
currentEnd = e + 1;
|
||||||
|
return cumulEle / (e - s + 1);
|
||||||
|
});
|
||||||
|
smoothedEle[0] = this.trkpt[start].ele ?? 0;
|
||||||
|
smoothedEle[smoothedEle.length - 1] = this.trkpt[end].ele ?? 0;
|
||||||
|
|
||||||
|
for (let j = start; j < end; j++) {
|
||||||
|
statistics.local.elevation.gain.push(statistics.global.elevation.gain);
|
||||||
|
statistics.local.elevation.loss.push(statistics.global.elevation.loss);
|
||||||
|
|
||||||
|
const ele = smoothedEle[j - start + 1] - smoothedEle[j - start];
|
||||||
|
if (ele > 0) {
|
||||||
|
statistics.global.elevation.gain += ele;
|
||||||
|
} else if (ele < 0) {
|
||||||
|
statistics.global.elevation.loss -= ele;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
statistics.local.elevation.gain.push(statistics.global.elevation.gain);
|
||||||
|
statistics.local.elevation.loss.push(statistics.global.elevation.loss);
|
||||||
|
|
||||||
let slope = [];
|
let slope = [];
|
||||||
let length = [];
|
let length = [];
|
||||||
|
|
||||||
for (let i = 0; i < simplified.length - 1; i++) {
|
for (let i = 0; i < simplified.length - 1; i++) {
|
||||||
let start = simplified[i].point._data.index;
|
let start = simplified[i].point._data.index;
|
||||||
let end = simplified[i + 1].point._data.index;
|
let end = simplified[i + 1].point._data.index;
|
||||||
@@ -1043,7 +1031,20 @@ export class TrackSegment extends GPXTreeLeaf {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [slope, length];
|
statistics.local.slope.segment = slope;
|
||||||
|
statistics.local.slope.length = length;
|
||||||
|
statistics.local.slope.at = distanceWindowSmoothing(
|
||||||
|
0,
|
||||||
|
this.trkpt.length,
|
||||||
|
statistics,
|
||||||
|
0.05,
|
||||||
|
(start, end) => {
|
||||||
|
const ele = this.trkpt[end].ele - this.trkpt[start].ele || 0;
|
||||||
|
const dist =
|
||||||
|
statistics.local.distance.total[end] - statistics.local.distance.total[start];
|
||||||
|
return dist > 0 ? (0.1 * ele) / dist : 0;
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getNumberOfTrackPoints(): number {
|
getNumberOfTrackPoints(): number {
|
||||||
@@ -1290,8 +1291,14 @@ export class TrackSegment extends GPXTreeLeaf {
|
|||||||
lastPoint: TrackPoint | undefined
|
lastPoint: TrackPoint | undefined
|
||||||
) {
|
) {
|
||||||
let og = getOriginal(this); // Read as much as possible from the original object because it is faster
|
let og = getOriginal(this); // Read as much as possible from the original object because it is faster
|
||||||
let slope = og._computeSlope();
|
let statistics = og._computeStatistics();
|
||||||
let trkpt = withArtificialTimestamps(og.trkpt, totalTime, lastPoint, startTime, slope);
|
let trkpt = withArtificialTimestamps(
|
||||||
|
og.trkpt,
|
||||||
|
totalTime,
|
||||||
|
lastPoint,
|
||||||
|
startTime,
|
||||||
|
statistics.local.slope.at
|
||||||
|
);
|
||||||
this.trkpt = freeze(trkpt); // Pre-freeze the array, faster as well
|
this.trkpt = freeze(trkpt); // Pre-freeze the array, faster as well
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1488,12 +1495,18 @@ export class Waypoint {
|
|||||||
this.attributes = waypoint.attributes;
|
this.attributes = waypoint.attributes;
|
||||||
this.ele = waypoint.ele;
|
this.ele = waypoint.ele;
|
||||||
this.time = waypoint.time;
|
this.time = waypoint.time;
|
||||||
this.name = waypoint.name;
|
this.name = waypoint.name === '' ? undefined : waypoint.name;
|
||||||
this.cmt = waypoint.cmt;
|
this.cmt = waypoint.cmt === '' ? undefined : waypoint.cmt;
|
||||||
this.desc = waypoint.desc;
|
this.desc = waypoint.desc === '' ? undefined : waypoint.desc;
|
||||||
this.link = waypoint.link;
|
this.link =
|
||||||
this.sym = waypoint.sym;
|
!waypoint.link ||
|
||||||
this.type = waypoint.type;
|
!waypoint.link.attributes ||
|
||||||
|
!waypoint.link.attributes.href ||
|
||||||
|
waypoint.link.attributes.href === ''
|
||||||
|
? undefined
|
||||||
|
: waypoint.link;
|
||||||
|
this.sym = waypoint.sym === '' ? undefined : waypoint.sym;
|
||||||
|
this.type = waypoint.type === '' ? undefined : waypoint.type;
|
||||||
if (waypoint.hasOwnProperty('_data')) {
|
if (waypoint.hasOwnProperty('_data')) {
|
||||||
this._data = waypoint._data;
|
this._data = waypoint._data;
|
||||||
}
|
}
|
||||||
@@ -1647,7 +1660,6 @@ export class GPXStatistics {
|
|||||||
};
|
};
|
||||||
speed: number[];
|
speed: number[];
|
||||||
elevation: {
|
elevation: {
|
||||||
smoothed: number[];
|
|
||||||
gain: number[];
|
gain: number[];
|
||||||
loss: number[];
|
loss: number[];
|
||||||
};
|
};
|
||||||
@@ -1718,7 +1730,6 @@ export class GPXStatistics {
|
|||||||
},
|
},
|
||||||
speed: [],
|
speed: [],
|
||||||
elevation: {
|
elevation: {
|
||||||
smoothed: [],
|
|
||||||
gain: [],
|
gain: [],
|
||||||
loss: [],
|
loss: [],
|
||||||
},
|
},
|
||||||
@@ -1753,9 +1764,6 @@ export class GPXStatistics {
|
|||||||
);
|
);
|
||||||
|
|
||||||
this.local.speed = this.local.speed.concat(other.local.speed);
|
this.local.speed = this.local.speed.concat(other.local.speed);
|
||||||
this.local.elevation.smoothed = this.local.elevation.smoothed.concat(
|
|
||||||
other.local.elevation.smoothed
|
|
||||||
);
|
|
||||||
this.local.slope.at = this.local.slope.at.concat(other.local.slope.at);
|
this.local.slope.at = this.local.slope.at.concat(other.local.slope.at);
|
||||||
this.local.slope.segment = this.local.slope.segment.concat(other.local.slope.segment);
|
this.local.slope.segment = this.local.slope.segment.concat(other.local.slope.segment);
|
||||||
this.local.slope.length = this.local.slope.length.concat(other.local.slope.length);
|
this.local.slope.length = this.local.slope.length.concat(other.local.slope.length);
|
||||||
@@ -1911,11 +1919,15 @@ export function distance(
|
|||||||
const rad = Math.PI / 180;
|
const rad = Math.PI / 180;
|
||||||
const lat1 = coord1.lat * rad;
|
const lat1 = coord1.lat * rad;
|
||||||
const lat2 = coord2.lat * rad;
|
const lat2 = coord2.lat * rad;
|
||||||
|
const dLat = lat2 - lat1;
|
||||||
|
const dLon = (coord2.lon - coord1.lon) * rad;
|
||||||
|
|
||||||
|
// Haversine formula - better numerical stability for small distances
|
||||||
const a =
|
const a =
|
||||||
Math.sin(lat1) * Math.sin(lat2) +
|
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||||
Math.cos(lat1) * Math.cos(lat2) * Math.cos((coord2.lon - coord1.lon) * rad);
|
Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
||||||
const maxMeters = earthRadius * Math.acos(Math.min(a, 1));
|
const c = 2 * Math.asin(Math.sqrt(Math.min(a, 1)));
|
||||||
return maxMeters;
|
return earthRadius * c;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getElevationDistanceFunction(statistics: GPXStatistics) {
|
export function getElevationDistanceFunction(statistics: GPXStatistics) {
|
||||||
@@ -1942,57 +1954,59 @@ export function getElevationDistanceFunction(statistics: GPXStatistics) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function distanceWindowSmoothing(
|
function windowSmoothing(
|
||||||
points: TrackPoint[],
|
left: number,
|
||||||
distanceWindow: number,
|
right: number,
|
||||||
accumulate: (index: number) => number,
|
distance: (index1: number, index2: number) => number,
|
||||||
compute: (accumulated: number, start: number, end: number) => number,
|
window: number,
|
||||||
remove?: (index: number) => number
|
compute: (start: number, end: number) => number
|
||||||
): number[] {
|
): number[] {
|
||||||
let result = [];
|
let result = [];
|
||||||
|
|
||||||
let start = 0,
|
let start = left;
|
||||||
end = 0,
|
for (var i = left; i < right; i++) {
|
||||||
accumulated = 0;
|
while (start + 1 < i && distance(start, i) > window) {
|
||||||
for (var i = 0; i < points.length; i++) {
|
|
||||||
while (
|
|
||||||
start + 1 < i &&
|
|
||||||
distance(points[start].getCoordinates(), points[i].getCoordinates()) > distanceWindow
|
|
||||||
) {
|
|
||||||
if (remove) {
|
|
||||||
accumulated -= remove(start);
|
|
||||||
} else {
|
|
||||||
accumulated -= accumulate(start);
|
|
||||||
}
|
|
||||||
start++;
|
start++;
|
||||||
}
|
}
|
||||||
while (
|
let end = Math.min(i + 2, right);
|
||||||
end < points.length &&
|
while (end < right && distance(i, end) <= window) {
|
||||||
distance(points[i].getCoordinates(), points[end].getCoordinates()) <= distanceWindow
|
|
||||||
) {
|
|
||||||
accumulated += accumulate(end);
|
|
||||||
end++;
|
end++;
|
||||||
}
|
}
|
||||||
result[i] = compute(accumulated, start, end - 1);
|
result.push(compute(start, end - 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function distanceWindowSmoothingWithDistanceAccumulator(
|
function distanceWindowSmoothing(
|
||||||
points: TrackPoint[],
|
left: number,
|
||||||
distanceWindow: number,
|
right: number,
|
||||||
compute: (accumulated: number, start: number, end: number) => number
|
statistics: GPXStatistics,
|
||||||
|
window: number,
|
||||||
|
compute: (start: number, end: number) => number
|
||||||
): number[] {
|
): number[] {
|
||||||
return distanceWindowSmoothing(
|
return windowSmoothing(
|
||||||
points,
|
left,
|
||||||
distanceWindow,
|
right,
|
||||||
(index) =>
|
(index1, index2) =>
|
||||||
index > 0
|
statistics.local.distance.total[index2] - statistics.local.distance.total[index1],
|
||||||
? distance(points[index - 1].getCoordinates(), points[index].getCoordinates())
|
window,
|
||||||
: 0,
|
compute
|
||||||
compute,
|
);
|
||||||
(index) => distance(points[index].getCoordinates(), points[index + 1].getCoordinates())
|
}
|
||||||
|
|
||||||
|
function timeWindowSmoothing(
|
||||||
|
points: TrackPoint[],
|
||||||
|
window: number,
|
||||||
|
compute: (start: number, end: number) => number
|
||||||
|
): number[] {
|
||||||
|
return windowSmoothing(
|
||||||
|
0,
|
||||||
|
points.length,
|
||||||
|
(index1, index2) =>
|
||||||
|
points[index2].time?.getTime() - points[index1].time?.getTime() || 2 * window,
|
||||||
|
window,
|
||||||
|
compute
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ 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(
|
export function ramerDouglasPeucker(
|
||||||
points: TrackPoint[],
|
points: TrackPoint[],
|
||||||
epsilon: number = 50,
|
epsilon: number = 50,
|
||||||
@@ -72,65 +70,45 @@ export function crossarcDistance(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const metersPerLatitudeDegree = 111320;
|
||||||
|
|
||||||
|
function getMetersPerLongitudeDegree(latitude: number): number {
|
||||||
|
return Math.cos((latitude * Math.PI) / 180) * metersPerLatitudeDegree;
|
||||||
|
}
|
||||||
|
|
||||||
function crossarc(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates): number {
|
function crossarc(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates): number {
|
||||||
// Calculates the shortest distance in meters
|
// Calculates the perpendicular distance in meters
|
||||||
// between an arc (defined by p1 and p2) and a third point, p3.
|
// between a line segment (defined by p1 and p2) and a third point, p3.
|
||||||
// Input lat1,lon1,lat2,lon2,lat3,lon3 in degrees.
|
// Uses simple planar geometry (ignores earth curvature).
|
||||||
|
|
||||||
const rad = Math.PI / 180;
|
// Convert to meters using approximate scaling
|
||||||
const lat1 = coord1.lat * rad;
|
const metersPerLongitudeDegree = getMetersPerLongitudeDegree(coord1.lat);
|
||||||
const lat2 = coord2.lat * rad;
|
|
||||||
const lat3 = coord3.lat * rad;
|
|
||||||
|
|
||||||
const lon1 = coord1.lon * rad;
|
const x1 = coord1.lon * metersPerLongitudeDegree;
|
||||||
const lon2 = coord2.lon * rad;
|
const y1 = coord1.lat * metersPerLatitudeDegree;
|
||||||
const lon3 = coord3.lon * rad;
|
const x2 = coord2.lon * metersPerLongitudeDegree;
|
||||||
|
const y2 = coord2.lat * metersPerLatitudeDegree;
|
||||||
|
const x3 = coord3.lon * metersPerLongitudeDegree;
|
||||||
|
const y3 = coord3.lat * metersPerLatitudeDegree;
|
||||||
|
|
||||||
// Prerequisites for the formulas
|
const dx = x2 - x1;
|
||||||
const bear12 = bearing(lat1, lon1, lat2, lon2);
|
const dy = y2 - y1;
|
||||||
const bear13 = bearing(lat1, lon1, lat3, lon3);
|
const segmentLengthSquared = dx * dx + dy * dy;
|
||||||
let dis13 = distance(lat1, lon1, lat3, lon3);
|
|
||||||
|
|
||||||
let diff = Math.abs(bear13 - bear12);
|
if (segmentLengthSquared === 0) {
|
||||||
if (diff > Math.PI) {
|
// p1 and p2 are the same point
|
||||||
diff = 2 * Math.PI - diff;
|
return Math.sqrt((x3 - x1) * (x3 - x1) + (y3 - y1) * (y3 - y1));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Is relative bearing obtuse?
|
// Project p3 onto the line defined by p1-p2
|
||||||
if (diff > Math.PI / 2) {
|
const t = Math.max(0, Math.min(1, ((x3 - x1) * dx + (y3 - y1) * dy) / segmentLengthSquared));
|
||||||
return dis13;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the cross-track distance.
|
// Find the closest point on the segment
|
||||||
let dxt = Math.asin(Math.sin(dis13 / earthRadius) * Math.sin(bear13 - bear12)) * earthRadius;
|
const projX = x1 + t * dx;
|
||||||
|
const projY = y1 + t * dy;
|
||||||
|
|
||||||
// Is p4 beyond the arc?
|
// Return distance from p3 to the projected point
|
||||||
let dis12 = distance(lat1, lon1, lat2, lon2);
|
return Math.sqrt((x3 - projX) * (x3 - projX) + (y3 - projY) * (y3 - projY));
|
||||||
let dis14 =
|
|
||||||
Math.acos(Math.cos(dis13 / earthRadius) / Math.cos(dxt / earthRadius)) * earthRadius;
|
|
||||||
if (dis14 > dis12) {
|
|
||||||
return distance(lat2, lon2, lat3, lon3);
|
|
||||||
} else {
|
|
||||||
return Math.abs(dxt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function distance(latA: number, lonA: number, latB: number, lonB: number): number {
|
|
||||||
// Finds the distance between two lat / lon points.
|
|
||||||
return (
|
|
||||||
Math.acos(
|
|
||||||
Math.sin(latA) * Math.sin(latB) +
|
|
||||||
Math.cos(latA) * Math.cos(latB) * Math.cos(lonB - lonA)
|
|
||||||
) * earthRadius
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function bearing(latA: number, lonA: number, latB: number, lonB: number): number {
|
|
||||||
// Finds the bearing from one lat / lon point to another.
|
|
||||||
return Math.atan2(
|
|
||||||
Math.sin(lonB - lonA) * Math.cos(latB),
|
|
||||||
Math.cos(latA) * Math.sin(latB) - Math.sin(latA) * Math.cos(latB) * Math.cos(lonB - lonA)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function projectedPoint(
|
export function projectedPoint(
|
||||||
@@ -146,56 +124,39 @@ export function projectedPoint(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function projected(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates): Coordinates {
|
function projected(coord1: Coordinates, coord2: Coordinates, coord3: Coordinates): Coordinates {
|
||||||
// Calculates the point on the line defined by p1 and p2
|
// Calculates the point on the line segment defined by p1 and p2
|
||||||
// that is closest to the third point, p3.
|
// that is closest to the third point, p3.
|
||||||
// Input lat1,lon1,lat2,lon2,lat3,lon3 in degrees.
|
// Uses simple planar geometry (ignores earth curvature).
|
||||||
|
|
||||||
const rad = Math.PI / 180;
|
// Convert to meters using approximate scaling
|
||||||
const lat1 = coord1.lat * rad;
|
const metersPerLongitudeDegree = getMetersPerLongitudeDegree(coord1.lat);
|
||||||
const lat2 = coord2.lat * rad;
|
|
||||||
const lat3 = coord3.lat * rad;
|
|
||||||
|
|
||||||
const lon1 = coord1.lon * rad;
|
const x1 = coord1.lon * metersPerLongitudeDegree;
|
||||||
const lon2 = coord2.lon * rad;
|
const y1 = coord1.lat * metersPerLatitudeDegree;
|
||||||
const lon3 = coord3.lon * rad;
|
const x2 = coord2.lon * metersPerLongitudeDegree;
|
||||||
|
const y2 = coord2.lat * metersPerLatitudeDegree;
|
||||||
|
const x3 = coord3.lon * metersPerLongitudeDegree;
|
||||||
|
const y3 = coord3.lat * metersPerLatitudeDegree;
|
||||||
|
|
||||||
// Prerequisites for the formulas
|
const dx = x2 - x1;
|
||||||
const bear12 = bearing(lat1, lon1, lat2, lon2);
|
const dy = y2 - y1;
|
||||||
const bear13 = bearing(lat1, lon1, lat3, lon3);
|
const segmentLengthSquared = dx * dx + dy * dy;
|
||||||
let dis13 = distance(lat1, lon1, lat3, lon3);
|
|
||||||
|
|
||||||
let diff = Math.abs(bear13 - bear12);
|
if (segmentLengthSquared === 0) {
|
||||||
if (diff > Math.PI) {
|
// p1 and p2 are the same point
|
||||||
diff = 2 * Math.PI - diff;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Is relative bearing obtuse?
|
|
||||||
if (diff > Math.PI / 2) {
|
|
||||||
return coord1;
|
return coord1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the cross-track distance.
|
// Project p3 onto the line defined by p1-p2
|
||||||
let dxt = Math.asin(Math.sin(dis13 / earthRadius) * Math.sin(bear13 - bear12)) * earthRadius;
|
const t = Math.max(0, Math.min(1, ((x3 - x1) * dx + (y3 - y1) * dy) / segmentLengthSquared));
|
||||||
|
|
||||||
// Is p4 beyond the arc?
|
// Find the closest point on the segment
|
||||||
let dis12 = distance(lat1, lon1, lat2, lon2);
|
const projX = x1 + t * dx;
|
||||||
let dis14 =
|
const projY = y1 + t * dy;
|
||||||
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 };
|
// Convert back to degrees
|
||||||
}
|
return {
|
||||||
|
lat: projY / metersPerLatitudeDegree,
|
||||||
|
lon: projX / metersPerLongitudeDegree,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
2
website/.gitignore
vendored
2
website/.gitignore
vendored
@@ -8,3 +8,5 @@ node_modules
|
|||||||
!.env.example
|
!.env.example
|
||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
|
static/*.webmanifest
|
||||||
|
!static/en.manifest.webmanifest
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://shadcn-svelte.com/schema.json",
|
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||||
"style": "default",
|
"style": "default",
|
||||||
"tailwind": {
|
"tailwind": {
|
||||||
"config": "tailwind.config.js",
|
"css": "src/app.css",
|
||||||
"css": "src/app.css",
|
"baseColor": "slate"
|
||||||
"baseColor": "slate"
|
},
|
||||||
},
|
"aliases": {
|
||||||
"aliases": {
|
"components": "$lib/components",
|
||||||
"components": "$lib/components",
|
"utils": "$lib/utils",
|
||||||
"utils": "$lib/utils"
|
"ui": "$lib/components/ui",
|
||||||
},
|
"hooks": "$lib/hooks",
|
||||||
"typescript": true
|
"lib": "$lib"
|
||||||
|
},
|
||||||
|
"typescript": true,
|
||||||
|
"registry": "https://shadcn-svelte.com/registry"
|
||||||
}
|
}
|
||||||
|
|||||||
6469
website/package-lock.json
generated
6469
website/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -5,8 +5,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"prebuild": "npx tsx src/lib/pwa-manifest.ts",
|
"prebuild": "npx tsx src/lib/scripts/pwa-manifest.ts",
|
||||||
"postbuild": "npx tsx src/lib/sitemap.ts",
|
"postbuild": "npx tsx src/lib/scripts/sitemap.ts",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
@@ -14,69 +14,71 @@
|
|||||||
"format": "prettier --write ."
|
"format": "prettier --write ."
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-auto": "^3.2.5",
|
"@lucide/svelte": "^0.544.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.5",
|
"@sveltejs/adapter-static": "^3.0.8",
|
||||||
"@sveltejs/enhanced-img": "^0.3.8",
|
"@sveltejs/enhanced-img": "^0.6.0",
|
||||||
"@sveltejs/kit": "^2.6.1",
|
"@sveltejs/kit": "^2.21.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^3.1.2",
|
"@sveltejs/vite-plugin-svelte": "^5.1.0",
|
||||||
"@types/eslint": "^8.56.12",
|
"@tailwindcss/vite": "^4.1.8",
|
||||||
|
"@types/eslint": "^9.6.1",
|
||||||
"@types/events": "^3.0.3",
|
"@types/events": "^3.0.3",
|
||||||
"@types/file-saver": "^2.0.7",
|
"@types/file-saver": "^2.0.7",
|
||||||
"@types/mapbox__tilebelt": "^1.0.4",
|
"@types/mapbox__tilebelt": "^1.0.4",
|
||||||
"@types/mapbox-gl": "^3.4.0",
|
"@types/mapbox-gl": "^3.4.1",
|
||||||
"@types/node": "^20.16.10",
|
"@types/node": "^22.15.30",
|
||||||
"@types/png.js": "^0.2.3",
|
"@types/png.js": "^0.2.3",
|
||||||
"@types/sanitize-html": "^2.13.0",
|
"@types/sanitize-html": "^2.16.0",
|
||||||
"@types/sortablejs": "^1.15.8",
|
"@types/sortablejs": "^1.15.8",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
"@typescript-eslint/eslint-plugin": "^8.33.1",
|
||||||
"@typescript-eslint/parser": "^7.18.0",
|
"@typescript-eslint/parser": "^8.33.1",
|
||||||
"autoprefixer": "^10.4.20",
|
"bits-ui": "^2.12.0",
|
||||||
"eslint": "^8.57.1",
|
"eslint": "^9.28.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^10.1.5",
|
||||||
"eslint-plugin-svelte": "^2.44.1",
|
"eslint-plugin-svelte": "^3.9.1",
|
||||||
"events": "^3.3.0",
|
"events": "^3.3.0",
|
||||||
"glob": "^10.4.5",
|
"glob": "^11.0.2",
|
||||||
|
"lucide-static": "^0.513.0",
|
||||||
"mdsvex": "^0.12.6",
|
"mdsvex": "^0.12.6",
|
||||||
|
"mode-watcher": "^1.1.0",
|
||||||
|
"paneforge": "^1.0.0-next.5",
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.47",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.5.3",
|
||||||
"prettier-plugin-svelte": "^3.2.7",
|
"prettier-plugin-svelte": "^3.4.0",
|
||||||
"svelte": "^4.2.19",
|
"svelte": "^5.33.18",
|
||||||
"svelte-check": "^3.8.6",
|
"svelte-check": "^4.0.0",
|
||||||
"tailwindcss": "^3.4.13",
|
"svelte-dnd-action": "^0.9.65",
|
||||||
"tslib": "^2.7.0",
|
"svelte-sonner": "^1.0.5",
|
||||||
|
"tailwind-variants": "^3.1.1",
|
||||||
|
"tailwindcss": "^4.1.8",
|
||||||
|
"tslib": "^2.8.1",
|
||||||
"tsx": "^4.19.1",
|
"tsx": "^4.19.1",
|
||||||
"typescript": "^5.6.2",
|
"tw-animate-css": "^1.3.4",
|
||||||
"vite": "^5.4.8",
|
"typescript": "^5.8.3",
|
||||||
"vite-plugin-node-polyfills": "^0.22.0"
|
"vaul-svelte": "^1.0.0-next.7",
|
||||||
|
"vite": "^6.3.5",
|
||||||
|
"vite-plugin-node-polyfills": "^0.23.0"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@docsearch/js": "^3.6.2",
|
"@docsearch/js": "^3.9.0",
|
||||||
"@internationalized/date": "^3.5.5",
|
"@internationalized/date": "^3.8.2",
|
||||||
"@mapbox/mapbox-gl-geocoder": "^5.0.3",
|
"@mapbox/mapbox-gl-geocoder": "^5.0.3",
|
||||||
"@mapbox/sphericalmercator": "^1.2.0",
|
"@mapbox/sphericalmercator": "^2.0.1",
|
||||||
"@mapbox/tilebelt": "^1.0.2",
|
"@mapbox/tilebelt": "^2.0.2",
|
||||||
"@types/mapbox__sphericalmercator": "^1.2.3",
|
"@types/mapbox__sphericalmercator": "^1.2.3",
|
||||||
"bits-ui": "^0.21.15",
|
"chart.js": "^4.4.9",
|
||||||
"chart.js": "^4.4.4",
|
"chartjs-plugin-zoom": "^2.2.0",
|
||||||
"chartjs-plugin-zoom": "^2.0.1",
|
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dexie": "^4.0.8",
|
"dexie": "^4.0.11",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"gpx": "file:../gpx",
|
"gpx": "file:../gpx",
|
||||||
"immer": "^10.1.1",
|
"immer": "^10.1.1",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"lucide-static": "^0.460.0",
|
"mapbox-gl": "^3.16.0",
|
||||||
"lucide-svelte": "^0.460.1",
|
|
||||||
"mapbox-gl": "^3.11.1",
|
|
||||||
"mapillary-js": "^4.1.2",
|
"mapillary-js": "^4.1.2",
|
||||||
"mode-watcher": "^0.3.1",
|
|
||||||
"png.js": "^0.2.1",
|
"png.js": "^0.2.1",
|
||||||
"sanitize-html": "^2.13.0",
|
"sanitize-html": "^2.17.0",
|
||||||
"sortablejs": "^1.15.3",
|
"sortablejs": "^1.15.6",
|
||||||
"svelte-i18n": "^4.0.0",
|
"tailwind-merge": "^3.3.0"
|
||||||
"svelte-sonner": "^0.3.28",
|
|
||||||
"tailwind-merge": "^2.5.2",
|
|
||||||
"tailwind-variants": "^0.2.1"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
126
website/src/app.css
Normal file
126
website/src/app.css
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: hsl(0 0% 100%) /* <- Wrap in HSL */;
|
||||||
|
--foreground: hsl(240 10% 3.9%);
|
||||||
|
--muted: hsl(240 4.8% 95.9%);
|
||||||
|
--muted-foreground: hsl(240 3.8% 46.1%);
|
||||||
|
--popover: hsl(0 0% 100%);
|
||||||
|
--popover-foreground: hsl(240 10% 3.9%);
|
||||||
|
--card: hsl(0 0% 100%);
|
||||||
|
--card-foreground: hsl(240 10% 3.9%);
|
||||||
|
--border: hsl(240 5.9% 90%);
|
||||||
|
--input: hsl(240 5.9% 90%);
|
||||||
|
--primary: hsl(240 5.9% 10%);
|
||||||
|
--primary-foreground: hsl(0 0% 98%);
|
||||||
|
--secondary: hsl(240 4.8% 95.9%);
|
||||||
|
--secondary-foreground: hsl(240 5.9% 10%);
|
||||||
|
--accent: hsl(240 4.8% 95.9%);
|
||||||
|
--accent-foreground: hsl(240 5.9% 10%);
|
||||||
|
--destructive: hsl(0 72.2% 50.6%);
|
||||||
|
--destructive-foreground: hsl(0 0% 98%);
|
||||||
|
--ring: hsl(240 10% 3.9%);
|
||||||
|
--sidebar: hsl(0 0% 98%);
|
||||||
|
--sidebar-foreground: hsl(240 5.3% 26.1%);
|
||||||
|
--sidebar-primary: hsl(240 5.9% 10%);
|
||||||
|
--sidebar-primary-foreground: hsl(0 0% 98%);
|
||||||
|
--sidebar-accent: hsl(240 4.8% 95.9%);
|
||||||
|
--sidebar-accent-foreground: hsl(240 5.9% 10%);
|
||||||
|
--sidebar-border: hsl(220 13% 91%);
|
||||||
|
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||||
|
|
||||||
|
--support: rgb(220 15 130);
|
||||||
|
--link: rgb(0 110 180);
|
||||||
|
--selection: hsl(240 4.8% 93%);
|
||||||
|
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: hsl(240 10% 3.9%);
|
||||||
|
--foreground: hsl(0 0% 98%);
|
||||||
|
--muted: hsl(240 3.7% 15.9%);
|
||||||
|
--muted-foreground: hsl(240 5% 64.9%);
|
||||||
|
--popover: hsl(240 10% 3.9%);
|
||||||
|
--popover-foreground: hsl(0 0% 98%);
|
||||||
|
--card: hsl(240 10% 3.9%);
|
||||||
|
--card-foreground: hsl(0 0% 98%);
|
||||||
|
--border: hsl(240 3.7% 15.9%);
|
||||||
|
--input: hsl(240 3.7% 15.9%);
|
||||||
|
--primary: hsl(0 0% 98%);
|
||||||
|
--primary-foreground: hsl(240 5.9% 10%);
|
||||||
|
--secondary: hsl(240 3.7% 15.9%);
|
||||||
|
--secondary-foreground: hsl(0 0% 98%);
|
||||||
|
--accent: hsl(240 3.7% 15.9%);
|
||||||
|
--accent-foreground: hsl(0 0% 98%);
|
||||||
|
--destructive: hsl(0 62.8% 30.6%);
|
||||||
|
--destructive-foreground: hsl(0 0% 98%);
|
||||||
|
--ring: hsl(240 4.9% 83.9%);
|
||||||
|
--sidebar: hsl(240 5.9% 10%);
|
||||||
|
--sidebar-foreground: hsl(240 4.8% 95.9%);
|
||||||
|
--sidebar-primary: hsl(224.3 76.3% 48%);
|
||||||
|
--sidebar-primary-foreground: hsl(0 0% 100%);
|
||||||
|
--sidebar-accent: hsl(240 3.7% 15.9%);
|
||||||
|
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
|
||||||
|
--sidebar-border: hsl(240 3.7% 15.9%);
|
||||||
|
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||||
|
|
||||||
|
--support: rgb(255 110 190);
|
||||||
|
--link: rgb(80 190 255);
|
||||||
|
--selection: hsl(240 3.7% 22%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
/* Radius (for rounded-*) */
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
|
||||||
|
/* Colors */
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-radius: var(--radius);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-support: var(--support);
|
||||||
|
--color-link: var(--link);
|
||||||
|
|
||||||
|
--breakpoint-xs: 540px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
:root {
|
|
||||||
--background: 0 0% 100%;
|
|
||||||
--foreground: 222.2 84% 4.9%;
|
|
||||||
|
|
||||||
--muted: 210 40% 96.1%;
|
|
||||||
--muted-foreground: 215.4 16.3% 45%;
|
|
||||||
|
|
||||||
--popover: 0 0% 100%;
|
|
||||||
--popover-foreground: 222.2 84% 4.9%;
|
|
||||||
|
|
||||||
--card: 0 0% 100%;
|
|
||||||
--card-foreground: 222.2 84% 4.9%;
|
|
||||||
|
|
||||||
--border: 214.3 31.8% 91.4%;
|
|
||||||
--input: 214.3 31.8% 91.4%;
|
|
||||||
|
|
||||||
--primary: 222.2 47.4% 11.2%;
|
|
||||||
--primary-foreground: 210 40% 98%;
|
|
||||||
|
|
||||||
--secondary: 210 40% 96.1%;
|
|
||||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
|
||||||
|
|
||||||
--accent: 210 40% 92%;
|
|
||||||
--accent-foreground: 222.2 47.4% 11.2%;
|
|
||||||
|
|
||||||
--destructive: 0 72.2% 50.6%;
|
|
||||||
--destructive-foreground: 210 40% 98%;
|
|
||||||
|
|
||||||
--support: 220 15 130;
|
|
||||||
|
|
||||||
--link: 0 110 180;
|
|
||||||
|
|
||||||
--ring: 222.2 84% 4.9%;
|
|
||||||
|
|
||||||
--radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
|
||||||
--background: 222.2 84% 4.9%;
|
|
||||||
--foreground: 210 40% 98%;
|
|
||||||
|
|
||||||
--muted: 217.2 32.6% 17.5%;
|
|
||||||
--muted-foreground: 215 20.2% 65.1%;
|
|
||||||
|
|
||||||
--popover: 222.2 84% 4.9%;
|
|
||||||
--popover-foreground: 210 40% 98%;
|
|
||||||
|
|
||||||
--card: 222.2 84% 4.9%;
|
|
||||||
--card-foreground: 210 40% 98%;
|
|
||||||
|
|
||||||
--border: 217.2 32.6% 17.5%;
|
|
||||||
--input: 217.2 32.6% 17.5%;
|
|
||||||
|
|
||||||
--primary: 210 40% 98%;
|
|
||||||
--primary-foreground: 222.2 47.4% 11.2%;
|
|
||||||
|
|
||||||
--secondary: 217.2 32.6% 17.5%;
|
|
||||||
--secondary-foreground: 210 40% 98%;
|
|
||||||
|
|
||||||
--accent: 217.2 32.6% 30%;
|
|
||||||
--accent-foreground: 210 40% 98%;
|
|
||||||
|
|
||||||
--destructive: 0 62.8% 30.6%;
|
|
||||||
--destructive-foreground: 210 40% 98%;
|
|
||||||
|
|
||||||
--support: 255 110 190;
|
|
||||||
|
|
||||||
--link: 80 190 255;
|
|
||||||
|
|
||||||
--ring: hsl(212.7, 26.8%, 83.9);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
* {
|
|
||||||
@apply border-border;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
@apply bg-background text-foreground;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -41,9 +41,11 @@ export async function handle({ event, resolve }) {
|
|||||||
<link rel="alternate" hreflang="x-default" href="https://gpx.studio${getURLForLanguage('en', path)}" />
|
<link rel="alternate" hreflang="x-default" href="https://gpx.studio${getURLForLanguage('en', path)}" />
|
||||||
<link rel="manifest" href="/${language}.manifest.webmanifest" />`;
|
<link rel="manifest" href="/${language}.manifest.webmanifest" />`;
|
||||||
|
|
||||||
for (let lang of Object.keys(languages)) {
|
if (page !== '404') {
|
||||||
headTag += ` <link rel="alternate" hreflang="${lang}" href="https://gpx.studio${getURLForLanguage(lang, 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, {
|
const response = await resolve(event, {
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ export const basemaps: { [key: string]: string | StyleSpecification } = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
utagawaVTT: 'https://maps.utagawavtt.com/styles/utagawavtt/style.json',
|
||||||
swisstopoRaster: {
|
swisstopoRaster: {
|
||||||
version: 8,
|
version: 8,
|
||||||
sources: {
|
sources: {
|
||||||
@@ -151,11 +152,12 @@ export const basemaps: { [key: string]: string | StyleSpecification } = {
|
|||||||
linzTopo: {
|
linzTopo: {
|
||||||
type: 'raster',
|
type: 'raster',
|
||||||
tiles: [
|
tiles: [
|
||||||
'https://tiles-cdn.koordinates.com/services;key=39a8b989633a4bef98bc0e065380454a/tiles/v4/layer=50767/EPSG:3857/{z}/{x}/{y}.png',
|
'https://basemaps.linz.govt.nz/v1/tiles/topo-raster/WebMercatorQuad/{z}/{x}/{y}.webp?api=d01fbtg0ar23gctac5m0jgyy2ds',
|
||||||
],
|
],
|
||||||
tileSize: 256,
|
tileSize: 256,
|
||||||
maxzoom: 18,
|
maxzoom: 16,
|
||||||
attribution: '© <a href="https://www.linz.govt.nz/" target="_blank">LINZ</a>',
|
attribution:
|
||||||
|
'© <a href="//www.linz.govt.nz/linz-copyright">LINZ CC BY 4.0</a> © <a href="//www.linz.govt.nz/data/linz-data/linz-basemaps/data-attribution">Imagery Basemap contributors</a>',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
layers: [
|
layers: [
|
||||||
@@ -185,8 +187,8 @@ export const basemaps: { [key: string]: string | StyleSpecification } = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
ignFrPlan: ignFrPlan,
|
ignFrPlan: ignFrPlan as StyleSpecification,
|
||||||
ignFrTopo: ignFrTopo,
|
ignFrTopo: ignFrTopo as StyleSpecification,
|
||||||
ignFrScan25: {
|
ignFrScan25: {
|
||||||
version: 8,
|
version: 8,
|
||||||
sources: {
|
sources: {
|
||||||
@@ -208,7 +210,7 @@ export const basemaps: { [key: string]: string | StyleSpecification } = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
ignFrSatellite: ignFrSatellite,
|
ignFrSatellite: ignFrSatellite as StyleSpecification,
|
||||||
ignEs: {
|
ignEs: {
|
||||||
version: 8,
|
version: 8,
|
||||||
sources: {
|
sources: {
|
||||||
@@ -275,68 +277,6 @@ export const basemaps: { [key: string]: string | StyleSpecification } = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
swedenTopo: {
|
|
||||||
version: 8,
|
|
||||||
sources: {
|
|
||||||
swedenTopoWMTS: {
|
|
||||||
type: 'raster',
|
|
||||||
tiles: [
|
|
||||||
'https://api.lantmateriet.se/open/topowebb-ccby/v1/wmts/token/1d54dd14-a28c-38a9-b6f3-b4ebfcc3c204/1.0.0/topowebb/default/3857/{z}/{y}/{x}.png',
|
|
||||||
],
|
|
||||||
tileSize: 256,
|
|
||||||
maxzoom: 14,
|
|
||||||
attribution:
|
|
||||||
'© <a href="https://www.lantmateriet.se" target="_blank">Lantmäteriet</a>',
|
|
||||||
},
|
|
||||||
swedenTopoWMS: {
|
|
||||||
type: 'raster',
|
|
||||||
tiles: [
|
|
||||||
'https://minkarta.lantmateriet.se/map/topowebb?REQUEST=GetMap&SERVICE=WMS&VERSION=1.1.1&FORMAT=image%2Fpng&STYLES=&TRANSPARENT=false&LAYERS=topowebbkartan&TILED=true&MAP_RESOLUTION=180&WIDTH=512&HEIGHT=512&SRS=EPSG%3A3857&BBOX={bbox-epsg-3857}',
|
|
||||||
],
|
|
||||||
tileSize: 512,
|
|
||||||
minzoom: 14,
|
|
||||||
maxzoom: 20,
|
|
||||||
attribution:
|
|
||||||
'© <a href="https://www.lantmateriet.se" target="_blank">Lantmäteriet</a>',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
layers: [
|
|
||||||
{
|
|
||||||
id: 'swedenTopoWMTS',
|
|
||||||
type: 'raster',
|
|
||||||
source: 'swedenTopoWMTS',
|
|
||||||
maxzoom: 14,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'swedenTopoWMS',
|
|
||||||
type: 'raster',
|
|
||||||
source: 'swedenTopoWMS',
|
|
||||||
minzoom: 14,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
swedenSatellite: {
|
|
||||||
version: 8,
|
|
||||||
sources: {
|
|
||||||
swedenSatellite: {
|
|
||||||
type: 'raster',
|
|
||||||
tiles: [
|
|
||||||
'https://minkarta.lantmateriet.se/map/ortofoto?REQUEST=GetMap&SERVICE=WMS&VERSION=1.1.1&FORMAT=image%2Fpng&STYLES=&TRANSPARENT=false&LAYERS=Ortofoto_0.5%2COrtofoto_0.4%2COrtofoto_0.25%2COrtofoto_0.16&TILED=true&MAP_RESOLUTION=180&WIDTH=512&HEIGHT=512&SRS=EPSG%3A3857&BBOX={bbox-epsg-3857}',
|
|
||||||
],
|
|
||||||
tileSize: 512,
|
|
||||||
maxzoom: 22,
|
|
||||||
attribution:
|
|
||||||
'© <a href="https://www.lantmateriet.se" target="_blank">Lantmäteriet</a>',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
layers: [
|
|
||||||
{
|
|
||||||
id: 'swedenSatellite',
|
|
||||||
type: 'raster',
|
|
||||||
source: 'swedenSatellite',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
finlandTopo: {
|
finlandTopo: {
|
||||||
version: 8,
|
version: 8,
|
||||||
sources: {
|
sources: {
|
||||||
@@ -427,7 +367,7 @@ export const overlays: { [key: string]: string | StyleSpecification } = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
bikerouterGravel: bikerouterGravel,
|
bikerouterGravel: bikerouterGravel as StyleSpecification,
|
||||||
swisstopoSlope: {
|
swisstopoSlope: {
|
||||||
version: 8,
|
version: 8,
|
||||||
sources: {
|
sources: {
|
||||||
@@ -803,6 +743,7 @@ export const basemapTree: LayerTreeType = {
|
|||||||
openTopoMap: true,
|
openTopoMap: true,
|
||||||
openHikingMap: true,
|
openHikingMap: true,
|
||||||
cyclOSM: true,
|
cyclOSM: true,
|
||||||
|
utagawaVTT: true,
|
||||||
},
|
},
|
||||||
countries: {
|
countries: {
|
||||||
belgium: {
|
belgium: {
|
||||||
@@ -831,10 +772,6 @@ export const basemapTree: LayerTreeType = {
|
|||||||
ignEs: true,
|
ignEs: true,
|
||||||
ignEsSatellite: true,
|
ignEsSatellite: true,
|
||||||
},
|
},
|
||||||
sweden: {
|
|
||||||
swedenTopo: true,
|
|
||||||
swedenSatellite: true,
|
|
||||||
},
|
|
||||||
switzerland: {
|
switzerland: {
|
||||||
swisstopoRaster: true,
|
swisstopoRaster: true,
|
||||||
swisstopoVector: true,
|
swisstopoVector: true,
|
||||||
@@ -1023,6 +960,7 @@ export const defaultBasemapTree: LayerTreeType = {
|
|||||||
openTopoMap: true,
|
openTopoMap: true,
|
||||||
openHikingMap: true,
|
openHikingMap: true,
|
||||||
cyclOSM: true,
|
cyclOSM: true,
|
||||||
|
utagawaVTT: true,
|
||||||
},
|
},
|
||||||
countries: {
|
countries: {
|
||||||
belgium: {
|
belgium: {
|
||||||
@@ -1051,10 +989,6 @@ export const defaultBasemapTree: LayerTreeType = {
|
|||||||
ignEs: false,
|
ignEs: false,
|
||||||
ignEsSatellite: false,
|
ignEsSatellite: false,
|
||||||
},
|
},
|
||||||
sweden: {
|
|
||||||
swedenTopo: false,
|
|
||||||
swedenSatellite: false,
|
|
||||||
},
|
|
||||||
switzerland: {
|
switzerland: {
|
||||||
swisstopoRaster: false,
|
swisstopoRaster: false,
|
||||||
swisstopoVector: false,
|
swisstopoVector: false,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
DoorOpen,
|
DoorOpen,
|
||||||
Trees,
|
Trees,
|
||||||
Fuel,
|
Fuel,
|
||||||
Home,
|
House,
|
||||||
Info,
|
Info,
|
||||||
TreeDeciduous,
|
TreeDeciduous,
|
||||||
CircleParking,
|
CircleParking,
|
||||||
@@ -29,7 +29,8 @@ import {
|
|||||||
TriangleAlert,
|
TriangleAlert,
|
||||||
Anchor,
|
Anchor,
|
||||||
Toilet,
|
Toilet,
|
||||||
} from 'lucide-svelte';
|
type IconProps,
|
||||||
|
} from '@lucide/svelte';
|
||||||
import {
|
import {
|
||||||
Landmark as LandmarkSvg,
|
Landmark as LandmarkSvg,
|
||||||
Shell as ShellSvg,
|
Shell as ShellSvg,
|
||||||
@@ -43,7 +44,7 @@ import {
|
|||||||
DoorOpen as DoorOpenSvg,
|
DoorOpen as DoorOpenSvg,
|
||||||
Trees as TreesSvg,
|
Trees as TreesSvg,
|
||||||
Fuel as FuelSvg,
|
Fuel as FuelSvg,
|
||||||
Home as HomeSvg,
|
House as HouseSvg,
|
||||||
Info as InfoSvg,
|
Info as InfoSvg,
|
||||||
TreeDeciduous as TreeDeciduousSvg,
|
TreeDeciduous as TreeDeciduousSvg,
|
||||||
CircleParking as CircleParkingSvg,
|
CircleParking as CircleParkingSvg,
|
||||||
@@ -61,11 +62,11 @@ import {
|
|||||||
Anchor as AnchorSvg,
|
Anchor as AnchorSvg,
|
||||||
Toilet as ToiletSvg,
|
Toilet as ToiletSvg,
|
||||||
} from 'lucide-static';
|
} from 'lucide-static';
|
||||||
import type { ComponentType } from 'svelte';
|
import type { Component } from 'svelte';
|
||||||
|
|
||||||
export type Symbol = {
|
export type Symbol = {
|
||||||
value: string;
|
value: string;
|
||||||
icon?: ComponentType<Icon>;
|
icon?: Component<IconProps>;
|
||||||
iconSvg?: string;
|
iconSvg?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -94,7 +95,7 @@ export const symbols: { [key: string]: Symbol } = {
|
|||||||
},
|
},
|
||||||
drinking_water: { value: 'Drinking Water', icon: Droplet, iconSvg: DropletSvg },
|
drinking_water: { value: 'Drinking Water', icon: Droplet, iconSvg: DropletSvg },
|
||||||
exit: { value: 'Exit', icon: DoorOpen, iconSvg: DoorOpenSvg },
|
exit: { value: 'Exit', icon: DoorOpen, iconSvg: DoorOpenSvg },
|
||||||
lodge: { value: 'Lodge', icon: Home, iconSvg: HomeSvg },
|
lodge: { value: 'Lodge', icon: House, iconSvg: HouseSvg },
|
||||||
lodging: { value: 'Lodging', icon: Bed, iconSvg: BedSvg },
|
lodging: { value: 'Lodging', icon: Bed, iconSvg: BedSvg },
|
||||||
forest: { value: 'Forest', icon: Trees, iconSvg: TreesSvg },
|
forest: { value: 'Forest', icon: Trees, iconSvg: TreesSvg },
|
||||||
gas_station: { value: 'Gas Station', icon: Fuel, iconSvg: FuelSvg },
|
gas_station: { value: 'Gas Station', icon: Fuel, iconSvg: FuelSvg },
|
||||||
@@ -104,7 +105,7 @@ export const symbols: { [key: string]: Symbol } = {
|
|||||||
iconSvg: TrainFrontSvg,
|
iconSvg: TrainFrontSvg,
|
||||||
},
|
},
|
||||||
hotel: { value: 'Hotel', icon: Bed, iconSvg: BedSvg },
|
hotel: { value: 'Hotel', icon: Bed, iconSvg: BedSvg },
|
||||||
house: { value: 'House', icon: Home, iconSvg: HomeSvg },
|
house: { value: 'House', icon: House, iconSvg: HouseSvg },
|
||||||
information: { value: 'Information', icon: Info, iconSvg: InfoSvg },
|
information: { value: 'Information', icon: Info, iconSvg: InfoSvg },
|
||||||
park: { value: 'Park', icon: TreeDeciduous, iconSvg: TreeDeciduousSvg },
|
park: { value: 'Park', icon: TreeDeciduous, iconSvg: TreeDeciduousSvg },
|
||||||
parking_area: { value: 'Parking Area', icon: CircleParking, iconSvg: CircleParkingSvg },
|
parking_area: { value: 'Parking Area', icon: CircleParking, iconSvg: CircleParkingSvg },
|
||||||
|
|||||||
@@ -2,7 +2,11 @@
|
|||||||
import docsearch from '@docsearch/js';
|
import docsearch from '@docsearch/js';
|
||||||
import '@docsearch/css';
|
import '@docsearch/css';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { _, locale, waitLocale } from 'svelte-i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
|
|
||||||
|
let props: {
|
||||||
|
class?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
let mounted = false;
|
let mounted = false;
|
||||||
|
|
||||||
@@ -13,31 +17,31 @@
|
|||||||
indexName: 'gpx',
|
indexName: 'gpx',
|
||||||
container: '#docsearch',
|
container: '#docsearch',
|
||||||
searchParameters: {
|
searchParameters: {
|
||||||
facetFilters: ['lang:' + ($locale ?? 'en')],
|
facetFilters: ['lang:' + i18n.lang],
|
||||||
},
|
},
|
||||||
placeholder: $_('docs.search.search'),
|
placeholder: i18n._('docs.search.search'),
|
||||||
disableUserPersonalization: true,
|
disableUserPersonalization: true,
|
||||||
translations: {
|
translations: {
|
||||||
button: {
|
button: {
|
||||||
buttonText: $_('docs.search.search'),
|
buttonText: i18n._('docs.search.search'),
|
||||||
buttonAriaLabel: $_('docs.search.search'),
|
buttonAriaLabel: i18n._('docs.search.search'),
|
||||||
},
|
},
|
||||||
modal: {
|
modal: {
|
||||||
searchBox: {
|
searchBox: {
|
||||||
resetButtonTitle: $_('docs.search.clear'),
|
resetButtonTitle: i18n._('docs.search.clear'),
|
||||||
resetButtonAriaLabel: $_('docs.search.clear'),
|
resetButtonAriaLabel: i18n._('docs.search.clear'),
|
||||||
cancelButtonText: $_('docs.search.cancel'),
|
cancelButtonText: i18n._('docs.search.cancel'),
|
||||||
cancelButtonAriaLabel: $_('docs.search.cancel'),
|
cancelButtonAriaLabel: i18n._('docs.search.cancel'),
|
||||||
searchInputLabel: $_('docs.search.search'),
|
searchInputLabel: i18n._('docs.search.search'),
|
||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
selectText: $_('docs.search.to_select'),
|
selectText: i18n._('docs.search.to_select'),
|
||||||
navigateText: $_('docs.search.to_navigate'),
|
navigateText: i18n._('docs.search.to_navigate'),
|
||||||
closeText: $_('docs.search.to_close'),
|
closeText: i18n._('docs.search.to_close'),
|
||||||
},
|
},
|
||||||
noResultsScreen: {
|
noResultsScreen: {
|
||||||
noResultsText: $_('docs.search.no_results'),
|
noResultsText: i18n._('docs.search.no_results'),
|
||||||
suggestedQueryText: $_('docs.search.no_results_suggestion'),
|
suggestedQueryText: i18n._('docs.search.no_results_suggestion'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -48,13 +52,15 @@
|
|||||||
mounted = true;
|
mounted = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
$: if (mounted && $locale) {
|
$effect(() => {
|
||||||
waitLocale().then(initDocsearch);
|
if (mounted && i18n.lang && !i18n.isLoading) {
|
||||||
}
|
initDocsearch();
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<link rel="preconnect" href="https://21XLD94PE3-dsn.algolia.net" crossorigin />
|
<link rel="preconnect" href="https://21XLD94PE3-dsn.algolia.net" crossorigin />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div id="docsearch" {...$$restProps}></div>
|
<div id="docsearch" class={props.class ?? ''}></div>
|
||||||
|
|||||||
@@ -1,28 +1,38 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
||||||
import type { Builder } from 'bits-ui';
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
export let variant:
|
const {
|
||||||
| 'default'
|
variant = 'default',
|
||||||
| 'secondary'
|
label,
|
||||||
| 'link'
|
side = 'top',
|
||||||
| 'destructive'
|
disabled = false,
|
||||||
| 'outline'
|
class: className = '',
|
||||||
| 'ghost'
|
children,
|
||||||
| undefined = 'default';
|
onclick,
|
||||||
export let label: string;
|
}: {
|
||||||
export let side: 'top' | 'right' | 'bottom' | 'left' = 'top';
|
variant?: 'default' | 'secondary' | 'link' | 'destructive' | 'outline' | 'ghost';
|
||||||
export let builders: Builder[] = [];
|
label: string;
|
||||||
|
side?: 'top' | 'right' | 'bottom' | 'left';
|
||||||
|
disabled?: boolean;
|
||||||
|
class?: string;
|
||||||
|
children: Snippet;
|
||||||
|
onclick?: (event: MouseEvent) => void;
|
||||||
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Tooltip.Root>
|
<Tooltip.Provider>
|
||||||
<Tooltip.Trigger asChild let:builder>
|
<Tooltip.Root>
|
||||||
<Button builders={[...builders, builder]} {variant} {...$$restProps} on:click>
|
<Tooltip.Trigger>
|
||||||
<slot />
|
{#snippet child({ props })}
|
||||||
</Button>
|
<Button {...props} {variant} class={className} {onclick}>
|
||||||
</Tooltip.Trigger>
|
{@render children()}
|
||||||
<Tooltip.Content {side}>
|
</Button>
|
||||||
<span>{label}</span>
|
{/snippet}
|
||||||
</Tooltip.Content>
|
</Tooltip.Trigger>
|
||||||
</Tooltip.Root>
|
<Tooltip.Content {side}>
|
||||||
|
<span>{label}</span>
|
||||||
|
</Tooltip.Content>
|
||||||
|
</Tooltip.Root>
|
||||||
|
</Tooltip.Provider>
|
||||||
|
|||||||
@@ -1,687 +0,0 @@
|
|||||||
<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 Chart from 'chart.js/auto';
|
|
||||||
import mapboxgl from 'mapbox-gl';
|
|
||||||
import { map } from '$lib/stores';
|
|
||||||
import { onDestroy, onMount } from 'svelte';
|
|
||||||
import {
|
|
||||||
BrickWall,
|
|
||||||
TriangleRight,
|
|
||||||
HeartPulse,
|
|
||||||
Orbit,
|
|
||||||
SquareActivity,
|
|
||||||
Thermometer,
|
|
||||||
Zap,
|
|
||||||
Circle,
|
|
||||||
Check,
|
|
||||||
ChartNoAxesColumn,
|
|
||||||
Construction,
|
|
||||||
} from 'lucide-svelte';
|
|
||||||
import { getSlopeColor, getSurfaceColor, getHighwayColor } from '$lib/assets/colors';
|
|
||||||
import { _ } from 'svelte-i18n';
|
|
||||||
import {
|
|
||||||
getCadenceWithUnits,
|
|
||||||
getConvertedDistance,
|
|
||||||
getConvertedElevation,
|
|
||||||
getConvertedTemperature,
|
|
||||||
getConvertedVelocity,
|
|
||||||
getDistanceUnits,
|
|
||||||
getDistanceWithUnits,
|
|
||||||
getElevationWithUnits,
|
|
||||||
getHeartRateWithUnits,
|
|
||||||
getPowerWithUnits,
|
|
||||||
getTemperatureWithUnits,
|
|
||||||
getVelocityWithUnits,
|
|
||||||
} from '$lib/units';
|
|
||||||
import type { Writable } from 'svelte/store';
|
|
||||||
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 additionalDatasets: string[];
|
|
||||||
export let elevationFill: 'slope' | 'surface' | 'highway' | undefined;
|
|
||||||
export let showControls: boolean = true;
|
|
||||||
|
|
||||||
const { distanceUnits, velocityUnits, temperatureUnits } = settings;
|
|
||||||
|
|
||||||
let canvas: HTMLCanvasElement;
|
|
||||||
let overlay: HTMLCanvasElement;
|
|
||||||
let chart: Chart;
|
|
||||||
|
|
||||||
Chart.defaults.font.family =
|
|
||||||
'ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"'; // Tailwind CSS font
|
|
||||||
|
|
||||||
let marker: mapboxgl.Marker | null = null;
|
|
||||||
let dragging = false;
|
|
||||||
let panning = false;
|
|
||||||
|
|
||||||
let options = {
|
|
||||||
animation: false,
|
|
||||||
parsing: false,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
scales: {
|
|
||||||
x: {
|
|
||||||
type: 'linear',
|
|
||||||
ticks: {
|
|
||||||
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,
|
|
||||||
cubicInterpolationMode: 'monotone',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
interaction: {
|
|
||||||
mode: 'nearest',
|
|
||||||
axis: 'x',
|
|
||||||
intersect: false,
|
|
||||||
},
|
|
||||||
plugins: {
|
|
||||||
legend: {
|
|
||||||
display: false,
|
|
||||||
},
|
|
||||||
decimation: {
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
enabled: () => !dragging && !panning,
|
|
||||||
callbacks: {
|
|
||||||
title: function () {
|
|
||||||
return '';
|
|
||||||
},
|
|
||||||
label: function (context: Chart.TooltipContext) {
|
|
||||||
let point = context.raw;
|
|
||||||
if (context.datasetIndex === 0) {
|
|
||||||
if ($map && marker) {
|
|
||||||
if (dragging) {
|
|
||||||
marker.remove();
|
|
||||||
} else {
|
|
||||||
marker.setLngLat(point.coordinates);
|
|
||||||
marker.addTo($map);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return `${$_('quantities.elevation')}: ${getElevationWithUnits(point.y, false)}`;
|
|
||||||
} else if (context.datasetIndex === 1) {
|
|
||||||
return `${$velocityUnits === 'speed' ? $_('quantities.speed') : $_('quantities.pace')}: ${getVelocityWithUnits(point.y, false)}`;
|
|
||||||
} else if (context.datasetIndex === 2) {
|
|
||||||
return `${$_('quantities.heartrate')}: ${getHeartRateWithUnits(point.y)}`;
|
|
||||||
} else if (context.datasetIndex === 3) {
|
|
||||||
return `${$_('quantities.cadence')}: ${getCadenceWithUnits(point.y)}`;
|
|
||||||
} else if (context.datasetIndex === 4) {
|
|
||||||
return `${$_('quantities.temperature')}: ${getTemperatureWithUnits(point.y, false)}`;
|
|
||||||
} else if (context.datasetIndex === 5) {
|
|
||||||
return `${$_('quantities.power')}: ${getPowerWithUnits(point.y)}`;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
afterBody: function (contexts: Chart.TooltipContext[]) {
|
|
||||||
let context = contexts.filter((context) => context.datasetIndex === 0);
|
|
||||||
if (context.length === 0) return;
|
|
||||||
let point = context[0].raw;
|
|
||||||
let slope = {
|
|
||||||
at: point.slope.at.toFixed(1),
|
|
||||||
segment: point.slope.segment.toFixed(1),
|
|
||||||
length: getDistanceWithUnits(point.slope.length),
|
|
||||||
};
|
|
||||||
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} %)` : ''}`,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (elevationFill === 'surface') {
|
|
||||||
labels.push(
|
|
||||||
` ${$_('quantities.surface')}: ${$_(`toolbar.routing.surface.${surface}`)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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: {
|
|
||||||
enabled: true,
|
|
||||||
mode: 'x',
|
|
||||||
modifierKey: 'shift',
|
|
||||||
onPanStart: function () {
|
|
||||||
// hide tooltip
|
|
||||||
panning = true;
|
|
||||||
$slicedGPXStatistics = undefined;
|
|
||||||
},
|
|
||||||
onPanComplete: function () {
|
|
||||||
panning = false;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
zoom: {
|
|
||||||
wheel: {
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
mode: 'x',
|
|
||||||
onZoomStart: function ({ chart, event }: { chart: Chart; event: any }) {
|
|
||||||
if (
|
|
||||||
event.deltaY < 0 &&
|
|
||||||
Math.abs(
|
|
||||||
chart.getInitialScaleBounds().x.max /
|
|
||||||
chart.options.plugins.zoom.limits.x.minRange -
|
|
||||||
chart.getZoomLevel()
|
|
||||||
) < 0.01
|
|
||||||
) {
|
|
||||||
// Disable wheel pan if zoomed in to the max, and zooming in
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$slicedGPXStatistics = undefined;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
limits: {
|
|
||||||
x: {
|
|
||||||
min: 'original',
|
|
||||||
max: 'original',
|
|
||||||
minRange: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
stacked: false,
|
|
||||||
onResize: function () {
|
|
||||||
updateOverlay();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
let datasets: string[] = ['speed', 'hr', 'cad', 'atemp', 'power'];
|
|
||||||
datasets.forEach((id) => {
|
|
||||||
options.scales[`y${id}`] = {
|
|
||||||
type: 'linear',
|
|
||||||
position: 'right',
|
|
||||||
grid: {
|
|
||||||
display: false,
|
|
||||||
},
|
|
||||||
reverse: () => id === 'speed' && $velocityUnits === 'pace',
|
|
||||||
display: false,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
Chart.register((await import('chartjs-plugin-zoom')).default); // dynamic import to avoid SSR and 'window is not defined' error
|
|
||||||
|
|
||||||
chart = new Chart(canvas, {
|
|
||||||
type: 'line',
|
|
||||||
data: {
|
|
||||||
datasets: [],
|
|
||||||
},
|
|
||||||
options,
|
|
||||||
plugins: [
|
|
||||||
{
|
|
||||||
id: 'toggleMarker',
|
|
||||||
events: ['mouseout'],
|
|
||||||
afterEvent: function (chart: Chart, args: { event: Chart.ChartEvent }) {
|
|
||||||
if (args.event.type === 'mouseout') {
|
|
||||||
if ($map && marker) {
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
let startIndex = 0;
|
|
||||||
let endIndex = 0;
|
|
||||||
function getIndex(evt) {
|
|
||||||
const points = chart.getElementsAtEventForMode(
|
|
||||||
evt,
|
|
||||||
'x',
|
|
||||||
{
|
|
||||||
intersect: false,
|
|
||||||
},
|
|
||||||
true
|
|
||||||
);
|
|
||||||
|
|
||||||
if (points.length === 0) {
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
if (evt.x - rect.left <= chart.chartArea.left) {
|
|
||||||
return 0;
|
|
||||||
} else if (evt.x - rect.left >= chart.chartArea.right) {
|
|
||||||
return $gpxStatistics.local.points.length - 1;
|
|
||||||
} else {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let point = points.find((point) => point.element.raw);
|
|
||||||
if (point) {
|
|
||||||
return point.element.raw.index;
|
|
||||||
} else {
|
|
||||||
return points[0].index;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let dragStarted = false;
|
|
||||||
function onMouseDown(evt) {
|
|
||||||
if (evt.shiftKey) {
|
|
||||||
// Panning interaction
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
dragStarted = true;
|
|
||||||
canvas.style.cursor = 'col-resize';
|
|
||||||
startIndex = getIndex(evt);
|
|
||||||
}
|
|
||||||
function onMouseMove(evt) {
|
|
||||||
if (dragStarted) {
|
|
||||||
dragging = true;
|
|
||||||
endIndex = getIndex(evt);
|
|
||||||
if (endIndex !== undefined) {
|
|
||||||
if (startIndex === undefined) {
|
|
||||||
startIndex = endIndex;
|
|
||||||
} else if (startIndex !== endIndex) {
|
|
||||||
$slicedGPXStatistics = [
|
|
||||||
$gpxStatistics.slice(
|
|
||||||
Math.min(startIndex, endIndex),
|
|
||||||
Math.max(startIndex, endIndex)
|
|
||||||
),
|
|
||||||
Math.min(startIndex, endIndex),
|
|
||||||
Math.max(startIndex, endIndex),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function onMouseUp(evt) {
|
|
||||||
dragStarted = false;
|
|
||||||
dragging = false;
|
|
||||||
canvas.style.cursor = '';
|
|
||||||
endIndex = getIndex(evt);
|
|
||||||
if (startIndex === endIndex) {
|
|
||||||
$slicedGPXStatistics = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
canvas.addEventListener('pointerdown', onMouseDown);
|
|
||||||
canvas.addEventListener('pointermove', onMouseMove);
|
|
||||||
canvas.addEventListener('pointerup', onMouseUp);
|
|
||||||
});
|
|
||||||
|
|
||||||
$: if (chart && $distanceUnits && $velocityUnits && $temperatureUnits) {
|
|
||||||
let data = $gpxStatistics;
|
|
||||||
|
|
||||||
// update data
|
|
||||||
chart.data.datasets[0] = {
|
|
||||||
label: $_('quantities.elevation'),
|
|
||||||
data: data.local.points.map((point, index) => {
|
|
||||||
return {
|
|
||||||
x: getConvertedDistance(data.local.distance.total[index]),
|
|
||||||
y: point.ele ? getConvertedElevation(point.ele) : 0,
|
|
||||||
time: point.time,
|
|
||||||
slope: {
|
|
||||||
at: data.local.slope.at[index],
|
|
||||||
segment: data.local.slope.segment[index],
|
|
||||||
length: data.local.slope.length[index],
|
|
||||||
},
|
|
||||||
extensions: point.getExtensions(),
|
|
||||||
coordinates: point.getCoordinates(),
|
|
||||||
index: index,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
normalized: true,
|
|
||||||
fill: 'start',
|
|
||||||
order: 1,
|
|
||||||
};
|
|
||||||
chart.data.datasets[1] = {
|
|
||||||
data: data.local.points.map((point, index) => {
|
|
||||||
return {
|
|
||||||
x: getConvertedDistance(data.local.distance.total[index]),
|
|
||||||
y: getConvertedVelocity(data.local.speed[index]),
|
|
||||||
index: index,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
normalized: true,
|
|
||||||
yAxisID: 'yspeed',
|
|
||||||
hidden: true,
|
|
||||||
};
|
|
||||||
chart.data.datasets[2] = {
|
|
||||||
data: data.local.points.map((point, index) => {
|
|
||||||
return {
|
|
||||||
x: getConvertedDistance(data.local.distance.total[index]),
|
|
||||||
y: point.getHeartRate(),
|
|
||||||
index: index,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
normalized: true,
|
|
||||||
yAxisID: 'yhr',
|
|
||||||
hidden: true,
|
|
||||||
};
|
|
||||||
chart.data.datasets[3] = {
|
|
||||||
data: data.local.points.map((point, index) => {
|
|
||||||
return {
|
|
||||||
x: getConvertedDistance(data.local.distance.total[index]),
|
|
||||||
y: point.getCadence(),
|
|
||||||
index: index,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
normalized: true,
|
|
||||||
yAxisID: 'ycad',
|
|
||||||
hidden: true,
|
|
||||||
};
|
|
||||||
chart.data.datasets[4] = {
|
|
||||||
data: data.local.points.map((point, index) => {
|
|
||||||
return {
|
|
||||||
x: getConvertedDistance(data.local.distance.total[index]),
|
|
||||||
y: getConvertedTemperature(point.getTemperature()),
|
|
||||||
index: index,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
normalized: true,
|
|
||||||
yAxisID: 'yatemp',
|
|
||||||
hidden: true,
|
|
||||||
};
|
|
||||||
chart.data.datasets[5] = {
|
|
||||||
data: data.local.points.map((point, index) => {
|
|
||||||
return {
|
|
||||||
x: getConvertedDistance(data.local.distance.total[index]),
|
|
||||||
y: point.getPower(),
|
|
||||||
index: index,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
normalized: true,
|
|
||||||
yAxisID: 'ypower',
|
|
||||||
hidden: true,
|
|
||||||
};
|
|
||||||
chart.options.scales.x['min'] = 0;
|
|
||||||
chart.options.scales.x['max'] = getConvertedDistance(data.global.distance.total);
|
|
||||||
|
|
||||||
chart.update();
|
|
||||||
}
|
|
||||||
|
|
||||||
function slopeFillCallback(context) {
|
|
||||||
return getSlopeColor(context.p0.raw.slope.segment);
|
|
||||||
}
|
|
||||||
|
|
||||||
function surfaceFillCallback(context) {
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
} else if (elevationFill === 'surface') {
|
|
||||||
chart.data.datasets[0]['segment'] = {
|
|
||||||
backgroundColor: surfaceFillCallback,
|
|
||||||
};
|
|
||||||
} else if (elevationFill === 'highway') {
|
|
||||||
chart.data.datasets[0]['segment'] = {
|
|
||||||
backgroundColor: highwayFillCallback,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
chart.data.datasets[0]['segment'] = {};
|
|
||||||
}
|
|
||||||
chart.update();
|
|
||||||
}
|
|
||||||
|
|
||||||
$: if (additionalDatasets && chart) {
|
|
||||||
let includeSpeed = additionalDatasets.includes('speed');
|
|
||||||
let includeHeartRate = additionalDatasets.includes('hr');
|
|
||||||
let includeCadence = additionalDatasets.includes('cad');
|
|
||||||
let includeTemperature = additionalDatasets.includes('atemp');
|
|
||||||
let includePower = additionalDatasets.includes('power');
|
|
||||||
if (chart.data.datasets.length > 0) {
|
|
||||||
chart.data.datasets[1].hidden = !includeSpeed;
|
|
||||||
chart.data.datasets[2].hidden = !includeHeartRate;
|
|
||||||
chart.data.datasets[3].hidden = !includeCadence;
|
|
||||||
chart.data.datasets[4].hidden = !includeTemperature;
|
|
||||||
chart.data.datasets[5].hidden = !includePower;
|
|
||||||
}
|
|
||||||
chart.update();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateOverlay() {
|
|
||||||
if (!canvas) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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];
|
|
||||||
let endIndex = $slicedGPXStatistics[2];
|
|
||||||
|
|
||||||
// Draw selection rectangle
|
|
||||||
let selectionContext = overlay.getContext('2d');
|
|
||||||
if (selectionContext) {
|
|
||||||
selectionContext.fillStyle = $mode === 'dark' ? 'white' : 'black';
|
|
||||||
selectionContext.globalAlpha = $mode === 'dark' ? 0.2 : 0.1;
|
|
||||||
selectionContext.clearRect(0, 0, overlay.width, overlay.height);
|
|
||||||
|
|
||||||
let startPixel = chart.scales.x.getPixelForValue(
|
|
||||||
getConvertedDistance($gpxStatistics.local.distance.total[startIndex])
|
|
||||||
);
|
|
||||||
let endPixel = chart.scales.x.getPixelForValue(
|
|
||||||
getConvertedDistance($gpxStatistics.local.distance.total[endIndex])
|
|
||||||
);
|
|
||||||
|
|
||||||
selectionContext.fillRect(
|
|
||||||
startPixel,
|
|
||||||
chart.chartArea.top,
|
|
||||||
endPixel - startPixel,
|
|
||||||
chart.chartArea.height
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (overlay) {
|
|
||||||
let selectionContext = overlay.getContext('2d');
|
|
||||||
if (selectionContext) {
|
|
||||||
selectionContext.clearRect(0, 0, overlay.width, overlay.height);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$: $slicedGPXStatistics, $mode, updateOverlay();
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
if (chart) {
|
|
||||||
chart.destroy();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<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="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="flex flex-col items-start gap-0 p-1"
|
|
||||||
type="single"
|
|
||||||
bind:value={elevationFill}
|
|
||||||
>
|
|
||||||
<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 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="flex flex-col items-start gap-0 p-1"
|
|
||||||
type="multiple"
|
|
||||||
bind:value={additionalDatasets}
|
|
||||||
>
|
|
||||||
<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"
|
|
||||||
>
|
|
||||||
<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 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 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 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 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>
|
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import LanguageSelect from '$lib/components/LanguageSelect.svelte';
|
import LanguageSelect from '$lib/components/LanguageSelect.svelte';
|
||||||
import Logo from '$lib/components/Logo.svelte';
|
import Logo from '$lib/components/Logo.svelte';
|
||||||
import { AtSign, BookOpenText, Heart, Home, Map } from 'lucide-svelte';
|
import { AtSign, BookOpenText, Heart, House, Map } from '@lucide/svelte';
|
||||||
import { _, locale } from 'svelte-i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import { getURLForLanguage } from '$lib/utils';
|
import { getURLForLanguage } from '$lib/utils';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -14,109 +14,101 @@
|
|||||||
<Logo class="h-8" width="153" />
|
<Logo class="h-8" width="153" />
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
class="h-6 px-0 text-muted-foreground"
|
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
||||||
href="https://github.com/gpxstudio/gpx.studio/blob/main/LICENSE"
|
href="https://github.com/gpxstudio/gpx.studio/blob/main/LICENSE"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
MIT © 2024 gpx.studio
|
MIT © 2025 gpx.studio
|
||||||
</Button>
|
</Button>
|
||||||
<LanguageSelect class="w-40 mt-3" />
|
<LanguageSelect class="w-40 mt-3" />
|
||||||
</div>
|
</div>
|
||||||
<div class="grow max-w-2xl flex flex-row flex-wrap justify-between gap-x-10 gap-y-6">
|
<div class="grow max-w-2xl flex flex-row flex-wrap justify-between gap-x-10 gap-y-6">
|
||||||
<div class="flex flex-col items-start gap-1">
|
<div class="flex flex-col items-start gap-1">
|
||||||
<span class="font-semibold">{$_('homepage.website')}</span>
|
<span class="font-semibold">{i18n._('homepage.website')}</span>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
class="h-6 px-0 text-muted-foreground"
|
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
||||||
href={getURLForLanguage($locale, '/')}
|
href={getURLForLanguage(i18n.lang, '/')}
|
||||||
>
|
>
|
||||||
<Home size="16" class="mr-1" />
|
<House size="16" />
|
||||||
{$_('homepage.home')}
|
{i18n._('homepage.home')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
data-sveltekit-reload
|
||||||
|
variant="link"
|
||||||
|
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
||||||
|
href={getURLForLanguage(i18n.lang, '/app')}
|
||||||
|
>
|
||||||
|
<Map size="16" />
|
||||||
|
{i18n._('homepage.app')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
class="h-6 px-0 text-muted-foreground"
|
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
||||||
href={getURLForLanguage($locale, '/app')}
|
href={getURLForLanguage(i18n.lang, '/help')}
|
||||||
>
|
>
|
||||||
<Map size="16" class="mr-1" />
|
<BookOpenText size="16" />
|
||||||
{$_('homepage.app')}
|
{i18n._('menu.help')}
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="link"
|
|
||||||
class="h-6 px-0 text-muted-foreground"
|
|
||||||
href={getURLForLanguage($locale, '/help')}
|
|
||||||
>
|
|
||||||
<BookOpenText size="16" class="mr-1" />
|
|
||||||
{$_('menu.help')}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col items-start gap-1" id="contact">
|
<div class="flex flex-col items-start gap-1" id="contact">
|
||||||
<span class="font-semibold">{$_('homepage.contact')}</span>
|
<span class="font-semibold">{i18n._('homepage.contact')}</span>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
class="h-6 px-0 text-muted-foreground"
|
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
||||||
href="https://www.reddit.com/r/gpxstudio/"
|
href="https://www.reddit.com/r/gpxstudio/"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<Logo company="reddit" class="h-4 mr-1 fill-muted-foreground" />
|
<Logo company="reddit" class="h-4 fill-muted-foreground" />
|
||||||
{$_('homepage.reddit')}
|
{i18n._('homepage.reddit')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
class="h-6 px-0 text-muted-foreground"
|
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
||||||
href="https://facebook.com/gpx.studio"
|
href="https://facebook.com/gpx.studio"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<Logo company="facebook" class="h-4 mr-1 fill-muted-foreground" />
|
<Logo company="facebook" class="h-4 fill-muted-foreground" />
|
||||||
{$_('homepage.facebook')}
|
{i18n._('homepage.facebook')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
class="h-6 px-0 text-muted-foreground"
|
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
||||||
href="https://x.com/gpxstudio"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<Logo company="x" class="h-4 mr-1 fill-muted-foreground" />
|
|
||||||
{$_('homepage.x')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="link"
|
|
||||||
class="h-6 px-0 text-muted-foreground"
|
|
||||||
href="mailto:hello@gpx.studio"
|
href="mailto:hello@gpx.studio"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<AtSign size="16" class="mr-1" />
|
<AtSign size="16" />
|
||||||
{$_('homepage.email')}
|
{i18n._('homepage.email')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col items-start gap-1">
|
<div class="flex flex-col items-start gap-1">
|
||||||
<span class="font-semibold">{$_('homepage.contribute')}</span>
|
<span class="font-semibold">{i18n._('homepage.contribute')}</span>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
class="h-6 px-0 text-muted-foreground"
|
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
||||||
href="https://ko-fi.com/gpxstudio"
|
href="https://ko-fi.com/gpxstudio"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<Heart size="16" class="mr-1" />
|
<Heart size="16" />
|
||||||
{$_('menu.donate')}
|
{i18n._('menu.donate')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
class="h-6 px-0 text-muted-foreground"
|
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
||||||
href="https://crowdin.com/project/gpxstudio"
|
href="https://crowdin.com/project/gpxstudio"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<Logo company="crowdin" class="h-4 mr-1 fill-muted-foreground" />
|
<Logo company="crowdin" class="h-4 fill-muted-foreground" />
|
||||||
{$_('homepage.crowdin')}
|
{i18n._('homepage.crowdin')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
class="h-6 px-0 text-muted-foreground"
|
class="h-6 px-0 has-[>svg]:px-0 text-muted-foreground"
|
||||||
href="https://github.com/gpxstudio/gpx.studio"
|
href="https://github.com/gpxstudio/gpx.studio"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<Logo company="github" class="h-4 mr-1 fill-muted-foreground" />
|
<Logo company="github" class="h-4 fill-muted-foreground" />
|
||||||
{$_('homepage.github')}
|
{i18n._('homepage.github')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,46 +3,49 @@
|
|||||||
import Tooltip from '$lib/components/Tooltip.svelte';
|
import Tooltip from '$lib/components/Tooltip.svelte';
|
||||||
import WithUnits from '$lib/components/WithUnits.svelte';
|
import WithUnits from '$lib/components/WithUnits.svelte';
|
||||||
|
|
||||||
import { MoveDownRight, MoveUpRight, Ruler, Timer, Zap } from 'lucide-svelte';
|
import { MoveDownRight, MoveUpRight, Ruler, Timer, Zap } from '@lucide/svelte';
|
||||||
|
|
||||||
import { _ } from 'svelte-i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import type { GPXStatistics } from 'gpx';
|
import type { GPXStatistics } from 'gpx';
|
||||||
import type { Writable } from 'svelte/store';
|
import type { Readable } from 'svelte/store';
|
||||||
import { settings } from '$lib/db';
|
import { settings } from '$lib/logic/settings';
|
||||||
|
|
||||||
export let gpxStatistics: Writable<GPXStatistics>;
|
|
||||||
export let slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>;
|
|
||||||
export let orientation: 'horizontal' | 'vertical';
|
|
||||||
export let panelSize: number;
|
|
||||||
|
|
||||||
const { velocityUnits } = settings;
|
const { velocityUnits } = settings;
|
||||||
|
|
||||||
let statistics: GPXStatistics;
|
let {
|
||||||
|
gpxStatistics,
|
||||||
|
slicedGPXStatistics,
|
||||||
|
orientation,
|
||||||
|
panelSize,
|
||||||
|
}: {
|
||||||
|
gpxStatistics: Readable<GPXStatistics>;
|
||||||
|
slicedGPXStatistics: Readable<[GPXStatistics, number, number] | undefined>;
|
||||||
|
orientation: 'horizontal' | 'vertical';
|
||||||
|
panelSize: number;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
$: if ($slicedGPXStatistics !== undefined) {
|
let statistics = $derived(
|
||||||
statistics = $slicedGPXStatistics[0];
|
$slicedGPXStatistics !== undefined ? $slicedGPXStatistics[0] : $gpxStatistics
|
||||||
} else {
|
);
|
||||||
statistics = $gpxStatistics;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card.Root
|
<Card.Root
|
||||||
class="h-full {orientation === 'vertical'
|
class="h-full {orientation === 'vertical'
|
||||||
? 'min-w-40 sm:min-w-44 text-sm sm:text-base'
|
? 'min-w-40 sm:min-w-44 text-sm sm:text-base'
|
||||||
: 'w-full'} border-none shadow-none"
|
: 'w-full'} border-none shadow-none p-0"
|
||||||
>
|
>
|
||||||
<Card.Content
|
<Card.Content
|
||||||
class="h-full flex {orientation === 'vertical'
|
class="h-full flex {orientation === 'vertical'
|
||||||
? 'flex-col justify-center'
|
? 'flex-col justify-center'
|
||||||
: 'flex-row w-full justify-between'} gap-4 p-0"
|
: 'flex-row w-full justify-between'} gap-4 p-0"
|
||||||
>
|
>
|
||||||
<Tooltip label={$_('quantities.distance')}>
|
<Tooltip label={i18n._('quantities.distance')}>
|
||||||
<span class="flex flex-row items-center">
|
<span class="flex flex-row items-center">
|
||||||
<Ruler size="16" class="mr-1" />
|
<Ruler size="16" class="mr-1" />
|
||||||
<WithUnits value={statistics.global.distance.total} type="distance" />
|
<WithUnits value={statistics.global.distance.total} type="distance" />
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip label={$_('quantities.elevation_gain_loss')}>
|
<Tooltip label={i18n._('quantities.elevation_gain_loss')}>
|
||||||
<span class="flex flex-row items-center">
|
<span class="flex flex-row items-center">
|
||||||
<MoveUpRight size="16" class="mr-1" />
|
<MoveUpRight size="16" class="mr-1" />
|
||||||
<WithUnits value={statistics.global.elevation.gain} type="elevation" />
|
<WithUnits value={statistics.global.elevation.gain} type="elevation" />
|
||||||
@@ -54,8 +57,10 @@
|
|||||||
<Tooltip
|
<Tooltip
|
||||||
class={orientation === 'horizontal' ? 'hidden xs:block' : ''}
|
class={orientation === 'horizontal' ? 'hidden xs:block' : ''}
|
||||||
label="{$velocityUnits === 'speed'
|
label="{$velocityUnits === 'speed'
|
||||||
? $_('quantities.speed')
|
? i18n._('quantities.speed')
|
||||||
: $_('quantities.pace')} ({$_('quantities.moving')} / {$_('quantities.total')})"
|
: i18n._('quantities.pace')} ({i18n._('quantities.moving')} / {i18n._(
|
||||||
|
'quantities.total'
|
||||||
|
)})"
|
||||||
>
|
>
|
||||||
<span class="flex flex-row items-center">
|
<span class="flex flex-row items-center">
|
||||||
<Zap size="16" class="mr-1" />
|
<Zap size="16" class="mr-1" />
|
||||||
@@ -72,7 +77,7 @@
|
|||||||
{#if panelSize > 160 || orientation === 'horizontal'}
|
{#if panelSize > 160 || orientation === 'horizontal'}
|
||||||
<Tooltip
|
<Tooltip
|
||||||
class={orientation === 'horizontal' ? 'hidden md:block' : ''}
|
class={orientation === 'horizontal' ? 'hidden md:block' : ''}
|
||||||
label="{$_('quantities.time')} ({$_('quantities.moving')} / {$_(
|
label="{i18n._('quantities.time')} ({i18n._('quantities.moving')} / {i18n._(
|
||||||
'quantities.total'
|
'quantities.total'
|
||||||
)})"
|
)})"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { CircleHelp } from 'lucide-svelte';
|
import { CircleQuestionMark } from '@lucide/svelte';
|
||||||
import { _ } from 'svelte-i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
export let link: string | undefined = undefined;
|
let {
|
||||||
|
link,
|
||||||
|
class: className = '',
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
link: string;
|
||||||
|
class?: string;
|
||||||
|
children: Snippet;
|
||||||
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div class="text-sm bg-secondary rounded border flex flex-row items-center p-2 {className}">
|
||||||
class="text-sm bg-secondary rounded border flex flex-row items-center p-2 {$$props.class || ''}"
|
<CircleQuestionMark size="16" class="w-4 mr-2 shrink-0 grow-0" />
|
||||||
>
|
|
||||||
<CircleHelp size="16" class="w-4 mr-2 shrink-0 grow-0" />
|
|
||||||
<div>
|
<div>
|
||||||
<slot />
|
{@render children()}
|
||||||
{#if link}
|
{#if link}
|
||||||
<a href={link} target="_blank" class="text-sm text-link hover:underline">
|
<a href={link} target="_blank" class="text-sm text-link hover:underline">
|
||||||
{$_('menu.more')}
|
{i18n._('menu.more')}
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,51 +1,36 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/state';
|
||||||
import * as Select from '$lib/components/ui/select';
|
import * as Select from '$lib/components/ui/select';
|
||||||
import { languages } from '$lib/languages';
|
import { languages } from '$lib/languages';
|
||||||
import { getURLForLanguage } from '$lib/utils';
|
import { getURLForLanguage } from '$lib/utils';
|
||||||
import { Languages } from 'lucide-svelte';
|
import { Languages } from '@lucide/svelte';
|
||||||
import { _, locale } from 'svelte-i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
|
|
||||||
let selected = {
|
let {
|
||||||
value: '',
|
class: className = '',
|
||||||
label: '',
|
}: {
|
||||||
};
|
class?: string;
|
||||||
|
} = $props();
|
||||||
$: if ($locale) {
|
|
||||||
selected = {
|
|
||||||
value: $locale,
|
|
||||||
label: languages[$locale],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Select.Root bind:selected>
|
<Select.Root type="single" value={i18n.lang}>
|
||||||
<Select.Trigger class="w-[180px] {$$props.class ?? ''}" aria-label={$_('menu.language')}>
|
<Select.Trigger class="min-w-[180px] {className}" aria-label={i18n._('menu.language')}>
|
||||||
<Languages size="16" />
|
<Languages size="16" />
|
||||||
<Select.Value class="ml-2 mr-auto" />
|
<span class="mr-auto">
|
||||||
|
{languages[i18n.lang]}
|
||||||
|
</span>
|
||||||
</Select.Trigger>
|
</Select.Trigger>
|
||||||
<Select.Content>
|
<Select.Content>
|
||||||
{#each Object.entries(languages) as [lang, label]}
|
{#each Object.entries(languages) as [lang, label]}
|
||||||
{#if $page.url.pathname.includes('404')}
|
{#if page.url.pathname.includes('404')}
|
||||||
<a href={getURLForLanguage(lang, '/')}>
|
<a href={getURLForLanguage(lang, '/')}>
|
||||||
<Select.Item value={lang}>{label}</Select.Item>
|
<Select.Item value={lang}>{label}</Select.Item>
|
||||||
</a>
|
</a>
|
||||||
{:else}
|
{:else}
|
||||||
<a href={getURLForLanguage(lang, $page.url.pathname)}>
|
<a href={getURLForLanguage(lang, page.url.pathname)}>
|
||||||
<Select.Item value={lang}>{label}</Select.Item>
|
<Select.Item value={lang}>{label}</Select.Item>
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</Select.Content>
|
</Select.Content>
|
||||||
</Select.Root>
|
</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, $page.url.pathname)}>
|
|
||||||
{label}
|
|
||||||
</a>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -1,31 +1,36 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { mode } from 'mode-watcher';
|
||||||
import { base } from '$app/paths';
|
import { base } from '$app/paths';
|
||||||
import { mode, systemPrefersMode } from 'mode-watcher';
|
|
||||||
|
|
||||||
export let iconOnly = false;
|
let {
|
||||||
export let company = 'gpx.studio';
|
iconOnly = false,
|
||||||
|
company = 'gpx.studio',
|
||||||
$: effectiveMode = $mode ?? $systemPrefersMode ?? 'light';
|
...others
|
||||||
|
}: {
|
||||||
|
iconOnly?: boolean;
|
||||||
|
company?: 'gpx.studio' | 'mapbox' | 'github' | 'crowdin' | 'facebook' | 'reddit';
|
||||||
|
[key: string]: any;
|
||||||
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if company === 'gpx.studio'}
|
{#if company === 'gpx.studio'}
|
||||||
<img
|
<img
|
||||||
src="{base}/{iconOnly ? 'icon' : 'logo'}{effectiveMode === 'dark' ? '-dark' : ''}.svg"
|
src="{base}/{iconOnly ? 'icon' : 'logo'}{mode.current === 'dark' ? '-dark' : ''}.svg"
|
||||||
alt="Logo of gpx.studio."
|
alt="Logo of gpx.studio."
|
||||||
{...$$restProps}
|
{...others}
|
||||||
/>
|
/>
|
||||||
{:else if company === 'mapbox'}
|
{:else if company === 'mapbox'}
|
||||||
<img
|
<img
|
||||||
src="{base}/mapbox-logo-{effectiveMode === 'dark' ? 'white' : 'black'}.svg"
|
src="{base}/mapbox-logo-{mode.current === 'dark' ? 'white' : 'black'}.svg"
|
||||||
alt="Logo of Mapbox."
|
alt="Logo of Mapbox."
|
||||||
{...$$restProps}
|
{...others}
|
||||||
/>
|
/>
|
||||||
{:else if company === 'github'}
|
{:else if company === 'github'}
|
||||||
<svg
|
<svg
|
||||||
role="img"
|
role="img"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
class="fill-foreground {$$restProps.class ?? ''}"
|
class="fill-foreground {others.class ?? ''}"
|
||||||
><title>GitHub</title><path
|
><title>GitHub</title><path
|
||||||
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
|
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
|
||||||
/></svg
|
/></svg
|
||||||
@@ -35,7 +40,7 @@
|
|||||||
role="img"
|
role="img"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
class="fill-foreground {$$restProps.class ?? ''}"
|
class="fill-foreground {others.class ?? ''}"
|
||||||
><title>Crowdin</title><path
|
><title>Crowdin</title><path
|
||||||
d="M16.119 17.793a2.619 2.619 0 0 1-1.667-.562c-.546-.436-1.004-1.09-1.018-1.858-.008-.388.414-.388.414-.388l1.018-.008c.332.008.43.47.445.586.128 1.04.717 1.495 1.168 1.702.273.123.204.513-.362.528zm-5.695-5.287L8.5 12.252c-.867-.214-.844-.982-.807-1.247a5.119 5.119 0 0 1 .814-2.125c.545-.804 1.303-1.508 2.29-2.073 1.856-1.074 4.45-1.673 7.31-1.673 2.09 0 4.256.27 4.29.27.197.025.328.213.333.437a.377.377 0 0 1-.355.393l-.92-.01c-2.902 0-4.968.394-6.506 1.248-1.527.837-2.57 2.117-3.287 4.012-.076.163-.335 1.12-1.24 1.022zm2.533 7.823c-1.44 0-2.797-.622-3.825-1.746-.87-.96-1.397-1.931-1.493-3.164-.06-.813.3-1.094.788-1.044l1.988.218c.45.092.75.34.825.854.397 2.736 2.122 3.814 3.15 4.046.18.042.292.157.283.365a.412.412 0 0 1-.322.398c-.458.074-.936.073-1.394.073zm-4.101 2.418a14.216 14.216 0 0 1-2.307-.214c-1.202-.214-2.208-.582-3.072-1.13C1.41 20.095.163 17.786.014 15.048c-.037-.65-.11-1.89 1.427-1.797.638.033 1.653.343 2.368.548.887.247 1.314.933 1.314 1.608 0 3.858 3.494 6.408 5.02 6.408.654 0 .414.701.127.779-.502.136-1.15.153-1.413.153zM3.525 11.419c-.605-.109-1.194-.358-1.768-.5C-.018 10.479.284 8.688.45 8.196c1.617-4.757 6.746-6.35 10.887-6.773 3.898-.4 7.978-.092 11.778.967.31.083 1.269.327.718.891-.35.358-1.7-.016-2.073-.041-2.23-.167-4.434-.192-6.656.15-2.349.357-4.768 1.099-6.71 2.665-.938.758-1.76 1.723-2.313 2.866-.144.3-.256.6-.354.9-.11.327-.47 1.91-2.215 1.6zm9.94.917c.332-1.488 1.81-3.848 6.385-3.686 1.05.033.57.749.052.731-2.586-.09-3.815 1.578-4.457 3.27-.219.546-.68.626-1.271.53-.415-.074-.866-.123-.71-.846Z"
|
d="M16.119 17.793a2.619 2.619 0 0 1-1.667-.562c-.546-.436-1.004-1.09-1.018-1.858-.008-.388.414-.388.414-.388l1.018-.008c.332.008.43.47.445.586.128 1.04.717 1.495 1.168 1.702.273.123.204.513-.362.528zm-5.695-5.287L8.5 12.252c-.867-.214-.844-.982-.807-1.247a5.119 5.119 0 0 1 .814-2.125c.545-.804 1.303-1.508 2.29-2.073 1.856-1.074 4.45-1.673 7.31-1.673 2.09 0 4.256.27 4.29.27.197.025.328.213.333.437a.377.377 0 0 1-.355.393l-.92-.01c-2.902 0-4.968.394-6.506 1.248-1.527.837-2.57 2.117-3.287 4.012-.076.163-.335 1.12-1.24 1.022zm2.533 7.823c-1.44 0-2.797-.622-3.825-1.746-.87-.96-1.397-1.931-1.493-3.164-.06-.813.3-1.094.788-1.044l1.988.218c.45.092.75.34.825.854.397 2.736 2.122 3.814 3.15 4.046.18.042.292.157.283.365a.412.412 0 0 1-.322.398c-.458.074-.936.073-1.394.073zm-4.101 2.418a14.216 14.216 0 0 1-2.307-.214c-1.202-.214-2.208-.582-3.072-1.13C1.41 20.095.163 17.786.014 15.048c-.037-.65-.11-1.89 1.427-1.797.638.033 1.653.343 2.368.548.887.247 1.314.933 1.314 1.608 0 3.858 3.494 6.408 5.02 6.408.654 0 .414.701.127.779-.502.136-1.15.153-1.413.153zM3.525 11.419c-.605-.109-1.194-.358-1.768-.5C-.018 10.479.284 8.688.45 8.196c1.617-4.757 6.746-6.35 10.887-6.773 3.898-.4 7.978-.092 11.778.967.31.083 1.269.327.718.891-.35.358-1.7-.016-2.073-.041-2.23-.167-4.434-.192-6.656.15-2.349.357-4.768 1.099-6.71 2.665-.938.758-1.76 1.723-2.313 2.866-.144.3-.256.6-.354.9-.11.327-.47 1.91-2.215 1.6zm9.94.917c.332-1.488 1.81-3.848 6.385-3.686 1.05.033.57.749.052.731-2.586-.09-3.815 1.578-4.457 3.27-.219.546-.68.626-1.271.53-.415-.074-.866-.123-.71-.846Z"
|
||||||
/></svg
|
/></svg
|
||||||
@@ -45,27 +50,17 @@
|
|||||||
role="img"
|
role="img"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
class="fill-foreground {$$restProps.class ?? ''}"
|
class="fill-foreground {others.class ?? ''}"
|
||||||
><title>Facebook</title><path
|
><title>Facebook</title><path
|
||||||
d="M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z"
|
d="M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z"
|
||||||
/></svg
|
/></svg
|
||||||
>
|
>
|
||||||
{:else if company === 'x'}
|
|
||||||
<svg
|
|
||||||
role="img"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="fill-foreground {$$restProps.class ?? ''}"
|
|
||||||
><title>X</title><path
|
|
||||||
d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z"
|
|
||||||
/></svg
|
|
||||||
>
|
|
||||||
{:else if company === 'reddit'}
|
{:else if company === 'reddit'}
|
||||||
<svg
|
<svg
|
||||||
role="img"
|
role="img"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
class="fill-foreground {$$restProps.class ?? ''}"
|
class="fill-foreground {others.class ?? ''}"
|
||||||
><title>Reddit</title><path
|
><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"
|
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
|
/></svg
|
||||||
|
|||||||
@@ -1,393 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onDestroy, onMount } from 'svelte';
|
|
||||||
|
|
||||||
import mapboxgl from 'mapbox-gl';
|
|
||||||
import 'mapbox-gl/dist/mapbox-gl.css';
|
|
||||||
|
|
||||||
import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder';
|
|
||||||
import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css';
|
|
||||||
|
|
||||||
import { Button } from '$lib/components/ui/button';
|
|
||||||
import { map } from '$lib/stores';
|
|
||||||
import { settings } from '$lib/db';
|
|
||||||
import { _ } from 'svelte-i18n';
|
|
||||||
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
|
|
||||||
import { page } from '$app/stores';
|
|
||||||
|
|
||||||
export let accessToken = PUBLIC_MAPBOX_TOKEN;
|
|
||||||
export let geolocate = true;
|
|
||||||
export let geocoder = true;
|
|
||||||
export let hash = true;
|
|
||||||
|
|
||||||
mapboxgl.accessToken = accessToken;
|
|
||||||
|
|
||||||
let webgl2Supported = true;
|
|
||||||
let embeddedApp = false;
|
|
||||||
let fitBoundsOptions: mapboxgl.FitBoundsOptions = {
|
|
||||||
maxZoom: 15,
|
|
||||||
linear: true,
|
|
||||||
easing: () => 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
const { distanceUnits, elevationProfile, treeFileView, bottomPanelSize, rightPanelSize } =
|
|
||||||
settings;
|
|
||||||
let scaleControl = new mapboxgl.ScaleControl({
|
|
||||||
unit: $distanceUnits,
|
|
||||||
});
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
let gl = document.createElement('canvas').getContext('webgl2');
|
|
||||||
if (!gl) {
|
|
||||||
webgl2Supported = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (window.top !== window.self && !$page.route.id?.includes('embed')) {
|
|
||||||
embeddedApp = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let language = $page.params.language;
|
|
||||||
if (language === 'zh') {
|
|
||||||
language = 'zh-Hans';
|
|
||||||
} else if (language?.includes('-')) {
|
|
||||||
language = language.split('-')[0];
|
|
||||||
} else if (language === '' || language === undefined) {
|
|
||||||
language = 'en';
|
|
||||||
}
|
|
||||||
|
|
||||||
let newMap = new mapboxgl.Map({
|
|
||||||
container: 'map',
|
|
||||||
style: {
|
|
||||||
version: 8,
|
|
||||||
sources: {},
|
|
||||||
layers: [],
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
newMap.addControl(
|
|
||||||
new mapboxgl.NavigationControl({
|
|
||||||
visualizePitch: true,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
if (geocoder) {
|
|
||||||
let geocoder = new MapboxGeocoder({
|
|
||||||
mapboxgl: mapboxgl,
|
|
||||||
enableEventLogging: false,
|
|
||||||
collapsed: true,
|
|
||||||
flyTo: fitBoundsOptions,
|
|
||||||
language,
|
|
||||||
localGeocoder: () => [],
|
|
||||||
localGeocoderOnly: true,
|
|
||||||
externalGeocoder: (query: string) =>
|
|
||||||
fetch(
|
|
||||||
`https://nominatim.openstreetmap.org/search?format=json&q=${query}&limit=5&accept-language=${language}`
|
|
||||||
)
|
|
||||||
.then((response) => response.json())
|
|
||||||
.then((data) => {
|
|
||||||
return data.map((result: any) => {
|
|
||||||
return {
|
|
||||||
type: 'Feature',
|
|
||||||
geometry: {
|
|
||||||
type: 'Point',
|
|
||||||
coordinates: [result.lon, result.lat],
|
|
||||||
},
|
|
||||||
place_name: result.display_name,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
let onKeyDown = geocoder._onKeyDown;
|
|
||||||
geocoder._onKeyDown = (e: KeyboardEvent) => {
|
|
||||||
// Trigger search on Enter key only
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
onKeyDown.apply(geocoder, [{ target: geocoder._inputEl }]);
|
|
||||||
} else if (geocoder._typeahead.data.length > 0) {
|
|
||||||
geocoder._typeahead.clear();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
newMap.addControl(geocoder);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (geolocate) {
|
|
||||||
newMap.addControl(
|
|
||||||
new mapboxgl.GeolocateControl({
|
|
||||||
positionOptions: {
|
|
||||||
enableHighAccuracy: true,
|
|
||||||
},
|
|
||||||
fitBoundsOptions,
|
|
||||||
trackUserLocation: true,
|
|
||||||
showUserHeading: true,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
newMap.addControl(scaleControl);
|
|
||||||
|
|
||||||
newMap.on('style.load', () => {
|
|
||||||
newMap.addSource('mapbox-dem', {
|
|
||||||
type: 'raster-dem',
|
|
||||||
url: 'mapbox://mapbox.mapbox-terrain-dem-v1',
|
|
||||||
tileSize: 512,
|
|
||||||
maxzoom: 14,
|
|
||||||
});
|
|
||||||
if (newMap.getPitch() > 0) {
|
|
||||||
newMap.setTerrain({
|
|
||||||
source: 'mapbox-dem',
|
|
||||||
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)',
|
|
||||||
});
|
|
||||||
newMap.on('pitch', () => {
|
|
||||||
if (newMap.getPitch() > 0) {
|
|
||||||
newMap.setTerrain({
|
|
||||||
source: 'mapbox-dem',
|
|
||||||
exaggeration: 1,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
newMap.setTerrain(null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
if ($map) {
|
|
||||||
$map.remove();
|
|
||||||
$map = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$: if ($map && (!$treeFileView || !$elevationProfile || $bottomPanelSize || $rightPanelSize)) {
|
|
||||||
$map.resize();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div {...$$restProps}>
|
|
||||||
<div id="map" class="h-full {webgl2Supported && !embeddedApp ? '' : 'hidden'}"></div>
|
|
||||||
<div
|
|
||||||
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>
|
|
||||||
|
|
||||||
<style lang="postcss">
|
|
||||||
div :global(.mapboxgl-map) {
|
|
||||||
@apply font-sans;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-ctrl-top-right > .mapboxgl-ctrl) {
|
|
||||||
@apply shadow-md;
|
|
||||||
@apply bg-background;
|
|
||||||
@apply text-foreground;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-ctrl-icon) {
|
|
||||||
@apply dark:brightness-[4.7];
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-ctrl-geocoder) {
|
|
||||||
@apply flex;
|
|
||||||
@apply flex-row;
|
|
||||||
@apply w-fit;
|
|
||||||
@apply min-w-fit;
|
|
||||||
@apply items-center;
|
|
||||||
@apply shadow-md;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.suggestions) {
|
|
||||||
@apply shadow-md;
|
|
||||||
@apply bg-background;
|
|
||||||
@apply text-foreground;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-ctrl-geocoder .suggestions > li > a) {
|
|
||||||
@apply text-foreground;
|
|
||||||
@apply hover:text-accent-foreground;
|
|
||||||
@apply hover:bg-accent;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-ctrl-geocoder .suggestions > .active > a) {
|
|
||||||
@apply bg-background;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-ctrl-geocoder--button) {
|
|
||||||
@apply bg-transparent;
|
|
||||||
@apply hover:bg-transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-ctrl-geocoder--icon) {
|
|
||||||
@apply fill-foreground;
|
|
||||||
@apply hover:fill-accent-foreground;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-ctrl-geocoder--icon-search) {
|
|
||||||
@apply relative;
|
|
||||||
@apply top-0;
|
|
||||||
@apply left-0;
|
|
||||||
@apply my-2;
|
|
||||||
@apply w-[29px];
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-ctrl-geocoder--input) {
|
|
||||||
@apply relative;
|
|
||||||
@apply w-64;
|
|
||||||
@apply py-0;
|
|
||||||
@apply pl-2;
|
|
||||||
@apply focus:outline-none;
|
|
||||||
@apply transition-[width];
|
|
||||||
@apply duration-200;
|
|
||||||
@apply text-foreground;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-ctrl-geocoder--collapsed .mapboxgl-ctrl-geocoder--input) {
|
|
||||||
@apply w-0;
|
|
||||||
@apply p-0;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-ctrl-top-right) {
|
|
||||||
@apply z-40;
|
|
||||||
@apply flex;
|
|
||||||
@apply flex-col;
|
|
||||||
@apply items-end;
|
|
||||||
@apply h-full;
|
|
||||||
@apply overflow-hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.horizontal :global(.mapboxgl-ctrl-bottom-left) {
|
|
||||||
@apply bottom-[42px];
|
|
||||||
}
|
|
||||||
|
|
||||||
.horizontal :global(.mapboxgl-ctrl-bottom-right) {
|
|
||||||
@apply bottom-[42px];
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-ctrl-attrib) {
|
|
||||||
@apply dark:bg-transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-compact-show.mapboxgl-ctrl-attrib) {
|
|
||||||
@apply dark:bg-background;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-ctrl-attrib-button) {
|
|
||||||
@apply dark:bg-foreground;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-compact-show .mapboxgl-ctrl-attrib-button) {
|
|
||||||
@apply dark:bg-foreground;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-ctrl-attrib a) {
|
|
||||||
@apply text-foreground;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-popup) {
|
|
||||||
@apply w-fit;
|
|
||||||
@apply z-50;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-popup-content) {
|
|
||||||
@apply p-0;
|
|
||||||
@apply bg-transparent;
|
|
||||||
@apply shadow-none;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-popup-anchor-top .mapboxgl-popup-tip) {
|
|
||||||
@apply border-b-background;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-popup-anchor-top-left .mapboxgl-popup-tip) {
|
|
||||||
@apply border-b-background;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-popup-anchor-top-right .mapboxgl-popup-tip) {
|
|
||||||
@apply border-b-background;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip) {
|
|
||||||
@apply border-t-background;
|
|
||||||
@apply drop-shadow-md;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-popup-anchor-bottom-left .mapboxgl-popup-tip) {
|
|
||||||
@apply border-t-background;
|
|
||||||
@apply drop-shadow-md;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-popup-anchor-bottom-right .mapboxgl-popup-tip) {
|
|
||||||
@apply border-t-background;
|
|
||||||
@apply drop-shadow-md;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-popup-anchor-left .mapboxgl-popup-tip) {
|
|
||||||
@apply border-r-background;
|
|
||||||
}
|
|
||||||
|
|
||||||
div :global(.mapboxgl-popup-anchor-right .mapboxgl-popup-tip) {
|
|
||||||
@apply border-l-background;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -43,40 +43,35 @@
|
|||||||
BookOpenText,
|
BookOpenText,
|
||||||
ChartArea,
|
ChartArea,
|
||||||
Maximize,
|
Maximize,
|
||||||
} from 'lucide-svelte';
|
} from '@lucide/svelte';
|
||||||
|
import { map } from '$lib/components/map/map';
|
||||||
import {
|
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
|
||||||
map,
|
import { editStyle } from '$lib/components/file-list/style/utils.svelte';
|
||||||
triggerFileInput,
|
import { exportState, ExportState } from '$lib/components/export/utils.svelte';
|
||||||
createFile,
|
import { anySelectedLayer } from '$lib/components/map/layer-control/utils';
|
||||||
loadFiles,
|
|
||||||
updateSelectionFromKey,
|
|
||||||
allHidden,
|
|
||||||
editMetadata,
|
|
||||||
editStyle,
|
|
||||||
exportState,
|
|
||||||
ExportState,
|
|
||||||
centerMapOnSelection,
|
|
||||||
} from '$lib/stores';
|
|
||||||
import {
|
|
||||||
copied,
|
|
||||||
copySelection,
|
|
||||||
cutSelection,
|
|
||||||
pasteSelection,
|
|
||||||
selectAll,
|
|
||||||
selection,
|
|
||||||
} from '$lib/components/file-list/Selection';
|
|
||||||
import { derived } from 'svelte/store';
|
|
||||||
import { canUndo, canRedo, dbUtils, fileObservers, settings } from '$lib/db';
|
|
||||||
import { anySelectedLayer } from '$lib/components/layer-control/utils';
|
|
||||||
import { defaultOverlays } from '$lib/assets/layers';
|
import { defaultOverlays } from '$lib/assets/layers';
|
||||||
import LayerControlSettings from '$lib/components/layer-control/LayerControlSettings.svelte';
|
import LayerControlSettings from '$lib/components/map/layer-control/LayerControlSettings.svelte';
|
||||||
import { allowedPastes, ListFileItem, ListTrackItem } from '$lib/components/file-list/FileList';
|
import { ListFileItem, ListTrackItem } from '$lib/components/file-list/file-list';
|
||||||
import Export from '$lib/components/Export.svelte';
|
import Export from '$lib/components/export/Export.svelte';
|
||||||
import { mode, setMode, systemPrefersMode } from 'mode-watcher';
|
import { mode, setMode } from 'mode-watcher';
|
||||||
import { _, locale } from 'svelte-i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import { languages } from '$lib/languages';
|
import { languages } from '$lib/languages';
|
||||||
import { getURLForLanguage } from '$lib/utils';
|
import { getURLForLanguage } from '$lib/utils';
|
||||||
|
import { settings } from '$lib/logic/settings';
|
||||||
|
import {
|
||||||
|
createFile,
|
||||||
|
fileActions,
|
||||||
|
loadFiles,
|
||||||
|
pasteSelection,
|
||||||
|
triggerFileInput,
|
||||||
|
} from '$lib/logic/file-actions';
|
||||||
|
import { fileStateCollection } from '$lib/logic/file-state';
|
||||||
|
import { fileActionManager } from '$lib/logic/file-action-manager';
|
||||||
|
import { copied, selection } from '$lib/logic/selection';
|
||||||
|
import { allHidden } from '$lib/logic/hidden';
|
||||||
|
import { boundsManager } from '$lib/logic/bounds';
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
import { allowedPastes } from '$lib/components/file-list/sortable-file-list';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
distanceUnits,
|
distanceUnits,
|
||||||
@@ -94,121 +89,109 @@
|
|||||||
routing,
|
routing,
|
||||||
} = settings;
|
} = settings;
|
||||||
|
|
||||||
let undoDisabled = derived(canUndo, ($canUndo) => !$canUndo);
|
const canUndo = fileActionManager.canUndo;
|
||||||
let redoDisabled = derived(canRedo, ($canRedo) => !$canRedo);
|
const canRedo = fileActionManager.canRedo;
|
||||||
|
|
||||||
function switchBasemaps() {
|
function switchBasemaps() {
|
||||||
[$currentBasemap, $previousBasemap] = [$previousBasemap, $currentBasemap];
|
[$currentBasemap, $previousBasemap] = [$previousBasemap, $currentBasemap];
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleOverlays() {
|
function toggleOverlays() {
|
||||||
if (anySelectedLayer($currentOverlays)) {
|
if ($currentOverlays && anySelectedLayer($currentOverlays)) {
|
||||||
[$currentOverlays, $previousOverlays] = [defaultOverlays, $currentOverlays];
|
[$currentOverlays, $previousOverlays] = [defaultOverlays, $currentOverlays];
|
||||||
} else {
|
} else {
|
||||||
[$currentOverlays, $previousOverlays] = [$previousOverlays, defaultOverlays];
|
[$currentOverlays, $previousOverlays] = [$previousOverlays, defaultOverlays];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggle3D() {
|
let layerSettingsOpen = $state(false);
|
||||||
if ($map) {
|
|
||||||
if ($map.getPitch() === 0) {
|
|
||||||
$map.easeTo({ pitch: 70 });
|
|
||||||
} else {
|
|
||||||
$map.easeTo({ pitch: 0 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let layerSettingsOpen = false;
|
|
||||||
|
|
||||||
$: selectedMode = $mode ?? $systemPrefersMode ?? 'light';
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="absolute md:top-2 left-0 right-0 z-20 flex flex-row justify-center pointer-events-none">
|
<div class="absolute md:top-2 left-0 right-0 z-20 flex flex-row justify-center pointer-events-none">
|
||||||
<div
|
<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"
|
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={getURLForLanguage($locale, '/')} target="_blank" class="shrink-0">
|
<a href={getURLForLanguage(i18n.lang, '/')} 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 md:hidden" iconOnly={true} width="16" />
|
||||||
<Logo class="h-5 mt-0.5 mx-2 hidden md:block" width="96" />
|
<Logo class="h-5 mt-0.5 mx-2 hidden md:block" width="96" />
|
||||||
</a>
|
</a>
|
||||||
<Menubar.Root class="border-none h-fit p-0">
|
<Menubar.Root class="border-none shadow-none h-fit p-0">
|
||||||
<Menubar.Menu>
|
<Menubar.Menu>
|
||||||
<Menubar.Trigger aria-label={$_('gpx.file')}>
|
<Menubar.Trigger aria-label={i18n._('gpx.file')}>
|
||||||
<File size="18" class="md:hidden" />
|
<File size="18" class="md:hidden" />
|
||||||
<span class="hidden md:block">{$_('gpx.file')}</span>
|
<span class="hidden md:block">{i18n._('gpx.file')}</span>
|
||||||
</Menubar.Trigger>
|
</Menubar.Trigger>
|
||||||
<Menubar.Content class="border-none">
|
<Menubar.Content class="border-none">
|
||||||
<Menubar.Item on:click={createFile}>
|
<Menubar.Item onclick={createFile}>
|
||||||
<Plus size="16" class="mr-1" />
|
<Plus size="16" />
|
||||||
{$_('menu.new')}
|
{i18n._('menu.new')}
|
||||||
<Shortcut key="+" ctrl={true} />
|
<Shortcut key="+" ctrl={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
<Menubar.Item on:click={triggerFileInput}>
|
<Menubar.Item onclick={triggerFileInput}>
|
||||||
<FolderOpen size="16" class="mr-1" />
|
<FolderOpen size="16" />
|
||||||
{$_('menu.open')}
|
{i18n._('menu.open')}
|
||||||
<Shortcut key="O" ctrl={true} />
|
<Shortcut key="O" ctrl={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
<Menubar.Item
|
<Menubar.Item
|
||||||
on:click={dbUtils.duplicateSelection}
|
onclick={fileActions.duplicateSelection}
|
||||||
disabled={$selection.size == 0}
|
disabled={$selection.size == 0}
|
||||||
>
|
>
|
||||||
<Copy size="16" class="mr-1" />
|
<Copy size="16" />
|
||||||
{$_('menu.duplicate')}
|
{i18n._('menu.duplicate')}
|
||||||
<Shortcut key="D" ctrl={true} />
|
<Shortcut key="D" ctrl={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
<Menubar.Item
|
<Menubar.Item
|
||||||
on:click={dbUtils.deleteSelectedFiles}
|
onclick={() => tick().then(fileActions.deleteSelectedFiles)}
|
||||||
disabled={$selection.size == 0}
|
disabled={$selection.size == 0}
|
||||||
>
|
>
|
||||||
<FileX size="16" class="mr-1" />
|
<FileX size="16" />
|
||||||
{$_('menu.close')}
|
{i18n._('menu.delete')}
|
||||||
<Shortcut key="⌫" ctrl={true} />
|
<Shortcut key="⌫" ctrl={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
<Menubar.Item
|
<Menubar.Item
|
||||||
on:click={dbUtils.deleteAllFiles}
|
onclick={fileActions.deleteAllFiles}
|
||||||
disabled={$fileObservers.size == 0}
|
disabled={fileStateCollection.size == 0}
|
||||||
>
|
>
|
||||||
<FileX size="16" class="mr-1" />
|
<FileX size="16" />
|
||||||
{$_('menu.close_all')}
|
{i18n._('menu.delete_all')}
|
||||||
<Shortcut key="⌫" ctrl={true} shift={true} />
|
<Shortcut key="⌫" ctrl={true} shift={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
<Menubar.Item
|
<Menubar.Item
|
||||||
on:click={() => ($exportState = ExportState.SELECTION)}
|
onclick={() => (exportState.current = ExportState.SELECTION)}
|
||||||
disabled={$selection.size == 0}
|
disabled={$selection.size == 0}
|
||||||
>
|
>
|
||||||
<Download size="16" class="mr-1" />
|
<Download size="16" />
|
||||||
{$_('menu.export')}
|
{i18n._('menu.export')}
|
||||||
<Shortcut key="S" ctrl={true} />
|
<Shortcut key="S" ctrl={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
<Menubar.Item
|
<Menubar.Item
|
||||||
on:click={() => ($exportState = ExportState.ALL)}
|
onclick={() => (exportState.current = ExportState.ALL)}
|
||||||
disabled={$fileObservers.size == 0}
|
disabled={fileStateCollection.size == 0}
|
||||||
>
|
>
|
||||||
<Download size="16" class="mr-1" />
|
<Download size="16" />
|
||||||
{$_('menu.export_all')}
|
{i18n._('menu.export_all')}
|
||||||
<Shortcut key="S" ctrl={true} shift={true} />
|
<Shortcut key="S" ctrl={true} shift={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
</Menubar.Content>
|
</Menubar.Content>
|
||||||
</Menubar.Menu>
|
</Menubar.Menu>
|
||||||
<Menubar.Menu>
|
<Menubar.Menu>
|
||||||
<Menubar.Trigger aria-label={$_('menu.edit')}>
|
<Menubar.Trigger aria-label={i18n._('menu.edit')}>
|
||||||
<FilePen size="18" class="md:hidden" />
|
<FilePen size="18" class="md:hidden" />
|
||||||
<span class="hidden md:block">{$_('menu.edit')}</span>
|
<span class="hidden md:block">{i18n._('menu.edit')}</span>
|
||||||
</Menubar.Trigger>
|
</Menubar.Trigger>
|
||||||
<Menubar.Content class="border-none">
|
<Menubar.Content class="border-none">
|
||||||
<Menubar.Item on:click={dbUtils.undo} disabled={$undoDisabled}>
|
<Menubar.Item onclick={() => fileActionManager.undo()} disabled={!$canUndo}>
|
||||||
<Undo2 size="16" class="mr-1" />
|
<Undo2 size="16" />
|
||||||
{$_('menu.undo')}
|
{i18n._('menu.undo')}
|
||||||
<Shortcut key="Z" ctrl={true} />
|
<Shortcut key="Z" ctrl={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
<Menubar.Item on:click={dbUtils.redo} disabled={$redoDisabled}>
|
<Menubar.Item onclick={() => fileActionManager.redo()} disabled={!$canRedo}>
|
||||||
<Redo2 size="16" class="mr-1" />
|
<Redo2 size="16" />
|
||||||
{$_('menu.redo')}
|
{i18n._('menu.redo')}
|
||||||
<Shortcut key="Z" ctrl={true} shift={true} />
|
<Shortcut key="Z" ctrl={true} shift={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
@@ -221,10 +204,10 @@
|
|||||||
item instanceof ListFileItem ||
|
item instanceof ListFileItem ||
|
||||||
item instanceof ListTrackItem
|
item instanceof ListTrackItem
|
||||||
)}
|
)}
|
||||||
on:click={() => ($editMetadata = true)}
|
onclick={() => (editMetadata.current = true)}
|
||||||
>
|
>
|
||||||
<Info size="16" class="mr-1" />
|
<Info size="16" />
|
||||||
{$_('menu.metadata.button')}
|
{i18n._('menu.metadata.button')}
|
||||||
<Shortcut key="I" ctrl={true} />
|
<Shortcut key="I" ctrl={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
<Menubar.Item
|
<Menubar.Item
|
||||||
@@ -236,27 +219,27 @@
|
|||||||
item instanceof ListFileItem ||
|
item instanceof ListFileItem ||
|
||||||
item instanceof ListTrackItem
|
item instanceof ListTrackItem
|
||||||
)}
|
)}
|
||||||
on:click={() => ($editStyle = true)}
|
onclick={() => (editStyle.current = true)}
|
||||||
>
|
>
|
||||||
<PaintBucket size="16" class="mr-1" />
|
<PaintBucket size="16" />
|
||||||
{$_('menu.style.button')}
|
{i18n._('menu.style.button')}
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
<Menubar.Item
|
<Menubar.Item
|
||||||
on:click={() => {
|
onclick={() => {
|
||||||
if ($allHidden) {
|
if ($allHidden) {
|
||||||
dbUtils.setHiddenToSelection(false);
|
fileActions.setHiddenToSelection(false);
|
||||||
} else {
|
} else {
|
||||||
dbUtils.setHiddenToSelection(true);
|
fileActions.setHiddenToSelection(true);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={$selection.size == 0}
|
disabled={$selection.size == 0}
|
||||||
>
|
>
|
||||||
{#if $allHidden}
|
{#if $allHidden}
|
||||||
<Eye size="16" class="mr-1" />
|
<Eye size="16" />
|
||||||
{$_('menu.unhide')}
|
{i18n._('menu.unhide')}
|
||||||
{:else}
|
{:else}
|
||||||
<EyeOff size="16" class="mr-1" />
|
<EyeOff size="16" />
|
||||||
{$_('menu.hide')}
|
{i18n._('menu.hide')}
|
||||||
{/if}
|
{/if}
|
||||||
<Shortcut key="H" ctrl={true} />
|
<Shortcut key="H" ctrl={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
@@ -264,56 +247,71 @@
|
|||||||
{#if $selection.getSelected().some((item) => item instanceof ListFileItem)}
|
{#if $selection.getSelected().some((item) => item instanceof ListFileItem)}
|
||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
<Menubar.Item
|
<Menubar.Item
|
||||||
on:click={() =>
|
onclick={() =>
|
||||||
dbUtils.addNewTrack($selection.getSelected()[0].getFileId())}
|
fileActions.addNewTrack(
|
||||||
|
$selection.getSelected()[0].getFileId()
|
||||||
|
)}
|
||||||
disabled={$selection.size !== 1}
|
disabled={$selection.size !== 1}
|
||||||
>
|
>
|
||||||
<Plus size="16" class="mr-1" />
|
<Plus size="16" />
|
||||||
{$_('menu.new_track')}
|
{i18n._('menu.new_track')}
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
{:else if $selection
|
{:else if $selection
|
||||||
.getSelected()
|
.getSelected()
|
||||||
.some((item) => item instanceof ListTrackItem)}
|
.some((item) => item instanceof ListTrackItem)}
|
||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
<Menubar.Item
|
<Menubar.Item
|
||||||
on:click={() => {
|
onclick={() => {
|
||||||
let item = $selection.getSelected()[0];
|
let item = $selection.getSelected()[0];
|
||||||
dbUtils.addNewSegment(item.getFileId(), item.getTrackIndex());
|
fileActions.addNewSegment(
|
||||||
|
item.getFileId(),
|
||||||
|
item.getTrackIndex()
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
disabled={$selection.size !== 1}
|
disabled={$selection.size !== 1}
|
||||||
>
|
>
|
||||||
<Plus size="16" class="mr-1" />
|
<Plus size="16" />
|
||||||
{$_('menu.new_segment')}
|
{i18n._('menu.new_segment')}
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
<Menubar.Item on:click={selectAll} disabled={$fileObservers.size == 0}>
|
<Menubar.Item
|
||||||
<FileStack size="16" class="mr-1" />
|
onclick={() => selection.selectAll()}
|
||||||
{$_('menu.select_all')}
|
disabled={fileStateCollection.size == 0}
|
||||||
|
>
|
||||||
|
<FileStack size="16" />
|
||||||
|
{i18n._('menu.select_all')}
|
||||||
<Shortcut key="A" ctrl={true} />
|
<Shortcut key="A" ctrl={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
<Menubar.Item
|
<Menubar.Item
|
||||||
on:click={() => {
|
onclick={() => {
|
||||||
if ($selection.size > 0) {
|
if ($selection.size > 0) {
|
||||||
centerMapOnSelection();
|
boundsManager.centerMapOnSelection();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
disabled={$selection.size == 0}
|
||||||
>
|
>
|
||||||
<Maximize size="16" class="mr-1" />
|
<Maximize size="16" />
|
||||||
{$_('menu.center')}
|
{i18n._('menu.center')}
|
||||||
<Shortcut key="⏎" ctrl={true} />
|
<Shortcut key="⏎" ctrl={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
{#if $treeFileView}
|
{#if $treeFileView}
|
||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
<Menubar.Item on:click={copySelection} disabled={$selection.size === 0}>
|
<Menubar.Item
|
||||||
<ClipboardCopy size="16" class="mr-1" />
|
onclick={() => selection.copySelection()}
|
||||||
{$_('menu.copy')}
|
disabled={$selection.size === 0}
|
||||||
|
>
|
||||||
|
<ClipboardCopy size="16" />
|
||||||
|
{i18n._('menu.copy')}
|
||||||
<Shortcut key="C" ctrl={true} />
|
<Shortcut key="C" ctrl={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
<Menubar.Item on:click={cutSelection} disabled={$selection.size === 0}>
|
<Menubar.Item
|
||||||
<Scissors size="16" class="mr-1" />
|
onclick={() => selection.cutSelection()}
|
||||||
{$_('menu.cut')}
|
disabled={$selection.size === 0}
|
||||||
|
>
|
||||||
|
<Scissors size="16" />
|
||||||
|
{i18n._('menu.cut')}
|
||||||
<Shortcut key="X" ctrl={true} />
|
<Shortcut key="X" ctrl={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
<Menubar.Item
|
<Menubar.Item
|
||||||
@@ -321,124 +319,118 @@
|
|||||||
$copied.length === 0 ||
|
$copied.length === 0 ||
|
||||||
($selection.size > 0 &&
|
($selection.size > 0 &&
|
||||||
!allowedPastes[$copied[0].level].includes(
|
!allowedPastes[$copied[0].level].includes(
|
||||||
$selection.getSelected().pop()?.level
|
$selection.getSelected().pop()!.level
|
||||||
))}
|
))}
|
||||||
on:click={pasteSelection}
|
onclick={pasteSelection}
|
||||||
>
|
>
|
||||||
<ClipboardPaste size="16" class="mr-1" />
|
<ClipboardPaste size="16" />
|
||||||
{$_('menu.paste')}
|
{i18n._('menu.paste')}
|
||||||
<Shortcut key="V" ctrl={true} />
|
<Shortcut key="V" ctrl={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
{/if}
|
{/if}
|
||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
<Menubar.Item
|
<Menubar.Item
|
||||||
on:click={dbUtils.deleteSelection}
|
onclick={() => tick().then(fileActions.deleteSelection)}
|
||||||
disabled={$selection.size == 0}
|
disabled={$selection.size == 0}
|
||||||
>
|
>
|
||||||
<Trash2 size="16" class="mr-1" />
|
<Trash2 size="16" />
|
||||||
{$_('menu.delete')}
|
{i18n._('menu.delete')}
|
||||||
<Shortcut key="⌫" ctrl={true} />
|
<Shortcut key="⌫" ctrl={true} />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
</Menubar.Content>
|
</Menubar.Content>
|
||||||
</Menubar.Menu>
|
</Menubar.Menu>
|
||||||
<Menubar.Menu>
|
<Menubar.Menu>
|
||||||
<Menubar.Trigger aria-label={$_('menu.view')}>
|
<Menubar.Trigger aria-label={i18n._('menu.view')}>
|
||||||
<View size="18" class="md:hidden" />
|
<View size="18" class="md:hidden" />
|
||||||
<span class="hidden md:block">{$_('menu.view')}</span>
|
<span class="hidden md:block">{i18n._('menu.view')}</span>
|
||||||
</Menubar.Trigger>
|
</Menubar.Trigger>
|
||||||
<Menubar.Content class="border-none">
|
<Menubar.Content class="border-none">
|
||||||
<Menubar.CheckboxItem bind:checked={$elevationProfile}>
|
<Menubar.CheckboxItem bind:checked={$elevationProfile}>
|
||||||
<ChartArea size="16" class="mr-1" />
|
<ChartArea size="16" />
|
||||||
{$_('menu.elevation_profile')}
|
{i18n._('menu.elevation_profile')}
|
||||||
<Shortcut key="P" ctrl={true} />
|
<Shortcut key="P" ctrl={true} />
|
||||||
</Menubar.CheckboxItem>
|
</Menubar.CheckboxItem>
|
||||||
<Menubar.CheckboxItem bind:checked={$treeFileView}>
|
<Menubar.CheckboxItem bind:checked={$treeFileView}>
|
||||||
<ListTree size="16" class="mr-1" />
|
<ListTree size="16" />
|
||||||
{$_('menu.tree_file_view')}
|
{i18n._('menu.tree_file_view')}
|
||||||
<Shortcut key="L" ctrl={true} />
|
<Shortcut key="L" ctrl={true} />
|
||||||
</Menubar.CheckboxItem>
|
</Menubar.CheckboxItem>
|
||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
<Menubar.Item inset on:click={switchBasemaps}>
|
<Menubar.Item inset onclick={switchBasemaps}>
|
||||||
<Map size="16" class="mr-1" />{$_('menu.switch_basemap')}<Shortcut
|
<Map size="16" />{i18n._('menu.switch_basemap')}<Shortcut key="F1" />
|
||||||
key="F1"
|
|
||||||
/>
|
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
<Menubar.Item inset on:click={toggleOverlays}>
|
<Menubar.Item inset onclick={toggleOverlays}>
|
||||||
<Layers2 size="16" class="mr-1" />{$_('menu.toggle_overlays')}<Shortcut
|
<Layers2 size="16" />{i18n._('menu.toggle_overlays')}<Shortcut key="F2" />
|
||||||
key="F2"
|
|
||||||
/>
|
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
<Menubar.CheckboxItem bind:checked={$distanceMarkers}>
|
<Menubar.CheckboxItem bind:checked={$distanceMarkers}>
|
||||||
<Coins size="16" class="mr-1" />{$_('menu.distance_markers')}<Shortcut
|
<Coins size="16" />{i18n._('menu.distance_markers')}<Shortcut key="F3" />
|
||||||
key="F3"
|
|
||||||
/>
|
|
||||||
</Menubar.CheckboxItem>
|
</Menubar.CheckboxItem>
|
||||||
<Menubar.CheckboxItem bind:checked={$directionMarkers}>
|
<Menubar.CheckboxItem bind:checked={$directionMarkers}>
|
||||||
<Milestone size="16" class="mr-1" />{$_('menu.direction_markers')}<Shortcut
|
<Milestone size="16" />{i18n._('menu.direction_markers')}<Shortcut
|
||||||
key="F4"
|
key="F4"
|
||||||
/>
|
/>
|
||||||
</Menubar.CheckboxItem>
|
</Menubar.CheckboxItem>
|
||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
<Menubar.Item inset on:click={toggle3D}>
|
<Menubar.Item inset onclick={() => map.toggle3D()}>
|
||||||
<Box size="16" class="mr-1" />
|
<Box size="16" />
|
||||||
{$_('menu.toggle_3d')}
|
{i18n._('menu.toggle_3d')}
|
||||||
<Shortcut key="{$_('menu.ctrl')}+{$_('menu.drag')}" />
|
<Shortcut key="{i18n._('menu.ctrl')} {i18n._('menu.drag')}" />
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
</Menubar.Content>
|
</Menubar.Content>
|
||||||
</Menubar.Menu>
|
</Menubar.Menu>
|
||||||
<Menubar.Menu>
|
<Menubar.Menu>
|
||||||
<Menubar.Trigger aria-label={$_('menu.settings')}>
|
<Menubar.Trigger aria-label={i18n._('menu.settings')}>
|
||||||
<Settings size="18" class="md:hidden" />
|
<Settings size="18" class="md:hidden" />
|
||||||
<span class="hidden md:block">
|
<span class="hidden md:block">
|
||||||
{$_('menu.settings')}
|
{i18n._('menu.settings')}
|
||||||
</span>
|
</span>
|
||||||
</Menubar.Trigger>
|
</Menubar.Trigger>
|
||||||
<Menubar.Content class="border-none">
|
<Menubar.Content class="border-none">
|
||||||
<Menubar.Sub>
|
<Menubar.Sub>
|
||||||
<Menubar.SubTrigger>
|
<Menubar.SubTrigger>
|
||||||
<Ruler size="16" class="mr-1" />{$_('menu.distance_units')}
|
<Ruler size="16" class="mr-2" />{i18n._('menu.distance_units')}
|
||||||
</Menubar.SubTrigger>
|
</Menubar.SubTrigger>
|
||||||
<Menubar.SubContent>
|
<Menubar.SubContent>
|
||||||
<Menubar.RadioGroup bind:value={$distanceUnits}>
|
<Menubar.RadioGroup bind:value={$distanceUnits}>
|
||||||
<Menubar.RadioItem value="metric"
|
<Menubar.RadioItem value="metric"
|
||||||
>{$_('menu.metric')}</Menubar.RadioItem
|
>{i18n._('menu.metric')}</Menubar.RadioItem
|
||||||
>
|
>
|
||||||
<Menubar.RadioItem value="imperial"
|
<Menubar.RadioItem value="imperial"
|
||||||
>{$_('menu.imperial')}</Menubar.RadioItem
|
>{i18n._('menu.imperial')}</Menubar.RadioItem
|
||||||
>
|
>
|
||||||
<Menubar.RadioItem value="nautical"
|
<Menubar.RadioItem value="nautical"
|
||||||
>{$_('menu.nautical')}</Menubar.RadioItem
|
>{i18n._('menu.nautical')}</Menubar.RadioItem
|
||||||
>
|
>
|
||||||
</Menubar.RadioGroup>
|
</Menubar.RadioGroup>
|
||||||
</Menubar.SubContent>
|
</Menubar.SubContent>
|
||||||
</Menubar.Sub>
|
</Menubar.Sub>
|
||||||
<Menubar.Sub>
|
<Menubar.Sub>
|
||||||
<Menubar.SubTrigger>
|
<Menubar.SubTrigger>
|
||||||
<Zap size="16" class="mr-1" />{$_('menu.velocity_units')}
|
<Zap size="16" class="mr-2" />{i18n._('menu.velocity_units')}
|
||||||
</Menubar.SubTrigger>
|
</Menubar.SubTrigger>
|
||||||
<Menubar.SubContent>
|
<Menubar.SubContent>
|
||||||
<Menubar.RadioGroup bind:value={$velocityUnits}>
|
<Menubar.RadioGroup bind:value={$velocityUnits}>
|
||||||
<Menubar.RadioItem value="speed"
|
<Menubar.RadioItem value="speed"
|
||||||
>{$_('quantities.speed')}</Menubar.RadioItem
|
>{i18n._('quantities.speed')}</Menubar.RadioItem
|
||||||
>
|
>
|
||||||
<Menubar.RadioItem value="pace"
|
<Menubar.RadioItem value="pace"
|
||||||
>{$_('quantities.pace')}</Menubar.RadioItem
|
>{i18n._('quantities.pace')}</Menubar.RadioItem
|
||||||
>
|
>
|
||||||
</Menubar.RadioGroup>
|
</Menubar.RadioGroup>
|
||||||
</Menubar.SubContent>
|
</Menubar.SubContent>
|
||||||
</Menubar.Sub>
|
</Menubar.Sub>
|
||||||
<Menubar.Sub>
|
<Menubar.Sub>
|
||||||
<Menubar.SubTrigger>
|
<Menubar.SubTrigger>
|
||||||
<Thermometer size="16" class="mr-1" />{$_('menu.temperature_units')}
|
<Thermometer size="16" class="mr-2" />{i18n._('menu.temperature_units')}
|
||||||
</Menubar.SubTrigger>
|
</Menubar.SubTrigger>
|
||||||
<Menubar.SubContent>
|
<Menubar.SubContent>
|
||||||
<Menubar.RadioGroup bind:value={$temperatureUnits}>
|
<Menubar.RadioGroup bind:value={$temperatureUnits}>
|
||||||
<Menubar.RadioItem value="celsius"
|
<Menubar.RadioItem value="celsius"
|
||||||
>{$_('menu.celsius')}</Menubar.RadioItem
|
>{i18n._('menu.celsius')}</Menubar.RadioItem
|
||||||
>
|
>
|
||||||
<Menubar.RadioItem value="fahrenheit"
|
<Menubar.RadioItem value="fahrenheit"
|
||||||
>{$_('menu.fahrenheit')}</Menubar.RadioItem
|
>{i18n._('menu.fahrenheit')}</Menubar.RadioItem
|
||||||
>
|
>
|
||||||
</Menubar.RadioGroup>
|
</Menubar.RadioGroup>
|
||||||
</Menubar.SubContent>
|
</Menubar.SubContent>
|
||||||
@@ -446,11 +438,11 @@
|
|||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
<Menubar.Sub>
|
<Menubar.Sub>
|
||||||
<Menubar.SubTrigger>
|
<Menubar.SubTrigger>
|
||||||
<Languages size="16" class="mr-1" />
|
<Languages size="16" class="mr-2" />
|
||||||
{$_('menu.language')}
|
{i18n._('menu.language')}
|
||||||
</Menubar.SubTrigger>
|
</Menubar.SubTrigger>
|
||||||
<Menubar.SubContent>
|
<Menubar.SubContent>
|
||||||
<Menubar.RadioGroup bind:value={$locale}>
|
<Menubar.RadioGroup value={i18n.lang}>
|
||||||
{#each Object.entries(languages) as [lang, label]}
|
{#each Object.entries(languages) as [lang, label]}
|
||||||
<a href={getURLForLanguage(lang, '/app')}>
|
<a href={getURLForLanguage(lang, '/app')}>
|
||||||
<Menubar.RadioItem value={lang}>{label}</Menubar.RadioItem>
|
<Menubar.RadioItem value={lang}>{label}</Menubar.RadioItem>
|
||||||
@@ -461,24 +453,25 @@
|
|||||||
</Menubar.Sub>
|
</Menubar.Sub>
|
||||||
<Menubar.Sub>
|
<Menubar.Sub>
|
||||||
<Menubar.SubTrigger>
|
<Menubar.SubTrigger>
|
||||||
{#if selectedMode === 'light'}
|
{#if mode.current === 'light' || !mode.current}
|
||||||
<Sun size="16" class="mr-1" />
|
<Sun size="16" class="mr-2" />
|
||||||
{:else}
|
{:else}
|
||||||
<Moon size="16" class="mr-1" />
|
<Moon size="16" class="mr-2" />
|
||||||
{/if}
|
{/if}
|
||||||
{$_('menu.mode')}
|
{i18n._('menu.mode')}
|
||||||
</Menubar.SubTrigger>
|
</Menubar.SubTrigger>
|
||||||
<Menubar.SubContent>
|
<Menubar.SubContent>
|
||||||
<Menubar.RadioGroup
|
<Menubar.RadioGroup
|
||||||
bind:value={selectedMode}
|
value={mode.current ?? 'light'}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
setMode(value);
|
setMode(value as 'light' | 'dark');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Menubar.RadioItem value="light"
|
<Menubar.RadioItem value="light"
|
||||||
>{$_('menu.light')}</Menubar.RadioItem
|
>{i18n._('menu.light')}</Menubar.RadioItem
|
||||||
>
|
>
|
||||||
<Menubar.RadioItem value="dark">{$_('menu.dark')}</Menubar.RadioItem
|
<Menubar.RadioItem value="dark"
|
||||||
|
>{i18n._('menu.dark')}</Menubar.RadioItem
|
||||||
>
|
>
|
||||||
</Menubar.RadioGroup>
|
</Menubar.RadioGroup>
|
||||||
</Menubar.SubContent>
|
</Menubar.SubContent>
|
||||||
@@ -486,23 +479,23 @@
|
|||||||
<Menubar.Separator />
|
<Menubar.Separator />
|
||||||
<Menubar.Sub>
|
<Menubar.Sub>
|
||||||
<Menubar.SubTrigger>
|
<Menubar.SubTrigger>
|
||||||
<PersonStanding size="16" class="mr-1" />
|
<PersonStanding size="16" class="mr-2" />
|
||||||
{$_('menu.street_view_source')}
|
{i18n._('menu.street_view_source')}
|
||||||
</Menubar.SubTrigger>
|
</Menubar.SubTrigger>
|
||||||
<Menubar.SubContent>
|
<Menubar.SubContent>
|
||||||
<Menubar.RadioGroup bind:value={$streetViewSource}>
|
<Menubar.RadioGroup bind:value={$streetViewSource}>
|
||||||
<Menubar.RadioItem value="mapillary"
|
<Menubar.RadioItem value="mapillary"
|
||||||
>{$_('menu.mapillary')}</Menubar.RadioItem
|
>{i18n._('menu.mapillary')}</Menubar.RadioItem
|
||||||
>
|
>
|
||||||
<Menubar.RadioItem value="google"
|
<Menubar.RadioItem value="google"
|
||||||
>{$_('menu.google')}</Menubar.RadioItem
|
>{i18n._('menu.google')}</Menubar.RadioItem
|
||||||
>
|
>
|
||||||
</Menubar.RadioGroup>
|
</Menubar.RadioGroup>
|
||||||
</Menubar.SubContent>
|
</Menubar.SubContent>
|
||||||
</Menubar.Sub>
|
</Menubar.Sub>
|
||||||
<Menubar.Item on:click={() => (layerSettingsOpen = true)}>
|
<Menubar.Item onclick={() => (layerSettingsOpen = true)}>
|
||||||
<Layers size="16" class="mr-1" />
|
<Layers size="16" />
|
||||||
{$_('menu.layers')}
|
{i18n._('menu.layers')}
|
||||||
</Menubar.Item>
|
</Menubar.Item>
|
||||||
</Menubar.Content>
|
</Menubar.Content>
|
||||||
</Menubar.Menu>
|
</Menubar.Menu>
|
||||||
@@ -513,11 +506,11 @@
|
|||||||
href="./help"
|
href="./help"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="cursor-default h-fit rounded-sm px-3 py-0.5"
|
class="cursor-default h-fit rounded-sm px-3 py-0.5"
|
||||||
aria-label={$_('menu.help')}
|
aria-label={i18n._('menu.help')}
|
||||||
>
|
>
|
||||||
<BookOpenText size="18" class="md:hidden" />
|
<BookOpenText size="18" class="md:hidden" />
|
||||||
<span class="hidden md:block">
|
<span class="hidden md:block">
|
||||||
{$_('menu.help')}
|
{i18n._('menu.help')}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -525,12 +518,12 @@
|
|||||||
href="https://ko-fi.com/gpxstudio"
|
href="https://ko-fi.com/gpxstudio"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="cursor-default h-fit rounded-sm font-bold text-support hover:text-support px-3 py-0.5"
|
class="cursor-default h-fit rounded-sm font-bold text-support hover:text-support px-3 py-0.5"
|
||||||
aria-label={$_('menu.donate')}
|
aria-label={i18n._('menu.donate')}
|
||||||
>
|
>
|
||||||
<HeartHandshake size="18" class="md:hidden" />
|
<HeartHandshake size="18" class="md:hidden" />
|
||||||
<span class="hidden md:flex flex-row items-center">
|
<span class="hidden md:flex flex-row items-center">
|
||||||
{$_('menu.donate')}
|
{i18n._('menu.donate')}
|
||||||
<Heart size="16" class="ml-1" fill="rgb(var(--support))" />
|
<Heart size="16" class="ml-1" fill="var(--support)" />
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -543,15 +536,17 @@
|
|||||||
<svelte:window
|
<svelte:window
|
||||||
on:keydown={(e) => {
|
on:keydown={(e) => {
|
||||||
let targetInput =
|
let targetInput =
|
||||||
e.target.tagName === 'INPUT' ||
|
e &&
|
||||||
e.target.tagName === 'TEXTAREA' ||
|
e.target &&
|
||||||
e.target.tagName === 'SELECT' ||
|
(e.target.tagName === 'INPUT' ||
|
||||||
e.target.role === 'combobox' ||
|
e.target.tagName === 'TEXTAREA' ||
|
||||||
e.target.role === 'radio' ||
|
e.target.tagName === 'SELECT' ||
|
||||||
e.target.role === 'menu' ||
|
e.target.role === 'combobox' ||
|
||||||
e.target.role === 'menuitem' ||
|
e.target.role === 'radio' ||
|
||||||
e.target.role === 'menuitemradio' ||
|
e.target.role === 'menu' ||
|
||||||
e.target.role === 'menuitemcheckbox';
|
e.target.role === 'menuitem' ||
|
||||||
|
e.target.role === 'menuitemradio' ||
|
||||||
|
e.target.role === 'menuitemcheckbox');
|
||||||
|
|
||||||
if (e.key === '+' && (e.metaKey || e.ctrlKey)) {
|
if (e.key === '+' && (e.metaKey || e.ctrlKey)) {
|
||||||
createFile();
|
createFile();
|
||||||
@@ -560,16 +555,16 @@
|
|||||||
triggerFileInput();
|
triggerFileInput();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
} else if (e.key === 'd' && (e.metaKey || e.ctrlKey)) {
|
} else if (e.key === 'd' && (e.metaKey || e.ctrlKey)) {
|
||||||
dbUtils.duplicateSelection();
|
fileActions.duplicateSelection();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
} else if (e.key === 'c' && (e.metaKey || e.ctrlKey)) {
|
} else if (e.key === 'c' && (e.metaKey || e.ctrlKey)) {
|
||||||
if (!targetInput) {
|
if (!targetInput) {
|
||||||
copySelection();
|
selection.copySelection();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
} else if (e.key === 'x' && (e.metaKey || e.ctrlKey)) {
|
} else if (e.key === 'x' && (e.metaKey || e.ctrlKey)) {
|
||||||
if (!targetInput) {
|
if (!targetInput) {
|
||||||
cutSelection();
|
selection.cutSelection();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
} else if (e.key === 'v' && (e.metaKey || e.ctrlKey)) {
|
} else if (e.key === 'v' && (e.metaKey || e.ctrlKey)) {
|
||||||
@@ -579,32 +574,32 @@
|
|||||||
}
|
}
|
||||||
} else if ((e.key === 's' || e.key == 'S') && (e.metaKey || e.ctrlKey)) {
|
} else if ((e.key === 's' || e.key == 'S') && (e.metaKey || e.ctrlKey)) {
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
if ($fileObservers.size > 0) {
|
if (fileStateCollection.size > 0) {
|
||||||
$exportState = ExportState.ALL;
|
exportState.current = ExportState.ALL;
|
||||||
}
|
}
|
||||||
} else if ($selection.size > 0) {
|
} else if ($selection.size > 0) {
|
||||||
$exportState = ExportState.SELECTION;
|
exportState.current = ExportState.SELECTION;
|
||||||
}
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
} else if ((e.key === 'z' || e.key == 'Z') && (e.metaKey || e.ctrlKey)) {
|
} else if ((e.key === 'z' || e.key == 'Z') && (e.metaKey || e.ctrlKey)) {
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
dbUtils.redo();
|
fileActionManager.redo();
|
||||||
} else {
|
} else {
|
||||||
dbUtils.undo();
|
fileActionManager.undo();
|
||||||
}
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
} else if ((e.key === 'Backspace' || e.key === 'Delete') && (e.metaKey || e.ctrlKey)) {
|
} else if ((e.key === 'Backspace' || e.key === 'Delete') && (e.metaKey || e.ctrlKey)) {
|
||||||
if (!targetInput) {
|
if (!targetInput) {
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
dbUtils.deleteAllFiles();
|
fileActions.deleteAllFiles();
|
||||||
} else {
|
} else {
|
||||||
dbUtils.deleteSelection();
|
fileActions.deleteSelection();
|
||||||
}
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
} else if (e.key === 'a' && (e.metaKey || e.ctrlKey)) {
|
} else if (e.key === 'a' && (e.metaKey || e.ctrlKey)) {
|
||||||
if (!targetInput) {
|
if (!targetInput) {
|
||||||
selectAll();
|
selection.selectAll();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
} else if (e.key === 'i' && (e.metaKey || e.ctrlKey)) {
|
} else if (e.key === 'i' && (e.metaKey || e.ctrlKey)) {
|
||||||
@@ -614,7 +609,7 @@
|
|||||||
.getSelected()
|
.getSelected()
|
||||||
.every((item) => item instanceof ListFileItem || item instanceof ListTrackItem)
|
.every((item) => item instanceof ListFileItem || item instanceof ListTrackItem)
|
||||||
) {
|
) {
|
||||||
$editMetadata = true;
|
editMetadata.current = true;
|
||||||
}
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
} else if (e.key === 'p' && (e.metaKey || e.ctrlKey)) {
|
} else if (e.key === 'p' && (e.metaKey || e.ctrlKey)) {
|
||||||
@@ -625,14 +620,14 @@
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
} else if (e.key === 'h' && (e.metaKey || e.ctrlKey)) {
|
} else if (e.key === 'h' && (e.metaKey || e.ctrlKey)) {
|
||||||
if ($allHidden) {
|
if ($allHidden) {
|
||||||
dbUtils.setHiddenToSelection(false);
|
fileActions.setHiddenToSelection(false);
|
||||||
} else {
|
} else {
|
||||||
dbUtils.setHiddenToSelection(true);
|
fileActions.setHiddenToSelection(true);
|
||||||
}
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
} else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
} else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||||
if ($selection.size > 0) {
|
if ($selection.size > 0) {
|
||||||
centerMapOnSelection();
|
boundsManager.centerMapOnSelection();
|
||||||
}
|
}
|
||||||
} else if (e.key === 'F1') {
|
} else if (e.key === 'F1') {
|
||||||
switchBasemaps();
|
switchBasemaps();
|
||||||
@@ -656,7 +651,10 @@
|
|||||||
e.key === 'ArrowUp'
|
e.key === 'ArrowUp'
|
||||||
) {
|
) {
|
||||||
if (!targetInput) {
|
if (!targetInput) {
|
||||||
updateSelectionFromKey(e.key === 'ArrowRight' || e.key === 'ArrowDown', e.shiftKey);
|
selection.updateFromKey(
|
||||||
|
e.key === 'ArrowRight' || e.key === 'ArrowDown',
|
||||||
|
e.shiftKey
|
||||||
|
);
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -664,13 +662,15 @@
|
|||||||
on:dragover={(e) => e.preventDefault()}
|
on:dragover={(e) => e.preventDefault()}
|
||||||
on:drop={(e) => {
|
on:drop={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (e.dataTransfer.files.length > 0) {
|
if (e.dataTransfer && e.dataTransfer.files.length > 0) {
|
||||||
loadFiles(e.dataTransfer.files);
|
loadFiles(e.dataTransfer.files);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<style lang="postcss">
|
<style lang="postcss">
|
||||||
|
@reference "../../app.css";
|
||||||
|
|
||||||
div :global(button) {
|
div :global(button) {
|
||||||
@apply hover:bg-accent;
|
@apply hover:bg-accent;
|
||||||
@apply px-3;
|
@apply px-3;
|
||||||
|
|||||||
@@ -1,25 +1,28 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Moon, Sun } from 'lucide-svelte';
|
import { Moon, Sun } from '@lucide/svelte';
|
||||||
import { mode, setMode, systemPrefersMode } from 'mode-watcher';
|
import { mode, setMode } from 'mode-watcher';
|
||||||
import { _ } from 'svelte-i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
|
|
||||||
export let size = '20';
|
let {
|
||||||
|
class: className = '',
|
||||||
$: selectedMode = $mode ?? $systemPrefersMode ?? 'light';
|
}: {
|
||||||
|
class?: string;
|
||||||
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="h-8 px-1.5 {$$props.class ?? ''}"
|
size="icon"
|
||||||
on:click={() => {
|
class={className}
|
||||||
setMode(selectedMode === 'light' ? 'dark' : 'light');
|
onclick={() => {
|
||||||
|
setMode(mode.current === 'light' ? 'dark' : 'light');
|
||||||
}}
|
}}
|
||||||
aria-label={$_('menu.mode')}
|
aria-label={i18n._('menu.mode')}
|
||||||
>
|
>
|
||||||
{#if selectedMode === 'light'}
|
{#if mode.current === 'light'}
|
||||||
<Sun {size} />
|
<Sun />
|
||||||
{:else}
|
{:else}
|
||||||
<Moon {size} />
|
<Moon />
|
||||||
{/if}
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -3,30 +3,43 @@
|
|||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import AlgoliaDocSearch from '$lib/components/AlgoliaDocSearch.svelte';
|
import AlgoliaDocSearch from '$lib/components/AlgoliaDocSearch.svelte';
|
||||||
import ModeSwitch from '$lib/components/ModeSwitch.svelte';
|
import ModeSwitch from '$lib/components/ModeSwitch.svelte';
|
||||||
import { BookOpenText, Home, Map } from 'lucide-svelte';
|
import { BookOpenText, House, Map } from '@lucide/svelte';
|
||||||
import { _, locale } from 'svelte-i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import { getURLForLanguage } from '$lib/utils';
|
import { getURLForLanguage } from '$lib/utils';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<nav class="w-full sticky top-0 bg-background z-50">
|
<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">
|
<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">
|
<a href={getURLForLanguage(i18n.lang, '/')} class="shrink-0 translate-y-0.5">
|
||||||
<Logo class="h-8 sm:hidden" iconOnly={true} width="26" />
|
<Logo class="h-8 sm:hidden" iconOnly={true} width="26" />
|
||||||
<Logo class="h-8 hidden sm:block" width="153" />
|
<Logo class="h-8 hidden sm:block" width="153" />
|
||||||
</a>
|
</a>
|
||||||
<Button variant="link" class="text-base px-0" href={getURLForLanguage($locale, '/')}>
|
<Button
|
||||||
<Home size="18" class="mr-1.5" />
|
variant="link"
|
||||||
{$_('homepage.home')}
|
class="text-base px-0 has-[>svg]:px-0"
|
||||||
|
href={getURLForLanguage(i18n.lang, '/')}
|
||||||
|
>
|
||||||
|
<House size="18" />
|
||||||
|
{i18n._('homepage.home')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="link" class="text-base px-0" href={getURLForLanguage($locale, '/app')}>
|
<Button
|
||||||
<Map size="18" class="mr-1.5" />
|
data-sveltekit-reload
|
||||||
{$_('homepage.app')}
|
variant="link"
|
||||||
|
class="text-base px-0 has-[>svg]:px-0"
|
||||||
|
href={getURLForLanguage(i18n.lang, '/app')}
|
||||||
|
>
|
||||||
|
<Map size="18" />
|
||||||
|
{i18n._('homepage.app')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="link" class="text-base px-0" href={getURLForLanguage($locale, '/help')}>
|
<Button
|
||||||
<BookOpenText size="18" class="mr-1.5" />
|
variant="link"
|
||||||
{$_('menu.help')}
|
class="text-base px-0 has-[>svg]:px-0"
|
||||||
|
href={getURLForLanguage(i18n.lang, '/help')}
|
||||||
|
>
|
||||||
|
<BookOpenText size="18" />
|
||||||
|
{i18n._('menu.help')}
|
||||||
</Button>
|
</Button>
|
||||||
<AlgoliaDocSearch class="ml-auto" />
|
<AlgoliaDocSearch class="ml-auto" />
|
||||||
<ModeSwitch class="hidden xs:block" />
|
<ModeSwitch class="hidden xs:inline-flex" />
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export let orientation: 'col' | 'row' = 'col';
|
let {
|
||||||
|
orientation = 'col',
|
||||||
export let after: number;
|
after = $bindable(),
|
||||||
export let minAfter: number = 0;
|
minAfter = 0,
|
||||||
export let maxAfter: number = Number.MAX_SAFE_INTEGER;
|
maxAfter = Number.MAX_SAFE_INTEGER,
|
||||||
|
}: {
|
||||||
|
orientation?: 'col' | 'row';
|
||||||
|
after: number;
|
||||||
|
minAfter?: number;
|
||||||
|
maxAfter?: number;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
function handleMouseDown(event: PointerEvent) {
|
function handleMouseDown(event: PointerEvent) {
|
||||||
const startX = event.clientX;
|
const startX = event.clientX;
|
||||||
@@ -33,10 +39,10 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="{orientation === 'col'
|
class="{orientation === 'col'
|
||||||
? 'w-1 h-full cursor-col-resize border-l'
|
? 'w-1 h-full cursor-col-resize border-l'
|
||||||
: 'w-full h-1 cursor-row-resize border-t'} {orientation}"
|
: 'w-full h-1 cursor-row-resize border-t'} {orientation}"
|
||||||
on:pointerdown={handleMouseDown}
|
onpointerdown={handleMouseDown}
|
||||||
/>
|
></div>
|
||||||
|
|||||||
@@ -1,15 +1,25 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { isMac, isSafari } from '$lib/utils';
|
import { isMac, isSafari } from '$lib/utils';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { _ } from 'svelte-i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
|
import * as Kbd from '$lib/components/ui/kbd/index.js';
|
||||||
|
|
||||||
export let key: string | undefined = undefined;
|
let {
|
||||||
export let shift: boolean = false;
|
key = undefined,
|
||||||
export let ctrl: boolean = false;
|
shift = false,
|
||||||
export let click: boolean = false;
|
ctrl = false,
|
||||||
|
click = false,
|
||||||
|
class: className = '',
|
||||||
|
}: {
|
||||||
|
key?: string;
|
||||||
|
shift?: boolean;
|
||||||
|
ctrl?: boolean;
|
||||||
|
click?: boolean;
|
||||||
|
class?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
let mac = false;
|
let mac = $state(false);
|
||||||
let safari = false;
|
let safari = $state(false);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
mac = isMac();
|
mac = isMac();
|
||||||
@@ -17,20 +27,17 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<Kbd.Root class="ml-auto {className}">
|
||||||
class="ml-auto pl-2 text-xs tracking-widest text-muted-foreground flex flex-row gap-0 items-baseline"
|
|
||||||
{...$$props}
|
|
||||||
>
|
|
||||||
{#if shift}
|
{#if shift}
|
||||||
<span>⇧</span>
|
⇧
|
||||||
{/if}
|
{/if}
|
||||||
{#if ctrl}
|
{#if ctrl}
|
||||||
<span>{mac && !safari ? '⌘' : $_('menu.ctrl') + '+'}</span>
|
{mac && !safari ? '⌘' : i18n._('menu.ctrl')}
|
||||||
{/if}
|
{/if}
|
||||||
{#if key}
|
{#if key}
|
||||||
<span class={key === '+' ? 'font-medium text-sm/4' : ''}>{key}</span>
|
{key}
|
||||||
{/if}
|
{/if}
|
||||||
{#if click}
|
{#if click}
|
||||||
<span>{$_('menu.click')}</span>
|
{i18n._('menu.click')}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</Kbd.Root>
|
||||||
|
|||||||
@@ -1,18 +1,32 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
export let label: string;
|
let {
|
||||||
export let side: 'top' | 'right' | 'bottom' | 'left' = 'top';
|
label,
|
||||||
|
side = 'top',
|
||||||
|
children,
|
||||||
|
extra,
|
||||||
|
class: className = '',
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
side?: 'top' | 'right' | 'bottom' | 'left';
|
||||||
|
children: Snippet;
|
||||||
|
extra?: Snippet;
|
||||||
|
class?: string;
|
||||||
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Tooltip.Root>
|
<Tooltip.Provider>
|
||||||
<Tooltip.Trigger {...$$restProps} aria-label={label}>
|
<Tooltip.Root>
|
||||||
<slot />
|
<Tooltip.Trigger class={className} aria-label={label}>
|
||||||
</Tooltip.Trigger>
|
{@render children()}
|
||||||
<Tooltip.Content {side}>
|
</Tooltip.Trigger>
|
||||||
<div class="flex flex-row items-center">
|
<Tooltip.Content {side}>
|
||||||
<span>{label}</span>
|
<div class="flex flex-row items-center gap-2">
|
||||||
<slot name="extra" />
|
<span>{label}</span>
|
||||||
</div>
|
{@render extra?.()}
|
||||||
</Tooltip.Content>
|
</div>
|
||||||
</Tooltip.Root>
|
</Tooltip.Content>
|
||||||
|
</Tooltip.Root>
|
||||||
|
</Tooltip.Provider>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { settings } from '$lib/db';
|
|
||||||
import {
|
import {
|
||||||
celsiusToFahrenheit,
|
celsiusToFahrenheit,
|
||||||
getConvertedDistance,
|
getConvertedDistance,
|
||||||
@@ -10,18 +9,27 @@
|
|||||||
getVelocityUnits,
|
getVelocityUnits,
|
||||||
secondsToHHMMSS,
|
secondsToHHMMSS,
|
||||||
} from '$lib/units';
|
} from '$lib/units';
|
||||||
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
|
import { settings } from '$lib/logic/settings';
|
||||||
|
|
||||||
import { _ } from 'svelte-i18n';
|
let {
|
||||||
|
value,
|
||||||
export let value: number;
|
type,
|
||||||
export let type: 'distance' | 'elevation' | 'speed' | 'temperature' | 'time';
|
showUnits = true,
|
||||||
export let showUnits: boolean = true;
|
decimals = undefined,
|
||||||
export let decimals: number | undefined = undefined;
|
class: className = '',
|
||||||
|
}: {
|
||||||
|
value: number;
|
||||||
|
type: 'distance' | 'elevation' | 'speed' | 'temperature' | 'time';
|
||||||
|
showUnits?: boolean;
|
||||||
|
decimals?: number;
|
||||||
|
class?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
const { distanceUnits, velocityUnits, temperatureUnits } = settings;
|
const { distanceUnits, velocityUnits, temperatureUnits } = settings;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<span class={$$props.class}>
|
<span class={className}>
|
||||||
{#if type === 'distance'}
|
{#if type === 'distance'}
|
||||||
{getConvertedDistance(value, $distanceUnits).toFixed(decimals ?? 2)}
|
{getConvertedDistance(value, $distanceUnits).toFixed(decimals ?? 2)}
|
||||||
{showUnits ? getDistanceUnits($distanceUnits) : ''}
|
{showUnits ? getDistanceUnits($distanceUnits) : ''}
|
||||||
@@ -38,9 +46,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{:else if type === 'temperature'}
|
{:else if type === 'temperature'}
|
||||||
{#if $temperatureUnits === 'celsius'}
|
{#if $temperatureUnits === 'celsius'}
|
||||||
{value} {showUnits ? $_('units.celsius') : ''}
|
{value} {showUnits ? i18n._('units.celsius') : ''}
|
||||||
{:else}
|
{:else}
|
||||||
{celsiusToFahrenheit(value)} {showUnits ? $_('units.fahrenheit') : ''}
|
{celsiusToFahrenheit(value)} {showUnits ? i18n._('units.fahrenheit') : ''}
|
||||||
{/if}
|
{/if}
|
||||||
{:else if type === 'time'}
|
{:else if type === 'time'}
|
||||||
{secondsToHHMMSS(value)}
|
{secondsToHHMMSS(value)}
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { setContext } from 'svelte';
|
import { setContext, type Snippet } from 'svelte';
|
||||||
import { writable } from 'svelte/store';
|
import { CollapsibleTreeState } from './utils.svelte';
|
||||||
|
|
||||||
export let defaultState: 'open' | 'closed' = 'open';
|
const {
|
||||||
export let side: 'left' | 'right' = 'right';
|
defaultState = 'open',
|
||||||
export let nohover: boolean = false;
|
side = 'right',
|
||||||
export let slotInsideTrigger: boolean = true;
|
nohover = false,
|
||||||
|
slotInsideTrigger = true,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
defaultState?: 'open' | 'closed';
|
||||||
|
side?: 'left' | 'right';
|
||||||
|
nohover?: boolean;
|
||||||
|
slotInsideTrigger?: boolean;
|
||||||
|
children: Snippet;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
let open = writable<Record<string, boolean>>({});
|
let open = $state(new CollapsibleTreeState(defaultState));
|
||||||
|
|
||||||
setContext('collapsible-tree-default-state', defaultState);
|
|
||||||
setContext('collapsible-tree-state', open);
|
setContext('collapsible-tree-state', open);
|
||||||
setContext('collapsible-tree-side', side);
|
setContext('collapsible-tree-side', side);
|
||||||
setContext('collapsible-tree-nohover', nohover);
|
setContext('collapsible-tree-nohover', nohover);
|
||||||
@@ -17,4 +25,4 @@
|
|||||||
setContext('collapsible-tree-slot-inside-trigger', slotInsideTrigger);
|
setContext('collapsible-tree-slot-inside-trigger', slotInsideTrigger);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<slot />
|
{@render children()}
|
||||||
|
|||||||
@@ -1,60 +1,56 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as Collapsible from '$lib/components/ui/collapsible';
|
import * as Collapsible from '$lib/components/ui/collapsible';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-svelte';
|
import { ChevronDown, ChevronLeft, ChevronRight } from '@lucide/svelte';
|
||||||
import { getContext, onMount, setContext } from 'svelte';
|
import { getContext, setContext, type Snippet } from 'svelte';
|
||||||
import { get, type Writable } from 'svelte/store';
|
import type { ClassValue } from 'svelte/elements';
|
||||||
|
import type { CollapsibleTreeState } from './utils.svelte';
|
||||||
|
|
||||||
export let id: string | number;
|
const props: {
|
||||||
|
id: string | number;
|
||||||
|
class?: ClassValue;
|
||||||
|
trigger: Snippet;
|
||||||
|
content: Snippet;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
let defaultState = getContext<'open' | 'closed'>('collapsible-tree-default-state');
|
let state = getContext<CollapsibleTreeState>('collapsible-tree-state');
|
||||||
let open = getContext<Writable<Record<string, boolean>>>('collapsible-tree-state');
|
|
||||||
let side = getContext<'left' | 'right'>('collapsible-tree-side');
|
let side = getContext<'left' | 'right'>('collapsible-tree-side');
|
||||||
let nohover = getContext<boolean>('collapsible-tree-nohover');
|
let nohover = getContext<boolean>('collapsible-tree-nohover');
|
||||||
let slotInsideTrigger = getContext<boolean>('collapsible-tree-slot-inside-trigger');
|
let slotInsideTrigger = getContext<boolean>('collapsible-tree-slot-inside-trigger');
|
||||||
let parentId = getContext<string>('collapsible-tree-parent-id');
|
let parentId = getContext<string>('collapsible-tree-parent-id');
|
||||||
|
|
||||||
let fullId = `${parentId}.${id}`;
|
let fullId = `${parentId}.${props.id}`;
|
||||||
setContext('collapsible-tree-parent-id', fullId);
|
setContext('collapsible-tree-parent-id', fullId);
|
||||||
|
|
||||||
onMount(() => {
|
let open = state.get(fullId);
|
||||||
if (!get(open).hasOwnProperty(fullId)) {
|
|
||||||
open.update((value) => {
|
|
||||||
value[fullId] = defaultState === 'open';
|
|
||||||
return value;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export function openNode() {
|
export function openNode() {
|
||||||
open.update((value) => {
|
open.current = true;
|
||||||
value[fullId] = true;
|
|
||||||
return value;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Collapsible.Root bind:open={$open[fullId]} class={$$props.class ?? ''}>
|
<Collapsible.Root bind:open={open.current} class={props.class}>
|
||||||
{#if slotInsideTrigger}
|
{#if slotInsideTrigger}
|
||||||
<Collapsible.Trigger class="w-full">
|
<Collapsible.Trigger class="w-full">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="w-full flex flex-row {side === 'right'
|
size="icon"
|
||||||
|
class="w-full flex flex-row gap-1 {side === 'right'
|
||||||
? 'justify-between'
|
? 'justify-between'
|
||||||
: 'justify-start'} py-0 px-1 h-fit {nohover
|
: 'justify-start pl-1'} h-fit {nohover
|
||||||
? 'hover:bg-background'
|
? 'hover:bg-background'
|
||||||
: ''} pointer-events-none"
|
: ''} pointer-events-none"
|
||||||
>
|
>
|
||||||
{#if side === 'left'}
|
{#if side === 'left'}
|
||||||
{#if $open[fullId]}
|
{#if open.current}
|
||||||
<ChevronDown size="16" class="shrink-0" />
|
<ChevronDown size="16" class="shrink-0" />
|
||||||
{:else}
|
{:else}
|
||||||
<ChevronRight size="16" class="shrink-0" />
|
<ChevronRight size="16" class="shrink-0" />
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
<slot name="trigger" />
|
{@render props.trigger()}
|
||||||
{#if side === 'right'}
|
{#if side === 'right'}
|
||||||
{#if $open[fullId]}
|
{#if open.current}
|
||||||
<ChevronDown size="16" class="shrink-0" />
|
<ChevronDown size="16" class="shrink-0" />
|
||||||
{:else}
|
{:else}
|
||||||
<ChevronLeft size="16" class="shrink-0" />
|
<ChevronLeft size="16" class="shrink-0" />
|
||||||
@@ -65,23 +61,24 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="w-full flex flex-row {side === 'right'
|
size="icon"
|
||||||
|
class="w-full flex flex-row gap-1 {side === 'right'
|
||||||
? 'justify-between'
|
? 'justify-between'
|
||||||
: 'justify-start'} py-0 px-1 h-fit {nohover ? 'hover:bg-background' : ''}"
|
: 'justify-start pl-1'} h-fit {nohover ? 'hover:bg-background' : ''}"
|
||||||
>
|
>
|
||||||
{#if side === 'left'}
|
{#if side === 'left'}
|
||||||
<Collapsible.Trigger>
|
<Collapsible.Trigger>
|
||||||
{#if $open[fullId]}
|
{#if open.current}
|
||||||
<ChevronDown size="16" class="shrink-0" />
|
<ChevronDown size="16" class="shrink-0" />
|
||||||
{:else}
|
{:else}
|
||||||
<ChevronRight size="16" class="shrink-0" />
|
<ChevronRight size="16" class="shrink-0" />
|
||||||
{/if}
|
{/if}
|
||||||
</Collapsible.Trigger>
|
</Collapsible.Trigger>
|
||||||
{/if}
|
{/if}
|
||||||
<slot name="trigger" />
|
{@render props.trigger()}
|
||||||
{#if side === 'right'}
|
{#if side === 'right'}
|
||||||
<Collapsible.Trigger>
|
<Collapsible.Trigger>
|
||||||
{#if $open[fullId]}
|
{#if open.current}
|
||||||
<ChevronDown size="16" class="shrink-0" />
|
<ChevronDown size="16" class="shrink-0" />
|
||||||
{:else}
|
{:else}
|
||||||
<ChevronLeft size="16" class="shrink-0" />
|
<ChevronLeft size="16" class="shrink-0" />
|
||||||
@@ -90,8 +87,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
|
<Collapsible.Content>
|
||||||
<Collapsible.Content class="ml-2">
|
{@render props.content()}
|
||||||
<slot name="content" />
|
|
||||||
</Collapsible.Content>
|
</Collapsible.Content>
|
||||||
</Collapsible.Root>
|
</Collapsible.Root>
|
||||||
|
|||||||
31
website/src/lib/components/collapsible-tree/utils.svelte.ts
Normal file
31
website/src/lib/components/collapsible-tree/utils.svelte.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
export class CollapsibleNodeState {
|
||||||
|
private _open: boolean;
|
||||||
|
|
||||||
|
constructor(defaultState: 'open' | 'closed') {
|
||||||
|
this._open = $state(defaultState === 'open');
|
||||||
|
}
|
||||||
|
|
||||||
|
get current(): boolean {
|
||||||
|
return this._open;
|
||||||
|
}
|
||||||
|
|
||||||
|
set current(value: boolean) {
|
||||||
|
this._open = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CollapsibleTreeState {
|
||||||
|
private _open: Record<string, CollapsibleNodeState> = {};
|
||||||
|
private _defaultState: 'open' | 'closed';
|
||||||
|
|
||||||
|
constructor(defaultState: 'open' | 'closed') {
|
||||||
|
this._defaultState = defaultState;
|
||||||
|
}
|
||||||
|
|
||||||
|
get(id: string): CollapsibleNodeState {
|
||||||
|
if (this._open[id] === undefined) {
|
||||||
|
this._open[id] = new CollapsibleNodeState(this._defaultState);
|
||||||
|
}
|
||||||
|
return this._open[id];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import CustomControl from './CustomControl';
|
|
||||||
import { map } from '$lib/stores';
|
|
||||||
|
|
||||||
export let position: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' = 'top-right';
|
|
||||||
|
|
||||||
let container: HTMLDivElement;
|
|
||||||
let control: CustomControl | undefined = undefined;
|
|
||||||
|
|
||||||
$: if ($map && container) {
|
|
||||||
if (position.includes('right')) container.classList.add('float-right');
|
|
||||||
else container.classList.add('float-left');
|
|
||||||
container.classList.remove('hidden');
|
|
||||||
if (control === undefined) {
|
|
||||||
control = new CustomControl(container);
|
|
||||||
}
|
|
||||||
$map.addControl(control, position);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
bind:this={container}
|
|
||||||
class="{$$props.class ||
|
|
||||||
''} clear-both translate-0 m-[10px] mb-0 last:mb-[10px] pointer-events-auto bg-background rounded shadow-md hidden"
|
|
||||||
>
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { _ } from 'svelte-i18n';
|
import type { Component } from 'svelte';
|
||||||
|
|
||||||
export let module;
|
let { module: Module }: { module: Component } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="markdown flex flex-col gap-3">
|
<div class="markdown flex flex-col gap-3">
|
||||||
<svelte:component this={module} />
|
<Module />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="postcss">
|
<style lang="postcss">
|
||||||
|
@reference "../../../app.css";
|
||||||
|
|
||||||
:global(.markdown) {
|
:global(.markdown) {
|
||||||
@apply text-muted-foreground;
|
@apply text-muted-foreground;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export let src: 'getting-started/interface' | 'tools/routing' | 'tools/split';
|
let {
|
||||||
export let alt: string;
|
src,
|
||||||
|
alt,
|
||||||
|
}: {
|
||||||
|
src: 'getting-started/interface' | 'tools/routing' | 'tools/split';
|
||||||
|
alt: string;
|
||||||
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col items-center py-6 w-full">
|
<div class="flex flex-col items-center py-6 w-full">
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export let type: 'note' | 'warning' = 'note';
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
let { type = 'note', children }: { type?: 'note' | 'warning'; children: Snippet } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -7,10 +9,12 @@
|
|||||||
? 'border-link'
|
? 'border-link'
|
||||||
: 'border-destructive'} p-2 text-sm rounded-md"
|
: 'border-destructive'} p-2 text-sm rounded-md"
|
||||||
>
|
>
|
||||||
<slot />
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="postcss">
|
<style lang="postcss">
|
||||||
|
@reference "../../../app.css";
|
||||||
|
|
||||||
div :global(a) {
|
div :global(a) {
|
||||||
@apply text-link;
|
@apply text-link;
|
||||||
@apply hover:underline;
|
@apply hover:underline;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import {
|
|||||||
File,
|
File,
|
||||||
FilePen,
|
FilePen,
|
||||||
View,
|
View,
|
||||||
type Icon,
|
|
||||||
Settings,
|
Settings,
|
||||||
Pencil,
|
Pencil,
|
||||||
MapPin,
|
MapPin,
|
||||||
@@ -10,11 +9,12 @@ import {
|
|||||||
CalendarClock,
|
CalendarClock,
|
||||||
Group,
|
Group,
|
||||||
Ungroup,
|
Ungroup,
|
||||||
Filter,
|
Funnel,
|
||||||
SquareDashedMousePointer,
|
SquareDashedMousePointer,
|
||||||
MountainSnow,
|
MountainSnow,
|
||||||
} from 'lucide-svelte';
|
type IconProps,
|
||||||
import type { ComponentType } from 'svelte';
|
} from '@lucide/svelte';
|
||||||
|
import type { Component } from 'svelte';
|
||||||
|
|
||||||
export const guides: Record<string, string[]> = {
|
export const guides: Record<string, string[]> = {
|
||||||
'getting-started': [],
|
'getting-started': [],
|
||||||
@@ -37,7 +37,7 @@ export const guides: Record<string, string[]> = {
|
|||||||
faq: [],
|
faq: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const guideIcons: Record<string, string | ComponentType<Icon>> = {
|
export const guideIcons: Record<string, string | Component<IconProps>> = {
|
||||||
'getting-started': '🚀',
|
'getting-started': '🚀',
|
||||||
menu: '📂 ⚙️',
|
menu: '📂 ⚙️',
|
||||||
file: File,
|
file: File,
|
||||||
@@ -53,7 +53,7 @@ export const guideIcons: Record<string, string | ComponentType<Icon>> = {
|
|||||||
merge: Group,
|
merge: Group,
|
||||||
extract: Ungroup,
|
extract: Ungroup,
|
||||||
elevation: MountainSnow,
|
elevation: MountainSnow,
|
||||||
minify: Filter,
|
minify: Funnel,
|
||||||
clean: SquareDashedMousePointer,
|
clean: SquareDashedMousePointer,
|
||||||
'map-controls': '🗺',
|
'map-controls': '🗺',
|
||||||
gpx: '💾',
|
gpx: '💾',
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ButtonWithTooltip from '$lib/components/ButtonWithTooltip.svelte';
|
||||||
|
import * as Popover from '$lib/components/ui/popover/index.js';
|
||||||
|
import * as ToggleGroup from '$lib/components/ui/toggle-group/index.js';
|
||||||
|
import Separator from '$lib/components/ui/separator/separator.svelte';
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
import {
|
||||||
|
BrickWall,
|
||||||
|
TriangleRight,
|
||||||
|
HeartPulse,
|
||||||
|
Orbit,
|
||||||
|
SquareActivity,
|
||||||
|
Thermometer,
|
||||||
|
Zap,
|
||||||
|
Circle,
|
||||||
|
Check,
|
||||||
|
ChartNoAxesColumn,
|
||||||
|
Construction,
|
||||||
|
} from '@lucide/svelte';
|
||||||
|
import type { Readable, Writable } from 'svelte/store';
|
||||||
|
import type { GPXStatistics } from 'gpx';
|
||||||
|
import { settings } from '$lib/logic/settings';
|
||||||
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
|
import { ElevationProfile } from '$lib/components/elevation-profile/elevation-profile';
|
||||||
|
|
||||||
|
const { velocityUnits } = settings;
|
||||||
|
|
||||||
|
let {
|
||||||
|
gpxStatistics,
|
||||||
|
slicedGPXStatistics,
|
||||||
|
additionalDatasets,
|
||||||
|
elevationFill,
|
||||||
|
showControls = true,
|
||||||
|
}: {
|
||||||
|
gpxStatistics: Readable<GPXStatistics>;
|
||||||
|
slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>;
|
||||||
|
additionalDatasets: Writable<string[]>;
|
||||||
|
elevationFill: Writable<'slope' | 'surface' | 'highway' | undefined>;
|
||||||
|
showControls?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let canvas: HTMLCanvasElement;
|
||||||
|
let overlay: HTMLCanvasElement;
|
||||||
|
let elevationProfile: ElevationProfile | null = null;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
elevationProfile = new ElevationProfile(
|
||||||
|
gpxStatistics,
|
||||||
|
slicedGPXStatistics,
|
||||||
|
additionalDatasets,
|
||||||
|
elevationFill,
|
||||||
|
canvas,
|
||||||
|
overlay
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (elevationProfile) {
|
||||||
|
elevationProfile.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<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="absolute bottom-10 right-1.5">
|
||||||
|
<Popover.Root>
|
||||||
|
<Popover.Trigger>
|
||||||
|
<ButtonWithTooltip
|
||||||
|
label={i18n._('chart.settings')}
|
||||||
|
variant="outline"
|
||||||
|
side="left"
|
||||||
|
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"
|
||||||
|
side="top"
|
||||||
|
align="end"
|
||||||
|
sideOffset={-32}
|
||||||
|
>
|
||||||
|
<ToggleGroup.Root
|
||||||
|
class="flex flex-col items-start gap-0 p-1 w-full border-none"
|
||||||
|
type="single"
|
||||||
|
bind:value={$elevationFill}
|
||||||
|
>
|
||||||
|
<ToggleGroup.Item
|
||||||
|
class="p-0 pr-1.5 h-6 w-full gap-1.5 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="size-1.5 fill-current text-current" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<TriangleRight size="15" />
|
||||||
|
{i18n._('quantities.slope')}
|
||||||
|
</ToggleGroup.Item>
|
||||||
|
<ToggleGroup.Item
|
||||||
|
class="p-0 pr-1.5 h-6 w-full gap-1.5 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="size-1.5 fill-current text-current" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<BrickWall size="15" />
|
||||||
|
{i18n._('quantities.surface')}
|
||||||
|
</ToggleGroup.Item>
|
||||||
|
<ToggleGroup.Item
|
||||||
|
class="p-0 pr-1.5 h-6 w-full gap-1.5 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="size-1.5 fill-current text-current" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<Construction size="15" />
|
||||||
|
{i18n._('quantities.highway')}
|
||||||
|
</ToggleGroup.Item>
|
||||||
|
</ToggleGroup.Root>
|
||||||
|
<Separator />
|
||||||
|
<ToggleGroup.Root
|
||||||
|
class="flex flex-col items-start gap-0 p-1"
|
||||||
|
type="multiple"
|
||||||
|
bind:value={$additionalDatasets}
|
||||||
|
>
|
||||||
|
<ToggleGroup.Item
|
||||||
|
class="p-0 pr-1.5 h-6 w-full gap-1.5 rounded flex justify-start data-[state=on]:bg-background data-[state=on]:hover:bg-accent hover:bg-accent hover:text-foreground"
|
||||||
|
value="speed"
|
||||||
|
>
|
||||||
|
<div class="w-6 flex justify-center items-center">
|
||||||
|
{#if $additionalDatasets.includes('speed')}
|
||||||
|
<Check size="14" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<Zap size="15" />
|
||||||
|
{$velocityUnits === 'speed'
|
||||||
|
? i18n._('quantities.speed')
|
||||||
|
: i18n._('quantities.pace')}
|
||||||
|
</ToggleGroup.Item>
|
||||||
|
<ToggleGroup.Item
|
||||||
|
class="p-0 pr-1.5 h-6 w-full gap-1.5 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" />
|
||||||
|
{i18n._('quantities.heartrate')}
|
||||||
|
</ToggleGroup.Item>
|
||||||
|
<ToggleGroup.Item
|
||||||
|
class="p-0 pr-1.5 h-6 w-full gap-1.5 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" />
|
||||||
|
{i18n._('quantities.cadence')}
|
||||||
|
</ToggleGroup.Item>
|
||||||
|
<ToggleGroup.Item
|
||||||
|
class="p-0 pr-1.5 h-6 w-full gap-1.5 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" />
|
||||||
|
{i18n._('quantities.temperature')}
|
||||||
|
</ToggleGroup.Item>
|
||||||
|
<ToggleGroup.Item
|
||||||
|
class="p-0 pr-1.5 h-6 w-full gap-1.5 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" />
|
||||||
|
{i18n._('quantities.power')}
|
||||||
|
</ToggleGroup.Item>
|
||||||
|
</ToggleGroup.Root>
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Root>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,603 @@
|
|||||||
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
|
import { settings } from '$lib/logic/settings';
|
||||||
|
import {
|
||||||
|
getCadenceWithUnits,
|
||||||
|
getConvertedDistance,
|
||||||
|
getConvertedElevation,
|
||||||
|
getConvertedTemperature,
|
||||||
|
getConvertedVelocity,
|
||||||
|
getDistanceUnits,
|
||||||
|
getDistanceWithUnits,
|
||||||
|
getElevationWithUnits,
|
||||||
|
getHeartRateWithUnits,
|
||||||
|
getPowerWithUnits,
|
||||||
|
getTemperatureWithUnits,
|
||||||
|
getVelocityWithUnits,
|
||||||
|
} from '$lib/units';
|
||||||
|
import Chart from 'chart.js/auto';
|
||||||
|
import mapboxgl from 'mapbox-gl';
|
||||||
|
import { get, type Readable, type Writable } from 'svelte/store';
|
||||||
|
import { map } from '$lib/components/map/map';
|
||||||
|
import type { GPXStatistics } from 'gpx';
|
||||||
|
import { mode } from 'mode-watcher';
|
||||||
|
import { getHighwayColor, getSlopeColor, getSurfaceColor } from '$lib/assets/colors';
|
||||||
|
|
||||||
|
const { distanceUnits, velocityUnits, temperatureUnits } = settings;
|
||||||
|
|
||||||
|
Chart.defaults.font.family =
|
||||||
|
'ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"'; // Tailwind CSS font
|
||||||
|
|
||||||
|
export class ElevationProfile {
|
||||||
|
private _chart: Chart | null = null;
|
||||||
|
private _canvas: HTMLCanvasElement;
|
||||||
|
private _overlay: HTMLCanvasElement;
|
||||||
|
private _marker: mapboxgl.Marker | null = null;
|
||||||
|
private _dragging = false;
|
||||||
|
private _panning = false;
|
||||||
|
|
||||||
|
private _gpxStatistics: Readable<GPXStatistics>;
|
||||||
|
private _slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>;
|
||||||
|
private _additionalDatasets: Readable<string[]>;
|
||||||
|
private _elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
gpxStatistics: Readable<GPXStatistics>,
|
||||||
|
slicedGPXStatistics: Writable<[GPXStatistics, number, number] | undefined>,
|
||||||
|
additionalDatasets: Readable<string[]>,
|
||||||
|
elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>,
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
overlay: HTMLCanvasElement
|
||||||
|
) {
|
||||||
|
this._gpxStatistics = gpxStatistics;
|
||||||
|
this._slicedGPXStatistics = slicedGPXStatistics;
|
||||||
|
this._additionalDatasets = additionalDatasets;
|
||||||
|
this._elevationFill = elevationFill;
|
||||||
|
this._canvas = canvas;
|
||||||
|
this._overlay = overlay;
|
||||||
|
|
||||||
|
let element = document.createElement('div');
|
||||||
|
element.className = 'h-4 w-4 rounded-full bg-cyan-500 border-2 border-white';
|
||||||
|
this._marker = new mapboxgl.Marker({
|
||||||
|
element,
|
||||||
|
});
|
||||||
|
|
||||||
|
import('chartjs-plugin-zoom').then((module) => {
|
||||||
|
Chart.register(module.default);
|
||||||
|
this.initialize();
|
||||||
|
|
||||||
|
this._gpxStatistics.subscribe(() => {
|
||||||
|
this.updateData();
|
||||||
|
});
|
||||||
|
this._slicedGPXStatistics.subscribe(() => {
|
||||||
|
this.updateOverlay();
|
||||||
|
});
|
||||||
|
distanceUnits.subscribe(() => {
|
||||||
|
this.updateData();
|
||||||
|
});
|
||||||
|
velocityUnits.subscribe(() => {
|
||||||
|
this.updateData();
|
||||||
|
});
|
||||||
|
temperatureUnits.subscribe(() => {
|
||||||
|
this.updateData();
|
||||||
|
});
|
||||||
|
this._additionalDatasets.subscribe(() => {
|
||||||
|
this.updateDataVisibility();
|
||||||
|
});
|
||||||
|
this._elevationFill.subscribe(() => {
|
||||||
|
this.updateFill();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
initialize() {
|
||||||
|
let options = {
|
||||||
|
animation: false,
|
||||||
|
parsing: false,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
type: 'linear',
|
||||||
|
ticks: {
|
||||||
|
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,
|
||||||
|
cubicInterpolationMode: 'monotone',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
interaction: {
|
||||||
|
mode: 'nearest',
|
||||||
|
axis: 'x',
|
||||||
|
intersect: false,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
decimation: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
enabled: () => !this._dragging && !this._panning,
|
||||||
|
callbacks: {
|
||||||
|
title: () => {
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
label: (context: Chart.TooltipContext) => {
|
||||||
|
let point = context.raw;
|
||||||
|
if (context.datasetIndex === 0) {
|
||||||
|
const map_ = get(map);
|
||||||
|
if (map_ && this._marker) {
|
||||||
|
if (this._dragging) {
|
||||||
|
this._marker.remove();
|
||||||
|
} else {
|
||||||
|
this._marker.setLngLat(point.coordinates);
|
||||||
|
this._marker.addTo(map_);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `${i18n._('quantities.elevation')}: ${getElevationWithUnits(point.y, false)}`;
|
||||||
|
} else if (context.datasetIndex === 1) {
|
||||||
|
return `${get(velocityUnits) === 'speed' ? i18n._('quantities.speed') : i18n._('quantities.pace')}: ${getVelocityWithUnits(point.y, false)}`;
|
||||||
|
} else if (context.datasetIndex === 2) {
|
||||||
|
return `${i18n._('quantities.heartrate')}: ${getHeartRateWithUnits(point.y)}`;
|
||||||
|
} else if (context.datasetIndex === 3) {
|
||||||
|
return `${i18n._('quantities.cadence')}: ${getCadenceWithUnits(point.y)}`;
|
||||||
|
} else if (context.datasetIndex === 4) {
|
||||||
|
return `${i18n._('quantities.temperature')}: ${getTemperatureWithUnits(point.y, false)}`;
|
||||||
|
} else if (context.datasetIndex === 5) {
|
||||||
|
return `${i18n._('quantities.power')}: ${getPowerWithUnits(point.y)}`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
afterBody: (contexts: Chart.TooltipContext[]) => {
|
||||||
|
let context = contexts.filter((context) => context.datasetIndex === 0);
|
||||||
|
if (context.length === 0) return;
|
||||||
|
let point = context[0].raw;
|
||||||
|
let slope = {
|
||||||
|
at: point.slope.at.toFixed(1),
|
||||||
|
segment: point.slope.segment.toFixed(1),
|
||||||
|
length: getDistanceWithUnits(point.slope.length),
|
||||||
|
};
|
||||||
|
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 = [
|
||||||
|
` ${i18n._('quantities.distance')}: ${getDistanceWithUnits(point.x, false)}`,
|
||||||
|
` ${i18n._('quantities.slope')}: ${slope.at} %${get(this._elevationFill) === 'slope' ? ` (${slope.length} @${slope.segment} %)` : ''}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (get(this._elevationFill) === 'surface') {
|
||||||
|
labels.push(
|
||||||
|
` ${i18n._('quantities.surface')}: ${i18n._(`toolbar.routing.surface.${surface}`)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (get(this._elevationFill) === 'highway') {
|
||||||
|
labels.push(
|
||||||
|
` ${i18n._('quantities.highway')}: ${i18n._(`toolbar.routing.highway.${highway}`)}${
|
||||||
|
sacScale
|
||||||
|
? ` (${i18n._(`toolbar.routing.sac_scale.${sacScale}`)})`
|
||||||
|
: ''
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
if (mtbScale) {
|
||||||
|
labels.push(
|
||||||
|
` ${i18n._('toolbar.routing.mtb_scale')}: ${mtbScale}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (point.time) {
|
||||||
|
labels.push(
|
||||||
|
` ${i18n._('quantities.time')}: ${i18n.df.format(point.time)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return labels;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
zoom: {
|
||||||
|
pan: {
|
||||||
|
enabled: true,
|
||||||
|
mode: 'x',
|
||||||
|
modifierKey: 'shift',
|
||||||
|
onPanStart: () => {
|
||||||
|
this._panning = true;
|
||||||
|
this._slicedGPXStatistics.set(undefined);
|
||||||
|
},
|
||||||
|
onPanComplete: () => {
|
||||||
|
this._panning = false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
zoom: {
|
||||||
|
wheel: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
mode: 'x',
|
||||||
|
onZoomStart: ({ chart, event }: { chart: Chart; event: any }) => {
|
||||||
|
if (
|
||||||
|
event.deltaY < 0 &&
|
||||||
|
Math.abs(
|
||||||
|
this._chart.getInitialScaleBounds().x.max /
|
||||||
|
this._chart.options.plugins.zoom.limits.x.minRange -
|
||||||
|
this._chart.getZoomLevel()
|
||||||
|
) < 0.01
|
||||||
|
) {
|
||||||
|
// Disable wheel pan if zoomed in to the max, and zooming in
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._slicedGPXStatistics.set(undefined);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
limits: {
|
||||||
|
x: {
|
||||||
|
min: 'original',
|
||||||
|
max: 'original',
|
||||||
|
minRange: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
stacked: false,
|
||||||
|
onResize: () => {
|
||||||
|
this.updateOverlay();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let datasets: string[] = ['speed', 'hr', 'cad', 'atemp', 'power'];
|
||||||
|
datasets.forEach((id) => {
|
||||||
|
options.scales[`y${id}`] = {
|
||||||
|
type: 'linear',
|
||||||
|
position: 'right',
|
||||||
|
grid: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
reverse: () => id === 'speed' && get(velocityUnits) === 'pace',
|
||||||
|
display: false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
this._chart = new Chart(this._canvas, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
datasets: [],
|
||||||
|
},
|
||||||
|
options,
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
id: 'toggleMarker',
|
||||||
|
events: ['mouseout'],
|
||||||
|
afterEvent: (chart: Chart, args: { event: Chart.ChartEvent }) => {
|
||||||
|
if (args.event.type === 'mouseout') {
|
||||||
|
const map_ = get(map);
|
||||||
|
if (map_ && this._marker) {
|
||||||
|
this._marker.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
let startIndex = 0;
|
||||||
|
let endIndex = 0;
|
||||||
|
const getIndex = (evt) => {
|
||||||
|
if (!this._chart) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const points = this._chart.getElementsAtEventForMode(
|
||||||
|
evt,
|
||||||
|
'x',
|
||||||
|
{
|
||||||
|
intersect: false,
|
||||||
|
},
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
if (points.length === 0) {
|
||||||
|
const rect = this._canvas.getBoundingClientRect();
|
||||||
|
if (evt.x - rect.left <= this._chart.chartArea.left) {
|
||||||
|
return 0;
|
||||||
|
} else if (evt.x - rect.left >= this._chart.chartArea.right) {
|
||||||
|
return get(this._gpxStatistics).local.points.length - 1;
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let point = points.find((point) => point.element.raw);
|
||||||
|
if (point) {
|
||||||
|
return point.element.raw.index;
|
||||||
|
} else {
|
||||||
|
return points[0].index;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let dragStarted = false;
|
||||||
|
const onMouseDown = (evt) => {
|
||||||
|
if (evt.shiftKey) {
|
||||||
|
// Panning interaction
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dragStarted = true;
|
||||||
|
this._canvas.style.cursor = 'col-resize';
|
||||||
|
startIndex = getIndex(evt);
|
||||||
|
};
|
||||||
|
const onMouseMove = (evt) => {
|
||||||
|
if (dragStarted) {
|
||||||
|
this._dragging = true;
|
||||||
|
endIndex = getIndex(evt);
|
||||||
|
if (endIndex !== undefined) {
|
||||||
|
if (startIndex === undefined) {
|
||||||
|
startIndex = endIndex;
|
||||||
|
} else if (startIndex !== endIndex) {
|
||||||
|
this._slicedGPXStatistics.set([
|
||||||
|
get(this._gpxStatistics).slice(
|
||||||
|
Math.min(startIndex, endIndex),
|
||||||
|
Math.max(startIndex, endIndex)
|
||||||
|
),
|
||||||
|
Math.min(startIndex, endIndex),
|
||||||
|
Math.max(startIndex, endIndex),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onMouseUp = (evt) => {
|
||||||
|
dragStarted = false;
|
||||||
|
this._dragging = false;
|
||||||
|
this._canvas.style.cursor = '';
|
||||||
|
endIndex = getIndex(evt);
|
||||||
|
if (startIndex === endIndex) {
|
||||||
|
this._slicedGPXStatistics.set(undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this._canvas.addEventListener('pointerdown', onMouseDown);
|
||||||
|
this._canvas.addEventListener('pointermove', onMouseMove);
|
||||||
|
this._canvas.addEventListener('pointerup', onMouseUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateData() {
|
||||||
|
if (!this._chart) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = get(this._gpxStatistics);
|
||||||
|
this._chart.data.datasets[0] = {
|
||||||
|
label: i18n._('quantities.elevation'),
|
||||||
|
data: data.local.points.map((point, index) => {
|
||||||
|
return {
|
||||||
|
x: getConvertedDistance(data.local.distance.total[index]),
|
||||||
|
y: point.ele ? getConvertedElevation(point.ele) : 0,
|
||||||
|
time: point.time,
|
||||||
|
slope: {
|
||||||
|
at: data.local.slope.at[index],
|
||||||
|
segment: data.local.slope.segment[index],
|
||||||
|
length: data.local.slope.length[index],
|
||||||
|
},
|
||||||
|
extensions: point.getExtensions(),
|
||||||
|
coordinates: point.getCoordinates(),
|
||||||
|
index: index,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
normalized: true,
|
||||||
|
fill: 'start',
|
||||||
|
order: 1,
|
||||||
|
segment: {},
|
||||||
|
};
|
||||||
|
this._chart.data.datasets[1] = {
|
||||||
|
data: data.local.points.map((point, index) => {
|
||||||
|
return {
|
||||||
|
x: getConvertedDistance(data.local.distance.total[index]),
|
||||||
|
y: getConvertedVelocity(data.local.speed[index]),
|
||||||
|
index: index,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
normalized: true,
|
||||||
|
yAxisID: 'yspeed',
|
||||||
|
};
|
||||||
|
this._chart.data.datasets[2] = {
|
||||||
|
data: data.local.points.map((point, index) => {
|
||||||
|
return {
|
||||||
|
x: getConvertedDistance(data.local.distance.total[index]),
|
||||||
|
y: point.getHeartRate(),
|
||||||
|
index: index,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
normalized: true,
|
||||||
|
yAxisID: 'yhr',
|
||||||
|
};
|
||||||
|
this._chart.data.datasets[3] = {
|
||||||
|
data: data.local.points.map((point, index) => {
|
||||||
|
return {
|
||||||
|
x: getConvertedDistance(data.local.distance.total[index]),
|
||||||
|
y: point.getCadence(),
|
||||||
|
index: index,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
normalized: true,
|
||||||
|
yAxisID: 'ycad',
|
||||||
|
};
|
||||||
|
this._chart.data.datasets[4] = {
|
||||||
|
data: data.local.points.map((point, index) => {
|
||||||
|
return {
|
||||||
|
x: getConvertedDistance(data.local.distance.total[index]),
|
||||||
|
y: getConvertedTemperature(point.getTemperature()),
|
||||||
|
index: index,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
normalized: true,
|
||||||
|
yAxisID: 'yatemp',
|
||||||
|
};
|
||||||
|
this._chart.data.datasets[5] = {
|
||||||
|
data: data.local.points.map((point, index) => {
|
||||||
|
return {
|
||||||
|
x: getConvertedDistance(data.local.distance.total[index]),
|
||||||
|
y: point.getPower(),
|
||||||
|
index: index,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
normalized: true,
|
||||||
|
yAxisID: 'ypower',
|
||||||
|
};
|
||||||
|
this._chart.options.scales.x['min'] = 0;
|
||||||
|
this._chart.options.scales.x['max'] = getConvertedDistance(data.global.distance.total);
|
||||||
|
|
||||||
|
this.setVisibility();
|
||||||
|
this.setFill();
|
||||||
|
|
||||||
|
this._chart.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDataVisibility() {
|
||||||
|
if (!this._chart) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.setVisibility();
|
||||||
|
this._chart.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
setVisibility() {
|
||||||
|
if (!this._chart) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const additionalDatasets = get(this._additionalDatasets);
|
||||||
|
let includeSpeed = additionalDatasets.includes('speed');
|
||||||
|
let includeHeartRate = additionalDatasets.includes('hr');
|
||||||
|
let includeCadence = additionalDatasets.includes('cad');
|
||||||
|
let includeTemperature = additionalDatasets.includes('atemp');
|
||||||
|
let includePower = additionalDatasets.includes('power');
|
||||||
|
if (this._chart.data.datasets.length == 6) {
|
||||||
|
this._chart.data.datasets[1].hidden = !includeSpeed;
|
||||||
|
this._chart.data.datasets[2].hidden = !includeHeartRate;
|
||||||
|
this._chart.data.datasets[3].hidden = !includeCadence;
|
||||||
|
this._chart.data.datasets[4].hidden = !includeTemperature;
|
||||||
|
this._chart.data.datasets[5].hidden = !includePower;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFill() {
|
||||||
|
if (!this._chart) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.setFill();
|
||||||
|
this._chart.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
setFill() {
|
||||||
|
if (!this._chart) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const elevationFill = get(this._elevationFill);
|
||||||
|
if (elevationFill === 'slope') {
|
||||||
|
this._chart.data.datasets[0]['segment'] = {
|
||||||
|
backgroundColor: this.slopeFillCallback,
|
||||||
|
};
|
||||||
|
} else if (elevationFill === 'surface') {
|
||||||
|
this._chart.data.datasets[0]['segment'] = {
|
||||||
|
backgroundColor: this.surfaceFillCallback,
|
||||||
|
};
|
||||||
|
} else if (elevationFill === 'highway') {
|
||||||
|
this._chart.data.datasets[0]['segment'] = {
|
||||||
|
backgroundColor: this.highwayFillCallback,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
this._chart.data.datasets[0]['segment'] = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateOverlay() {
|
||||||
|
if (!this._chart) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._overlay.width = this._canvas.width / window.devicePixelRatio;
|
||||||
|
this._overlay.height = this._canvas.height / window.devicePixelRatio;
|
||||||
|
this._overlay.style.width = `${this._overlay.width}px`;
|
||||||
|
this._overlay.style.height = `${this._overlay.height}px`;
|
||||||
|
|
||||||
|
const slicedGPXStatistics = get(this._slicedGPXStatistics);
|
||||||
|
if (slicedGPXStatistics) {
|
||||||
|
let startIndex = slicedGPXStatistics[1];
|
||||||
|
let endIndex = slicedGPXStatistics[2];
|
||||||
|
|
||||||
|
// Draw selection rectangle
|
||||||
|
let selectionContext = this._overlay.getContext('2d');
|
||||||
|
if (selectionContext) {
|
||||||
|
selectionContext.fillStyle = mode.current === 'dark' ? 'white' : 'black';
|
||||||
|
selectionContext.globalAlpha = mode.current === 'dark' ? 0.2 : 0.1;
|
||||||
|
selectionContext.clearRect(0, 0, this._overlay.width, this._overlay.height);
|
||||||
|
|
||||||
|
const gpxStatistics = get(this._gpxStatistics);
|
||||||
|
let startPixel = this._chart.scales.x.getPixelForValue(
|
||||||
|
getConvertedDistance(gpxStatistics.local.distance.total[startIndex])
|
||||||
|
);
|
||||||
|
let endPixel = this._chart.scales.x.getPixelForValue(
|
||||||
|
getConvertedDistance(gpxStatistics.local.distance.total[endIndex])
|
||||||
|
);
|
||||||
|
|
||||||
|
selectionContext.fillRect(
|
||||||
|
startPixel,
|
||||||
|
this._chart.chartArea.top,
|
||||||
|
endPixel - startPixel,
|
||||||
|
this._chart.chartArea.height
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (this._overlay) {
|
||||||
|
let selectionContext = this._overlay.getContext('2d');
|
||||||
|
if (selectionContext) {
|
||||||
|
selectionContext.clearRect(0, 0, this._overlay.width, this._overlay.height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slopeFillCallback(context) {
|
||||||
|
return getSlopeColor(context.p0.raw.slope.segment);
|
||||||
|
}
|
||||||
|
|
||||||
|
surfaceFillCallback(context) {
|
||||||
|
return getSurfaceColor(context.p0.raw.extensions.surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
highwayFillCallback(context) {
|
||||||
|
return getHighwayColor(
|
||||||
|
context.p0.raw.extensions.highway,
|
||||||
|
context.p0.raw.extensions.sac_scale,
|
||||||
|
context.p0.raw.extensions.mtb_scale
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
if (this._chart) {
|
||||||
|
this._chart.destroy();
|
||||||
|
this._chart = null;
|
||||||
|
}
|
||||||
|
if (this._marker) {
|
||||||
|
this._marker.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,37 +1,39 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import GPXLayers from '$lib/components/gpx-layer/GPXLayers.svelte';
|
import GPXLayers from '$lib/components/map/gpx-layer/GPXLayers.svelte';
|
||||||
import ElevationProfile from '$lib/components/ElevationProfile.svelte';
|
import ElevationProfile from '$lib/components/elevation-profile/ElevationProfile.svelte';
|
||||||
import FileList from '$lib/components/file-list/FileList.svelte';
|
import FileList from '$lib/components/file-list/FileList.svelte';
|
||||||
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
|
import GPXStatistics from '$lib/components/GPXStatistics.svelte';
|
||||||
import Map from '$lib/components/Map.svelte';
|
import Map from '$lib/components/map/Map.svelte';
|
||||||
import LayerControl from '$lib/components/layer-control/LayerControl.svelte';
|
import LayerControl from '$lib/components/map/layer-control/LayerControl.svelte';
|
||||||
import OpenIn from '$lib/components/embedding/OpenIn.svelte';
|
import OpenIn from '$lib/components/embedding/OpenIn.svelte';
|
||||||
import {
|
import { writable } from 'svelte/store';
|
||||||
gpxStatistics,
|
|
||||||
slicedGPXStatistics,
|
|
||||||
embedding,
|
|
||||||
loadFile,
|
|
||||||
map,
|
|
||||||
updateGPXData,
|
|
||||||
} from '$lib/stores';
|
|
||||||
import { onDestroy, onMount } from 'svelte';
|
|
||||||
import { fileObservers, settings, GPXStatisticsTree } from '$lib/db';
|
|
||||||
import { readable } from 'svelte/store';
|
|
||||||
import type { GPXFile } from 'gpx';
|
import type { GPXFile } from 'gpx';
|
||||||
import { selection } from '$lib/components/file-list/Selection';
|
|
||||||
import { ListFileItem } from '$lib/components/file-list/FileList';
|
|
||||||
import {
|
import {
|
||||||
allowedEmbeddingBasemaps,
|
allowedEmbeddingBasemaps,
|
||||||
getFilesFromEmbeddingOptions,
|
getFilesFromEmbeddingOptions,
|
||||||
type EmbeddingOptions,
|
type EmbeddingOptions,
|
||||||
} from './Embedding';
|
} from './embedding';
|
||||||
import { mode, setMode } from 'mode-watcher';
|
import { setMode } from 'mode-watcher';
|
||||||
import { browser } from '$app/environment';
|
import { settings } from '$lib/logic/settings';
|
||||||
|
import { fileStateCollection } from '$lib/logic/file-state';
|
||||||
|
import { gpxStatistics, slicedGPXStatistics } from '$lib/logic/statistics';
|
||||||
|
import { loadFile } from '$lib/logic/file-actions';
|
||||||
|
import { selection } from '$lib/logic/selection';
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
import { isSelected, toggle } from '$lib/components/map/layer-control/utils';
|
||||||
|
|
||||||
$embedding = true;
|
let {
|
||||||
|
useHash = true,
|
||||||
|
options = $bindable(),
|
||||||
|
hash = $bindable(),
|
||||||
|
}: { useHash?: boolean; options: EmbeddingOptions; hash: string } = $props();
|
||||||
|
|
||||||
|
let additionalDatasets = writable<string[]>([]);
|
||||||
|
let elevationFill = writable<'slope' | 'surface' | 'highway' | undefined>(undefined);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
currentBasemap,
|
currentBasemap,
|
||||||
|
selectedBasemapTree,
|
||||||
distanceUnits,
|
distanceUnits,
|
||||||
velocityUnits,
|
velocityUnits,
|
||||||
temperatureUnits,
|
temperatureUnits,
|
||||||
@@ -40,203 +42,75 @@
|
|||||||
directionMarkers,
|
directionMarkers,
|
||||||
} = settings;
|
} = settings;
|
||||||
|
|
||||||
export let useHash = true;
|
settings.initialize();
|
||||||
export let options: EmbeddingOptions;
|
|
||||||
export let hash: string;
|
|
||||||
|
|
||||||
let prevSettings = {
|
|
||||||
distanceMarkers: false,
|
|
||||||
directionMarkers: false,
|
|
||||||
distanceUnits: 'metric',
|
|
||||||
velocityUnits: 'speed',
|
|
||||||
temperatureUnits: 'celsius',
|
|
||||||
theme: 'system',
|
|
||||||
};
|
|
||||||
|
|
||||||
function applyOptions() {
|
function applyOptions() {
|
||||||
fileObservers.update(($fileObservers) => {
|
let downloads: Promise<GPXFile | null>[] = getFilesFromEmbeddingOptions(options).map(
|
||||||
$fileObservers.clear();
|
(url) => {
|
||||||
return $fileObservers;
|
return fetch(url)
|
||||||
});
|
|
||||||
|
|
||||||
let downloads: Promise<GPXFile | null>[] = [];
|
|
||||||
getFilesFromEmbeddingOptions(options).forEach((url) => {
|
|
||||||
downloads.push(
|
|
||||||
fetch(url)
|
|
||||||
.then((response) => response.blob())
|
.then((response) => response.blob())
|
||||||
.then((blob) => new File([blob], url.split('/').pop() ?? url))
|
.then((blob) => new File([blob], url.split('/').pop() ?? url))
|
||||||
.then(loadFile)
|
.then(loadFile);
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
Promise.all(downloads).then((files) => {
|
|
||||||
let ids: string[] = [];
|
|
||||||
let bounds = {
|
|
||||||
southWest: {
|
|
||||||
lat: 90,
|
|
||||||
lon: 180,
|
|
||||||
},
|
|
||||||
northEast: {
|
|
||||||
lat: -90,
|
|
||||||
lon: -180,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
fileObservers.update(($fileObservers) => {
|
|
||||||
files.forEach((file, index) => {
|
|
||||||
if (file === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let id = `gpx-${index}-embed`;
|
|
||||||
file._data.id = id;
|
|
||||||
let statistics = new GPXStatisticsTree(file);
|
|
||||||
|
|
||||||
$fileObservers.set(
|
|
||||||
id,
|
|
||||||
readable({
|
|
||||||
file,
|
|
||||||
statistics,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
ids.push(id);
|
|
||||||
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);
|
|
||||||
bounds.northEast.lat = Math.max(bounds.northEast.lat, fileBounds.northEast.lat);
|
|
||||||
bounds.northEast.lon = Math.max(bounds.northEast.lon, fileBounds.northEast.lon);
|
|
||||||
});
|
|
||||||
|
|
||||||
return $fileObservers;
|
|
||||||
});
|
|
||||||
|
|
||||||
$fileOrder = [...$fileOrder.filter((id) => !id.includes('embed')), ...ids];
|
|
||||||
|
|
||||||
selection.update(($selection) => {
|
|
||||||
$selection.clear();
|
|
||||||
ids.forEach((id) => {
|
|
||||||
$selection.toggle(new ListFileItem(id));
|
|
||||||
});
|
|
||||||
return $selection;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (hash.length === 0) {
|
|
||||||
map.subscribe(($map) => {
|
|
||||||
if ($map) {
|
|
||||||
$map.fitBounds(
|
|
||||||
[
|
|
||||||
bounds.southWest.lon,
|
|
||||||
bounds.southWest.lat,
|
|
||||||
bounds.northEast.lon,
|
|
||||||
bounds.northEast.lat,
|
|
||||||
],
|
|
||||||
{
|
|
||||||
padding: 80,
|
|
||||||
linear: true,
|
|
||||||
easing: () => 1,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
Promise.all(downloads).then((answers) => {
|
||||||
|
const files = answers.filter((file) => file !== null) as GPXFile[];
|
||||||
|
let ids: string[] = [];
|
||||||
|
files.forEach((file, index) => {
|
||||||
|
let id = `gpx-${index}-embed`;
|
||||||
|
file._data.id = id;
|
||||||
|
ids.push(id);
|
||||||
|
});
|
||||||
|
fileStateCollection.setEmbeddedFiles(files);
|
||||||
|
$fileOrder = ids;
|
||||||
|
selection.selectAll();
|
||||||
});
|
});
|
||||||
|
if (allowedEmbeddingBasemaps.includes(options.basemap)) {
|
||||||
if (
|
|
||||||
options.basemap !== $currentBasemap &&
|
|
||||||
allowedEmbeddingBasemaps.includes(options.basemap)
|
|
||||||
) {
|
|
||||||
$currentBasemap = options.basemap;
|
$currentBasemap = options.basemap;
|
||||||
}
|
}
|
||||||
|
if (!isSelected($selectedBasemapTree, options.basemap)) {
|
||||||
if (options.distanceMarkers !== $distanceMarkers) {
|
$selectedBasemapTree = toggle($selectedBasemapTree, options.basemap);
|
||||||
$distanceMarkers = options.distanceMarkers;
|
|
||||||
}
|
}
|
||||||
|
$distanceMarkers = options.distanceMarkers;
|
||||||
if (options.directionMarkers !== $directionMarkers) {
|
$directionMarkers = options.directionMarkers;
|
||||||
$directionMarkers = options.directionMarkers;
|
$distanceUnits = options.distanceUnits;
|
||||||
}
|
$velocityUnits = options.velocityUnits;
|
||||||
|
$temperatureUnits = options.temperatureUnits;
|
||||||
if (options.distanceUnits !== $distanceUnits) {
|
if (options.theme != 'system') {
|
||||||
$distanceUnits = options.distanceUnits;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.velocityUnits !== $velocityUnits) {
|
|
||||||
$velocityUnits = options.velocityUnits;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.temperatureUnits !== $temperatureUnits) {
|
|
||||||
$temperatureUnits = options.temperatureUnits;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.theme !== $mode) {
|
|
||||||
setMode(options.theme);
|
setMode(options.theme);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
additionalDatasets.set(
|
||||||
|
[
|
||||||
|
options.elevation.speed ? 'speed' : null,
|
||||||
|
options.elevation.hr ? 'hr' : null,
|
||||||
|
options.elevation.cad ? 'cad' : null,
|
||||||
|
options.elevation.temp ? 'temp' : null,
|
||||||
|
options.elevation.power ? 'power' : null,
|
||||||
|
].filter((dataset) => dataset !== null)
|
||||||
|
);
|
||||||
|
elevationFill.set(options.elevation.fill == 'none' ? undefined : options.elevation.fill);
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
$effect(() => {
|
||||||
prevSettings.distanceMarkers = $distanceMarkers;
|
options;
|
||||||
prevSettings.directionMarkers = $directionMarkers;
|
untrack(applyOptions);
|
||||||
prevSettings.distanceUnits = $distanceUnits;
|
|
||||||
prevSettings.velocityUnits = $velocityUnits;
|
|
||||||
prevSettings.temperatureUnits = $temperatureUnits;
|
|
||||||
prevSettings.theme = $mode ?? 'system';
|
|
||||||
});
|
|
||||||
|
|
||||||
$: if (browser && options) {
|
|
||||||
applyOptions();
|
|
||||||
}
|
|
||||||
|
|
||||||
$: if ($fileOrder) {
|
|
||||||
updateGPXData();
|
|
||||||
}
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
if ($distanceMarkers !== prevSettings.distanceMarkers) {
|
|
||||||
$distanceMarkers = prevSettings.distanceMarkers;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($directionMarkers !== prevSettings.directionMarkers) {
|
|
||||||
$directionMarkers = prevSettings.directionMarkers;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($distanceUnits !== prevSettings.distanceUnits) {
|
|
||||||
$distanceUnits = prevSettings.distanceUnits;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($velocityUnits !== prevSettings.velocityUnits) {
|
|
||||||
$velocityUnits = prevSettings.velocityUnits;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($temperatureUnits !== prevSettings.temperatureUnits) {
|
|
||||||
$temperatureUnits = prevSettings.temperatureUnits;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($mode !== prevSettings.theme) {
|
|
||||||
setMode(prevSettings.theme);
|
|
||||||
}
|
|
||||||
|
|
||||||
$selection.clear();
|
|
||||||
$fileObservers.clear();
|
|
||||||
$fileOrder = $fileOrder.filter((id) => !id.includes('embed'));
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="absolute flex flex-col h-full w-full border rounded-xl overflow-clip">
|
<div class="absolute flex flex-col h-full w-full border rounded-xl overflow-clip">
|
||||||
<div class="grow relative">
|
<div class="grow relative">
|
||||||
<Map
|
<Map
|
||||||
class="h-full {$fileObservers.size > 1 ? 'horizontal' : ''}"
|
class="h-full {$fileStateCollection.size > 1 ? 'horizontal' : ''}"
|
||||||
accessToken={options.token}
|
accessToken={options.token}
|
||||||
geocoder={false}
|
geocoder={false}
|
||||||
geolocate={false}
|
geolocate={true}
|
||||||
hash={useHash}
|
hash={useHash}
|
||||||
/>
|
/>
|
||||||
<OpenIn bind:files={options.files} bind:ids={options.ids} />
|
<OpenIn files={options.files} ids={options.ids} />
|
||||||
<LayerControl />
|
<LayerControl />
|
||||||
<GPXLayers />
|
<GPXLayers />
|
||||||
{#if $fileObservers.size > 1}
|
{#if $fileStateCollection.size > 1}
|
||||||
<div class="h-10 -translate-y-10 w-full pointer-events-none absolute z-30">
|
<div class="h-10 -translate-y-10 w-full pointer-events-none absolute z-30">
|
||||||
<FileList orientation="horizontal" />
|
<FileList orientation="horizontal" />
|
||||||
</div>
|
</div>
|
||||||
@@ -256,14 +130,8 @@
|
|||||||
<ElevationProfile
|
<ElevationProfile
|
||||||
{gpxStatistics}
|
{gpxStatistics}
|
||||||
{slicedGPXStatistics}
|
{slicedGPXStatistics}
|
||||||
additionalDatasets={[
|
{additionalDatasets}
|
||||||
options.elevation.speed ? 'speed' : null,
|
{elevationFill}
|
||||||
options.elevation.hr ? 'hr' : null,
|
|
||||||
options.elevation.cad ? 'cad' : null,
|
|
||||||
options.elevation.temp ? 'temp' : null,
|
|
||||||
options.elevation.power ? 'power' : null,
|
|
||||||
].filter((dataset) => dataset !== null)}
|
|
||||||
elevationFill={options.elevation.fill}
|
|
||||||
showControls={options.elevation.controls}
|
showControls={options.elevation.controls}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -14,67 +14,65 @@
|
|||||||
Coins,
|
Coins,
|
||||||
Milestone,
|
Milestone,
|
||||||
Video,
|
Video,
|
||||||
} from 'lucide-svelte';
|
} from '@lucide/svelte';
|
||||||
import { _ } from 'svelte-i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import {
|
import {
|
||||||
allowedEmbeddingBasemaps,
|
allowedEmbeddingBasemaps,
|
||||||
|
defaultEmbeddingOptions,
|
||||||
getCleanedEmbeddingOptions,
|
getCleanedEmbeddingOptions,
|
||||||
getDefaultEmbeddingOptions,
|
getMergedEmbeddingOptions,
|
||||||
} from './Embedding';
|
} from './embedding';
|
||||||
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
|
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
|
||||||
import Embedding from './Embedding.svelte';
|
import Embedding from './Embedding.svelte';
|
||||||
import { map } from '$lib/stores';
|
import { onDestroy } from 'svelte';
|
||||||
import { tick } from 'svelte';
|
|
||||||
import { base } from '$app/paths';
|
import { base } from '$app/paths';
|
||||||
|
import { map } from '$lib/components/map/map';
|
||||||
|
import { mode } from 'mode-watcher';
|
||||||
|
|
||||||
let options = getDefaultEmbeddingOptions();
|
let options = $state(
|
||||||
options.token = 'YOUR_MAPBOX_TOKEN';
|
getMergedEmbeddingOptions(
|
||||||
options.files = [
|
{
|
||||||
'https://raw.githubusercontent.com/gpxstudio/gpx.studio/main/gpx/test-data/simple.gpx',
|
token: 'YOUR_MAPBOX_TOKEN',
|
||||||
];
|
theme: mode.current,
|
||||||
|
},
|
||||||
|
defaultEmbeddingOptions
|
||||||
|
)
|
||||||
|
);
|
||||||
|
let files = $state(
|
||||||
|
'https://raw.githubusercontent.com/gpxstudio/gpx.studio/main/gpx/test-data/simple.gpx'
|
||||||
|
);
|
||||||
|
let driveIds = $state('');
|
||||||
|
|
||||||
let files = options.files[0];
|
let iframeOptions = $derived(
|
||||||
$: {
|
getMergedEmbeddingOptions(
|
||||||
let urls = files.split(',');
|
{
|
||||||
urls = urls.filter((url) => url.length > 0);
|
token:
|
||||||
if (JSON.stringify(urls) !== JSON.stringify(options.files)) {
|
options.token.length === 0 || options.token === 'YOUR_MAPBOX_TOKEN'
|
||||||
options.files = urls;
|
? PUBLIC_MAPBOX_TOKEN
|
||||||
|
: options.token,
|
||||||
|
files: files.split(',').filter((url) => url.length > 0),
|
||||||
|
ids: driveIds.split(',').filter((id) => id.length > 0),
|
||||||
|
elevation: {
|
||||||
|
fill: options.elevation.fill === 'none' ? undefined : options.elevation.fill,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
options
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
let manualCamera = $state(false);
|
||||||
|
let zoom = $state('0');
|
||||||
|
let lat = $state('0');
|
||||||
|
let lon = $state('0');
|
||||||
|
let bearing = $state('0');
|
||||||
|
let pitch = $state('0');
|
||||||
|
let hash = $derived(manualCamera ? `#${zoom}/${lat}/${lon}/${bearing}/${pitch}` : '');
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (options.elevation.show || options.elevation.height) {
|
||||||
|
map.resize();
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
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;
|
|
||||||
|
|
||||||
let zoom = '0';
|
|
||||||
let lat = '0';
|
|
||||||
let lon = '0';
|
|
||||||
let bearing = '0';
|
|
||||||
let pitch = '0';
|
|
||||||
|
|
||||||
$: hash = manualCamera ? `#${zoom}/${lat}/${lon}/${bearing}/${pitch}` : '';
|
|
||||||
|
|
||||||
$: iframeOptions =
|
|
||||||
options.token.length === 0 || options.token === 'YOUR_MAPBOX_TOKEN'
|
|
||||||
? Object.assign({}, options, { token: PUBLIC_MAPBOX_TOKEN })
|
|
||||||
: options;
|
|
||||||
|
|
||||||
async function resizeMap() {
|
|
||||||
if ($map) {
|
|
||||||
await tick();
|
|
||||||
$map.resize();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$: if (options.elevation.height || options.elevation.show) {
|
|
||||||
resizeMap();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateCamera() {
|
function updateCamera() {
|
||||||
if ($map) {
|
if ($map) {
|
||||||
@@ -87,49 +85,50 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$: if ($map) {
|
map.onLoad((map_) => {
|
||||||
$map.on('moveend', updateCamera);
|
map_.on('moveend', updateCamera);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if ($map) {
|
||||||
|
$map.off('moveend', updateCamera);
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card.Root id="embedding-playground">
|
<Card.Root id="embedding-playground">
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<Card.Title>{$_('embedding.title')}</Card.Title>
|
<Card.Title>{i18n._('embedding.title')}</Card.Title>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content>
|
<Card.Content>
|
||||||
<fieldset class="flex flex-col gap-3">
|
<fieldset class="flex flex-col gap-3">
|
||||||
<Label for="token">{$_('embedding.mapbox_token')}</Label>
|
<Label for="token">{i18n._('embedding.mapbox_token')}</Label>
|
||||||
<Input id="token" type="text" class="h-8" bind:value={options.token} />
|
<Input id="token" type="text" class="h-8" bind:value={options.token} />
|
||||||
<Label for="file_urls">{$_('embedding.file_urls')}</Label>
|
<Label for="file_urls">{i18n._('embedding.file_urls')}</Label>
|
||||||
<Input id="file_urls" type="text" class="h-8" bind:value={files} />
|
<Input id="file_urls" type="text" class="h-8" bind:value={files} />
|
||||||
<Label for="drive_ids">{$_('embedding.drive_ids')}</Label>
|
<Label for="drive_ids">{i18n._('embedding.drive_ids')}</Label>
|
||||||
<Input id="drive_ids" type="text" class="h-8" bind:value={driveIds} />
|
<Input id="drive_ids" type="text" class="h-8" bind:value={driveIds} />
|
||||||
<Label for="basemap">{$_('embedding.basemap')}</Label>
|
<Label for="basemap">{i18n._('embedding.basemap')}</Label>
|
||||||
<Select.Root
|
<Select.Root type="single" bind:value={options.basemap}>
|
||||||
selected={{ value: options.basemap, label: $_(`layers.label.${options.basemap}`) }}
|
|
||||||
onSelectedChange={(selected) => {
|
|
||||||
if (selected?.value) {
|
|
||||||
options.basemap = selected?.value;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Select.Trigger id="basemap" class="w-full h-8">
|
<Select.Trigger id="basemap" class="w-full h-8">
|
||||||
<Select.Value />
|
{i18n._(`layers.label.${options.basemap}`)}
|
||||||
</Select.Trigger>
|
</Select.Trigger>
|
||||||
<Select.Content class="max-h-60 overflow-y-scroll">
|
<Select.Content class="max-h-60 overflow-y-scroll">
|
||||||
{#each allowedEmbeddingBasemaps as basemap}
|
{#each allowedEmbeddingBasemaps as basemap}
|
||||||
<Select.Item value={basemap}>{$_(`layers.label.${basemap}`)}</Select.Item>
|
<Select.Item value={basemap}
|
||||||
|
>{i18n._(`layers.label.${basemap}`)}</Select.Item
|
||||||
|
>
|
||||||
{/each}
|
{/each}
|
||||||
</Select.Content>
|
</Select.Content>
|
||||||
</Select.Root>
|
</Select.Root>
|
||||||
<div class="flex flex-row items-center gap-2">
|
<div class="flex flex-row items-center gap-2">
|
||||||
<Label for="profile">{$_('menu.elevation_profile')}</Label>
|
<Label for="profile">{i18n._('menu.elevation_profile')}</Label>
|
||||||
<Checkbox id="profile" bind:checked={options.elevation.show} />
|
<Checkbox id="profile" bind:checked={options.elevation.show} />
|
||||||
</div>
|
</div>
|
||||||
{#if options.elevation.show}
|
{#if options.elevation.show}
|
||||||
<div class="grid grid-cols-2 gap-x-6 gap-y-3 rounded-md border p-3 mt-1">
|
<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">
|
<Label class="flex flex-row items-center gap-2">
|
||||||
{$_('embedding.height')}
|
{i18n._('embedding.height')}
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
bind:value={options.elevation.height}
|
bind:value={options.elevation.height}
|
||||||
@@ -138,73 +137,64 @@
|
|||||||
</Label>
|
</Label>
|
||||||
<div class="flex flex-row items-center gap-2">
|
<div class="flex flex-row items-center gap-2">
|
||||||
<span class="shrink-0">
|
<span class="shrink-0">
|
||||||
{$_('embedding.fill_by')}
|
{i18n._('embedding.fill_by')}
|
||||||
</span>
|
</span>
|
||||||
<Select.Root
|
<Select.Root type="single" bind:value={options.elevation.fill}>
|
||||||
selected={{ value: 'none', label: $_('embedding.none') }}
|
|
||||||
onSelectedChange={(selected) => {
|
|
||||||
let value = selected?.value;
|
|
||||||
if (value === 'none') {
|
|
||||||
options.elevation.fill = undefined;
|
|
||||||
} else if (
|
|
||||||
value === 'slope' ||
|
|
||||||
value === 'surface' ||
|
|
||||||
value === 'highway'
|
|
||||||
) {
|
|
||||||
options.elevation.fill = value;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Select.Trigger class="grow h-8">
|
<Select.Trigger class="grow h-8">
|
||||||
<Select.Value />
|
{options.elevation.fill !== 'none'
|
||||||
|
? i18n._(`quantities.${options.elevation.fill}`)
|
||||||
|
: i18n._('embedding.none')}
|
||||||
</Select.Trigger>
|
</Select.Trigger>
|
||||||
<Select.Content>
|
<Select.Content>
|
||||||
<Select.Item value="slope">{$_('quantities.slope')}</Select.Item>
|
<Select.Item value="slope">{i18n._('quantities.slope')}</Select.Item
|
||||||
<Select.Item value="surface">{$_('quantities.surface')}</Select.Item
|
|
||||||
>
|
>
|
||||||
<Select.Item value="highway">{$_('quantities.highway')}</Select.Item
|
<Select.Item value="surface"
|
||||||
|
>{i18n._('quantities.surface')}</Select.Item
|
||||||
>
|
>
|
||||||
<Select.Item value="none">{$_('embedding.none')}</Select.Item>
|
<Select.Item value="highway"
|
||||||
|
>{i18n._('quantities.highway')}</Select.Item
|
||||||
|
>
|
||||||
|
<Select.Item value="none">{i18n._('embedding.none')}</Select.Item>
|
||||||
</Select.Content>
|
</Select.Content>
|
||||||
</Select.Root>
|
</Select.Root>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row items-center gap-2">
|
<div class="flex flex-row items-center gap-2">
|
||||||
<Checkbox id="controls" bind:checked={options.elevation.controls} />
|
<Checkbox id="controls" bind:checked={options.elevation.controls} />
|
||||||
<Label for="controls">{$_('embedding.show_controls')}</Label>
|
<Label for="controls">{i18n._('embedding.show_controls')}</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row items-center gap-2">
|
<div class="flex flex-row items-center gap-2">
|
||||||
<Checkbox id="show-speed" bind:checked={options.elevation.speed} />
|
<Checkbox id="show-speed" bind:checked={options.elevation.speed} />
|
||||||
<Label for="show-speed" class="flex flex-row items-center gap-1">
|
<Label for="show-speed" class="flex flex-row items-center gap-1">
|
||||||
<Zap size="16" />
|
<Zap size="16" />
|
||||||
{$_('quantities.speed')}
|
{i18n._('quantities.speed')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row items-center gap-2">
|
<div class="flex flex-row items-center gap-2">
|
||||||
<Checkbox id="show-hr" bind:checked={options.elevation.hr} />
|
<Checkbox id="show-hr" bind:checked={options.elevation.hr} />
|
||||||
<Label for="show-hr" class="flex flex-row items-center gap-1">
|
<Label for="show-hr" class="flex flex-row items-center gap-1">
|
||||||
<HeartPulse size="16" />
|
<HeartPulse size="16" />
|
||||||
{$_('quantities.heartrate')}
|
{i18n._('quantities.heartrate')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row items-center gap-2">
|
<div class="flex flex-row items-center gap-2">
|
||||||
<Checkbox id="show-cad" bind:checked={options.elevation.cad} />
|
<Checkbox id="show-cad" bind:checked={options.elevation.cad} />
|
||||||
<Label for="show-cad" class="flex flex-row items-center gap-1">
|
<Label for="show-cad" class="flex flex-row items-center gap-1">
|
||||||
<Orbit size="16" />
|
<Orbit size="16" />
|
||||||
{$_('quantities.cadence')}
|
{i18n._('quantities.cadence')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row items-center gap-2">
|
<div class="flex flex-row items-center gap-2">
|
||||||
<Checkbox id="show-temp" bind:checked={options.elevation.temp} />
|
<Checkbox id="show-temp" bind:checked={options.elevation.temp} />
|
||||||
<Label for="show-temp" class="flex flex-row items-center gap-1">
|
<Label for="show-temp" class="flex flex-row items-center gap-1">
|
||||||
<Thermometer size="16" />
|
<Thermometer size="16" />
|
||||||
{$_('quantities.temperature')}
|
{i18n._('quantities.temperature')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row items-center gap-2">
|
<div class="flex flex-row items-center gap-2">
|
||||||
<Checkbox id="show-power" bind:checked={options.elevation.power} />
|
<Checkbox id="show-power" bind:checked={options.elevation.power} />
|
||||||
<Label for="show-power" class="flex flex-row items-center gap-1">
|
<Label for="show-power" class="flex flex-row items-center gap-1">
|
||||||
<SquareActivity size="16" />
|
<SquareActivity size="16" />
|
||||||
{$_('quantities.power')}
|
{i18n._('quantities.power')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -213,75 +203,75 @@
|
|||||||
<Checkbox id="distance-markers" bind:checked={options.distanceMarkers} />
|
<Checkbox id="distance-markers" bind:checked={options.distanceMarkers} />
|
||||||
<Label for="distance-markers" class="flex flex-row items-center gap-1">
|
<Label for="distance-markers" class="flex flex-row items-center gap-1">
|
||||||
<Coins size="16" />
|
<Coins size="16" />
|
||||||
{$_('menu.distance_markers')}
|
{i18n._('menu.distance_markers')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row items-center gap-2">
|
<div class="flex flex-row items-center gap-2">
|
||||||
<Checkbox id="direction-markers" bind:checked={options.directionMarkers} />
|
<Checkbox id="direction-markers" bind:checked={options.directionMarkers} />
|
||||||
<Label for="direction-markers" class="flex flex-row items-center gap-1">
|
<Label for="direction-markers" class="flex flex-row items-center gap-1">
|
||||||
<Milestone size="16" />
|
<Milestone size="16" />
|
||||||
{$_('menu.direction_markers')}
|
{i18n._('menu.direction_markers')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row flex-wrap justify-between gap-3">
|
<div class="flex flex-row flex-wrap justify-between gap-3">
|
||||||
<Label class="flex flex-col items-start gap-2">
|
<Label class="flex flex-col items-start gap-2">
|
||||||
{$_('menu.distance_units')}
|
{i18n._('menu.distance_units')}
|
||||||
<RadioGroup.Root bind:value={options.distanceUnits}>
|
<RadioGroup.Root bind:value={options.distanceUnits}>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<RadioGroup.Item value="metric" id="metric" />
|
<RadioGroup.Item value="metric" id="metric" />
|
||||||
<Label for="metric">{$_('menu.metric')}</Label>
|
<Label for="metric">{i18n._('menu.metric')}</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<RadioGroup.Item value="imperial" id="imperial" />
|
<RadioGroup.Item value="imperial" id="imperial" />
|
||||||
<Label for="imperial">{$_('menu.imperial')}</Label>
|
<Label for="imperial">{i18n._('menu.imperial')}</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<RadioGroup.Item value="nautical" id="nautical" />
|
<RadioGroup.Item value="nautical" id="nautical" />
|
||||||
<Label for="nautical">{$_('menu.nautical')}</Label>
|
<Label for="nautical">{i18n._('menu.nautical')}</Label>
|
||||||
</div>
|
</div>
|
||||||
</RadioGroup.Root>
|
</RadioGroup.Root>
|
||||||
</Label>
|
</Label>
|
||||||
<Label class="flex flex-col items-start gap-2">
|
<Label class="flex flex-col items-start gap-2">
|
||||||
{$_('menu.velocity_units')}
|
{i18n._('menu.velocity_units')}
|
||||||
<RadioGroup.Root bind:value={options.velocityUnits}>
|
<RadioGroup.Root bind:value={options.velocityUnits}>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<RadioGroup.Item value="speed" id="speed" />
|
<RadioGroup.Item value="speed" id="speed" />
|
||||||
<Label for="speed">{$_('quantities.speed')}</Label>
|
<Label for="speed">{i18n._('quantities.speed')}</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<RadioGroup.Item value="pace" id="pace" />
|
<RadioGroup.Item value="pace" id="pace" />
|
||||||
<Label for="pace">{$_('quantities.pace')}</Label>
|
<Label for="pace">{i18n._('quantities.pace')}</Label>
|
||||||
</div>
|
</div>
|
||||||
</RadioGroup.Root>
|
</RadioGroup.Root>
|
||||||
</Label>
|
</Label>
|
||||||
<Label class="flex flex-col items-start gap-2">
|
<Label class="flex flex-col items-start gap-2">
|
||||||
{$_('menu.temperature_units')}
|
{i18n._('menu.temperature_units')}
|
||||||
<RadioGroup.Root bind:value={options.temperatureUnits}>
|
<RadioGroup.Root bind:value={options.temperatureUnits}>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<RadioGroup.Item value="celsius" id="celsius" />
|
<RadioGroup.Item value="celsius" id="celsius" />
|
||||||
<Label for="celsius">{$_('menu.celsius')}</Label>
|
<Label for="celsius">{i18n._('menu.celsius')}</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<RadioGroup.Item value="fahrenheit" id="fahrenheit" />
|
<RadioGroup.Item value="fahrenheit" id="fahrenheit" />
|
||||||
<Label for="fahrenheit">{$_('menu.fahrenheit')}</Label>
|
<Label for="fahrenheit">{i18n._('menu.fahrenheit')}</Label>
|
||||||
</div>
|
</div>
|
||||||
</RadioGroup.Root>
|
</RadioGroup.Root>
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<Label class="flex flex-col items-start gap-2">
|
<Label class="flex flex-col items-start gap-2">
|
||||||
{$_('menu.mode')}
|
{i18n._('menu.mode')}
|
||||||
<RadioGroup.Root bind:value={options.theme} class="flex flex-row">
|
<RadioGroup.Root bind:value={options.theme} class="flex flex-row">
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<RadioGroup.Item value="system" id="system" />
|
<RadioGroup.Item value="system" id="system" />
|
||||||
<Label for="system">{$_('menu.system')}</Label>
|
<Label for="system">{i18n._('menu.system')}</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<RadioGroup.Item value="light" id="light" />
|
<RadioGroup.Item value="light" id="light" />
|
||||||
<Label for="light">{$_('menu.light')}</Label>
|
<Label for="light">{i18n._('menu.light')}</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<RadioGroup.Item value="dark" id="dark" />
|
<RadioGroup.Item value="dark" id="dark" />
|
||||||
<Label for="dark">{$_('menu.dark')}</Label>
|
<Label for="dark">{i18n._('menu.dark')}</Label>
|
||||||
</div>
|
</div>
|
||||||
</RadioGroup.Root>
|
</RadioGroup.Root>
|
||||||
</Label>
|
</Label>
|
||||||
@@ -290,48 +280,48 @@
|
|||||||
<Checkbox id="manual-camera" bind:checked={manualCamera} />
|
<Checkbox id="manual-camera" bind:checked={manualCamera} />
|
||||||
<Label for="manual-camera" class="flex flex-row items-center gap-1">
|
<Label for="manual-camera" class="flex flex-row items-center gap-1">
|
||||||
<Video size="16" />
|
<Video size="16" />
|
||||||
{$_('embedding.manual_camera')}
|
{i18n._('embedding.manual_camera')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-muted-foreground">
|
<p class="text-sm text-muted-foreground">
|
||||||
{$_('embedding.manual_camera_description')}
|
{i18n._('embedding.manual_camera_description')}
|
||||||
</p>
|
</p>
|
||||||
<div class="flex flex-row flex-wrap items-center gap-6">
|
<div class="flex flex-row flex-wrap items-center gap-6">
|
||||||
<Label class="flex flex-col gap-1">
|
<Label class="flex flex-col gap-1">
|
||||||
<span>{$_('embedding.latitude')}</span>
|
<span>{i18n._('embedding.latitude')}</span>
|
||||||
<span>{lat}</span>
|
<span>{lat}</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Label class="flex flex-col gap-1">
|
<Label class="flex flex-col gap-1">
|
||||||
<span>{$_('embedding.longitude')}</span>
|
<span>{i18n._('embedding.longitude')}</span>
|
||||||
<span>{lon}</span>
|
<span>{lon}</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Label class="flex flex-col gap-1">
|
<Label class="flex flex-col gap-1">
|
||||||
<span>{$_('embedding.zoom')}</span>
|
<span>{i18n._('embedding.zoom')}</span>
|
||||||
<span>{zoom}</span>
|
<span>{zoom}</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Label class="flex flex-col gap-1">
|
<Label class="flex flex-col gap-1">
|
||||||
<span>{$_('embedding.bearing')}</span>
|
<span>{i18n._('embedding.bearing')}</span>
|
||||||
<span>{bearing}</span>
|
<span>{bearing}</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Label class="flex flex-col gap-1">
|
<Label class="flex flex-col gap-1">
|
||||||
<span>{$_('embedding.pitch')}</span>
|
<span>{i18n._('embedding.pitch')}</span>
|
||||||
<span>{pitch}</span>
|
<span>{pitch}</span>
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Label>
|
<Label>
|
||||||
{$_('embedding.preview')}
|
{i18n._('embedding.preview')}
|
||||||
</Label>
|
</Label>
|
||||||
<div class="relative h-[600px]">
|
<div class="relative h-[600px]">
|
||||||
<Embedding bind:options={iframeOptions} bind:hash useHash={false} />
|
<Embedding options={iframeOptions} bind:hash useHash={false} />
|
||||||
</div>
|
</div>
|
||||||
<Label>
|
<Label>
|
||||||
{$_('embedding.code')}
|
{i18n._('embedding.code')}
|
||||||
</Label>
|
</Label>
|
||||||
<pre
|
<pre
|
||||||
class="bg-primary text-primary-foreground p-3 rounded-md whitespace-normal break-all">
|
class="bg-primary text-primary-foreground p-3 rounded-md whitespace-normal break-all">
|
||||||
<code class="language-html">
|
<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;"/>`}
|
{`<iframe src="https://gpx.studio${base}/embed?options=${encodeURIComponent(JSON.stringify(getCleanedEmbeddingOptions(iframeOptions)))}${hash}" width="100%" height="600px" frameborder="0" style="outline: none;"/>`}
|
||||||
</code>
|
</code>
|
||||||
</pre>
|
</pre>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@@ -2,22 +2,27 @@
|
|||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import Logo from '$lib/components/Logo.svelte';
|
import Logo from '$lib/components/Logo.svelte';
|
||||||
import { getURLForLanguage } from '$lib/utils';
|
import { getURLForLanguage } from '$lib/utils';
|
||||||
import { _, locale } from 'svelte-i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
|
|
||||||
export let files: string[];
|
let {
|
||||||
export let ids: string[];
|
files,
|
||||||
|
ids,
|
||||||
|
}: {
|
||||||
|
files: string[];
|
||||||
|
ids: string[];
|
||||||
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
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"
|
class="absolute top-0 flex-wrap h-fit bg-background font-semibold rounded-md py-1 px-2 gap-1.5 xs:text-base mt-2.5 ml-2.5 mr-12"
|
||||||
href="{getURLForLanguage($locale, '/app')}?{files.length > 0
|
href="{getURLForLanguage(i18n.lang, '/app')}?{files.length > 0
|
||||||
? `files=${encodeURIComponent(JSON.stringify(files))}`
|
? `files=${encodeURIComponent(JSON.stringify(files))}`
|
||||||
: ''}{files.length > 0 && ids.length > 0 ? '&' : ''}{ids.length > 0
|
: ''}{files.length > 0 && ids.length > 0 ? '&' : ''}{ids.length > 0
|
||||||
? `ids=${encodeURIComponent(JSON.stringify(ids))}`
|
? `ids=${encodeURIComponent(JSON.stringify(ids))}`
|
||||||
: ''}"
|
: ''}"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
{$_('menu.open_in')}
|
{i18n._('menu.open_in')}
|
||||||
<Logo class="h-[18px] xs:h-5 translate-y-[1px]" />
|
<Logo class="h-[18px] xs:h-5 translate-y-[1px]" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export type EmbeddingOptions = {
|
|||||||
show: boolean;
|
show: boolean;
|
||||||
height: number;
|
height: number;
|
||||||
controls: boolean;
|
controls: boolean;
|
||||||
fill: 'slope' | 'surface' | 'highway' | undefined;
|
fill: 'slope' | 'surface' | 'highway' | 'none';
|
||||||
speed: boolean;
|
speed: boolean;
|
||||||
hr: boolean;
|
hr: boolean;
|
||||||
cad: boolean;
|
cad: boolean;
|
||||||
@@ -34,7 +34,7 @@ export const defaultEmbeddingOptions = {
|
|||||||
show: true,
|
show: true,
|
||||||
height: 170,
|
height: 170,
|
||||||
controls: true,
|
controls: true,
|
||||||
fill: undefined,
|
fill: 'none',
|
||||||
speed: false,
|
speed: false,
|
||||||
hr: false,
|
hr: false,
|
||||||
cad: false,
|
cad: false,
|
||||||
@@ -49,10 +49,6 @@ export const defaultEmbeddingOptions = {
|
|||||||
theme: 'system',
|
theme: 'system',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getDefaultEmbeddingOptions(): EmbeddingOptions {
|
|
||||||
return JSON.parse(JSON.stringify(defaultEmbeddingOptions));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMergedEmbeddingOptions(
|
export function getMergedEmbeddingOptions(
|
||||||
options: any,
|
options: any,
|
||||||
defaultOptions: any = defaultEmbeddingOptions
|
defaultOptions: any = defaultEmbeddingOptions
|
||||||
@@ -5,14 +5,12 @@
|
|||||||
import { Separator } from '$lib/components/ui/separator';
|
import { Separator } from '$lib/components/ui/separator';
|
||||||
import { Dialog } from 'bits-ui';
|
import { Dialog } from 'bits-ui';
|
||||||
import {
|
import {
|
||||||
currentTool,
|
|
||||||
exportAllFiles,
|
exportAllFiles,
|
||||||
exportSelectedFiles,
|
exportSelectedFiles,
|
||||||
ExportState,
|
ExportState,
|
||||||
exportState,
|
exportState,
|
||||||
gpxStatistics,
|
} from '$lib/components/export/utils.svelte';
|
||||||
} from '$lib/stores';
|
import { currentTool } from '$lib/components/toolbar/tools';
|
||||||
import { fileObservers } from '$lib/db';
|
|
||||||
import {
|
import {
|
||||||
Download,
|
Download,
|
||||||
Zap,
|
Zap,
|
||||||
@@ -21,63 +19,70 @@
|
|||||||
Orbit,
|
Orbit,
|
||||||
Thermometer,
|
Thermometer,
|
||||||
SquareActivity,
|
SquareActivity,
|
||||||
} from 'lucide-svelte';
|
} from '@lucide/svelte';
|
||||||
import { _ } from 'svelte-i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import { selection } from './file-list/Selection';
|
|
||||||
import { get } from 'svelte/store';
|
|
||||||
import { GPXStatistics } from 'gpx';
|
import { GPXStatistics } from 'gpx';
|
||||||
import { ListRootItem } from './file-list/FileList';
|
import { ListRootItem } from '$lib/components/file-list/file-list';
|
||||||
|
import { fileStateCollection } from '$lib/logic/file-state';
|
||||||
|
import { selection } from '$lib/logic/selection';
|
||||||
|
import { gpxStatistics } from '$lib/logic/statistics';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
let open = false;
|
let open = $derived(exportState.current !== ExportState.NONE);
|
||||||
let exportOptions: Record<string, boolean> = {
|
let exportOptions: Record<string, boolean> = $state({
|
||||||
time: true,
|
time: true,
|
||||||
hr: true,
|
hr: true,
|
||||||
cad: true,
|
cad: true,
|
||||||
atemp: true,
|
atemp: true,
|
||||||
power: true,
|
power: true,
|
||||||
extensions: true,
|
|
||||||
};
|
|
||||||
let hide: Record<string, boolean> = {
|
|
||||||
time: false,
|
|
||||||
hr: false,
|
|
||||||
cad: false,
|
|
||||||
atemp: false,
|
|
||||||
power: false,
|
|
||||||
extensions: false,
|
extensions: false,
|
||||||
};
|
});
|
||||||
|
let hide: Record<string, boolean> = $derived.by(() => {
|
||||||
$: if ($exportState !== ExportState.NONE) {
|
if (exportState.current === ExportState.NONE) {
|
||||||
open = true;
|
return {
|
||||||
$currentTool = null;
|
time: false,
|
||||||
|
hr: false,
|
||||||
let statistics = $gpxStatistics;
|
cad: false,
|
||||||
if ($exportState === ExportState.ALL) {
|
atemp: false,
|
||||||
statistics = Array.from($fileObservers.values())
|
power: false,
|
||||||
.map((file) => get(file)?.statistics)
|
extensions: false,
|
||||||
.reduce((acc, cur) => {
|
};
|
||||||
if (cur !== undefined) {
|
} else {
|
||||||
acc.mergeWith(cur.getStatisticsFor(new ListRootItem()));
|
let statistics = $gpxStatistics;
|
||||||
}
|
if (exportState.current === ExportState.ALL) {
|
||||||
return acc;
|
statistics = Array.from(get(fileStateCollection).values())
|
||||||
}, new GPXStatistics());
|
.map((file) => file.statistics)
|
||||||
|
.reduce((acc, cur) => {
|
||||||
|
if (cur !== undefined) {
|
||||||
|
acc.mergeWith(cur.getStatisticsFor(new ListRootItem()));
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, new GPXStatistics());
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
time: statistics.global.time.total === 0,
|
||||||
|
hr: statistics.global.hr.count === 0,
|
||||||
|
cad: statistics.global.cad.count === 0,
|
||||||
|
atemp: statistics.global.atemp.count === 0,
|
||||||
|
power: statistics.global.power.count === 0,
|
||||||
|
extensions: Object.keys(statistics.global.extensions).length === 0,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
let exclude = $derived(Object.keys(exportOptions).filter((key) => !exportOptions[key]));
|
||||||
|
|
||||||
hide.time = statistics.global.time.total === 0;
|
$effect(() => {
|
||||||
hide.hr = statistics.global.hr.count === 0;
|
if (open) {
|
||||||
hide.cad = statistics.global.cad.count === 0;
|
currentTool.set(null);
|
||||||
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]);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dialog.Root
|
<Dialog.Root
|
||||||
bind:open
|
bind:open
|
||||||
onOpenChange={(isOpen) => {
|
onOpenChange={(isOpen) => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
$exportState = ExportState.NONE;
|
exportState.current = ExportState.NONE;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -87,36 +92,36 @@
|
|||||||
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"
|
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
|
<div
|
||||||
class="w-full flex flex-row items-center justify-center gap-4 border rounded-md p-2 bg-secondary"
|
class="w-full flex flex-col sm:flex-row items-center justify-center gap-1 sm:gap-2 border rounded-md p-2 bg-secondary"
|
||||||
>
|
>
|
||||||
<span>⚠️</span>
|
<span class="w-12 shrink-0 text-center text-xl">⚠️</span>
|
||||||
<span class="max-w-[80%] text-sm">
|
<span class="text-sm">
|
||||||
{$_('menu.support_message')}
|
{i18n._('menu.support_message')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full flex flex-row flex-wrap gap-2">
|
<div class="w-full flex flex-row flex-wrap gap-2">
|
||||||
<Button class="bg-support grow" href="https://ko-fi.com/gpxstudio" target="_blank">
|
<Button class="bg-support grow" href="https://ko-fi.com/gpxstudio" target="_blank">
|
||||||
{$_('menu.support_button')}
|
{i18n._('menu.support_button')}
|
||||||
<span class="ml-2">🙏</span>
|
<span>🙏</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="grow"
|
class="grow"
|
||||||
on:click={() => {
|
onclick={() => {
|
||||||
if ($exportState === ExportState.SELECTION) {
|
if (exportState.current === ExportState.SELECTION) {
|
||||||
exportSelectedFiles(exclude);
|
exportSelectedFiles(exclude);
|
||||||
} else if ($exportState === ExportState.ALL) {
|
} else if (exportState.current === ExportState.ALL) {
|
||||||
exportAllFiles(exclude);
|
exportAllFiles(exclude);
|
||||||
}
|
}
|
||||||
open = false;
|
open = false;
|
||||||
$exportState = ExportState.NONE;
|
exportState.current = ExportState.NONE;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Download size="16" class="mr-1" />
|
<Download size="16" />
|
||||||
{#if $fileObservers.size === 1 || ($exportState === ExportState.SELECTION && $selection.size === 1)}
|
{#if $fileStateCollection.size === 1 || (exportState.current === ExportState.SELECTION && $selection.size === 1)}
|
||||||
{$_('menu.download_file')}
|
{i18n._('menu.download_file')}
|
||||||
{:else}
|
{:else}
|
||||||
{$_('menu.download_files')}
|
{i18n._('menu.download_files')}
|
||||||
{/if}
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -132,7 +137,7 @@
|
|||||||
<Separator />
|
<Separator />
|
||||||
</div>
|
</div>
|
||||||
<Label class="shrink-0">
|
<Label class="shrink-0">
|
||||||
{$_('menu.export_options')}
|
{i18n._('menu.export_options')}
|
||||||
</Label>
|
</Label>
|
||||||
<div class="grow">
|
<div class="grow">
|
||||||
<Separator />
|
<Separator />
|
||||||
@@ -143,7 +148,35 @@
|
|||||||
<Checkbox id="export-time" bind:checked={exportOptions.time} />
|
<Checkbox id="export-time" bind:checked={exportOptions.time} />
|
||||||
<Label for="export-time" class="flex flex-row items-center gap-1">
|
<Label for="export-time" class="flex flex-row items-center gap-1">
|
||||||
<Zap size="16" />
|
<Zap size="16" />
|
||||||
{$_('quantities.time')}
|
{i18n._('quantities.time')}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row items-center gap-1.5 {hide.hr ? 'hidden' : ''}">
|
||||||
|
<Checkbox id="export-heartrate" bind:checked={exportOptions.hr} />
|
||||||
|
<Label for="export-heartrate" class="flex flex-row items-center gap-1">
|
||||||
|
<HeartPulse size="16" />
|
||||||
|
{i18n._('quantities.heartrate')}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row items-center gap-1.5 {hide.cad ? 'hidden' : ''}">
|
||||||
|
<Checkbox id="export-cadence" bind:checked={exportOptions.cad} />
|
||||||
|
<Label for="export-cadence" class="flex flex-row items-center gap-1">
|
||||||
|
<Orbit size="16" />
|
||||||
|
{i18n._('quantities.cadence')}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row items-center gap-1.5 {hide.atemp ? 'hidden' : ''}">
|
||||||
|
<Checkbox id="export-temperature" bind:checked={exportOptions.atemp} />
|
||||||
|
<Label for="export-temperature" class="flex flex-row items-center gap-1">
|
||||||
|
<Thermometer size="16" />
|
||||||
|
{i18n._('quantities.temperature')}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row items-center gap-1.5 {hide.power ? 'hidden' : ''}">
|
||||||
|
<Checkbox id="export-power" bind:checked={exportOptions.power} />
|
||||||
|
<Label for="export-power" class="flex flex-row items-center gap-1">
|
||||||
|
<SquareActivity size="16" />
|
||||||
|
{i18n._('quantities.power')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -152,35 +185,7 @@
|
|||||||
<Checkbox id="export-extensions" bind:checked={exportOptions.extensions} />
|
<Checkbox id="export-extensions" bind:checked={exportOptions.extensions} />
|
||||||
<Label for="export-extensions" class="flex flex-row items-center gap-1">
|
<Label for="export-extensions" class="flex flex-row items-center gap-1">
|
||||||
<Earth size="16" />
|
<Earth size="16" />
|
||||||
{$_('quantities.osm_extensions')}
|
{i18n._('quantities.osm_extensions')}
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-row items-center gap-1.5 {hide.hr ? 'hidden' : ''}">
|
|
||||||
<Checkbox id="export-heartrate" bind:checked={exportOptions.hr} />
|
|
||||||
<Label for="export-heartrate" class="flex flex-row items-center gap-1">
|
|
||||||
<HeartPulse size="16" />
|
|
||||||
{$_('quantities.heartrate')}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-row items-center gap-1.5 {hide.cad ? 'hidden' : ''}">
|
|
||||||
<Checkbox id="export-cadence" bind:checked={exportOptions.cad} />
|
|
||||||
<Label for="export-cadence" class="flex flex-row items-center gap-1">
|
|
||||||
<Orbit size="16" />
|
|
||||||
{$_('quantities.cadence')}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-row items-center gap-1.5 {hide.atemp ? 'hidden' : ''}">
|
|
||||||
<Checkbox id="export-temperature" bind:checked={exportOptions.atemp} />
|
|
||||||
<Label for="export-temperature" class="flex flex-row items-center gap-1">
|
|
||||||
<Thermometer size="16" />
|
|
||||||
{$_('quantities.temperature')}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-row items-center gap-1.5 {hide.power ? 'hidden' : ''}">
|
|
||||||
<Checkbox id="export-power" bind:checked={exportOptions.power} />
|
|
||||||
<Label for="export-power" class="flex flex-row items-center gap-1">
|
|
||||||
<SquareActivity size="16" />
|
|
||||||
{$_('quantities.power')}
|
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
66
website/src/lib/components/export/utils.svelte.ts
Normal file
66
website/src/lib/components/export/utils.svelte.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { selection } from '$lib/logic/selection';
|
||||||
|
import { fileStateCollection } from '$lib/logic/file-state';
|
||||||
|
import { settings } from '$lib/logic/settings';
|
||||||
|
import { buildGPX, type GPXFile } from 'gpx';
|
||||||
|
import FileSaver from 'file-saver';
|
||||||
|
import JSZip from 'jszip';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
|
export enum ExportState {
|
||||||
|
NONE,
|
||||||
|
SELECTION,
|
||||||
|
ALL,
|
||||||
|
}
|
||||||
|
export const exportState = $state({
|
||||||
|
current: ExportState.NONE,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function exportFiles(fileIds: string[], exclude: string[]) {
|
||||||
|
if (fileIds.length > 1) {
|
||||||
|
await exportFilesAsZip(fileIds, exclude);
|
||||||
|
} else {
|
||||||
|
const firstFileId = fileIds.at(0);
|
||||||
|
if (firstFileId != null) {
|
||||||
|
const file = fileStateCollection.getFile(firstFileId);
|
||||||
|
if (file) {
|
||||||
|
exportFile(file, exclude);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportSelectedFiles(exclude: string[]) {
|
||||||
|
const fileIds: string[] = [];
|
||||||
|
selection.applyToOrderedSelectedItemsFromFile(async (fileId, level, items) => {
|
||||||
|
fileIds.push(fileId);
|
||||||
|
});
|
||||||
|
await exportFiles(fileIds, exclude);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportAllFiles(exclude: string[]) {
|
||||||
|
await exportFiles(get(settings.fileOrder), exclude);
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportFile(file: GPXFile, exclude: string[]) {
|
||||||
|
const blob = new Blob([buildGPX(file, exclude)], { type: 'application/gpx+xml' });
|
||||||
|
FileSaver.saveAs(blob, `${file.metadata.name}.gpx`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportFilesAsZip(fileIds: string[], exclude: string[]) {
|
||||||
|
const zip = new JSZip();
|
||||||
|
for (const fileId of fileIds) {
|
||||||
|
const file = fileStateCollection.getFile(fileId);
|
||||||
|
if (file) {
|
||||||
|
const gpx = buildGPX(file, exclude);
|
||||||
|
let filename = file.metadata.name;
|
||||||
|
for (let i = 1; zip.files[filename + '.gpx']; i++) {
|
||||||
|
filename = file.metadata.name + `-${i}`;
|
||||||
|
}
|
||||||
|
zip.file(filename + '.gpx', gpx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(zip.files).length > 0) {
|
||||||
|
const blob = await zip.generateAsync({ type: 'blob' });
|
||||||
|
FileSaver.saveAs(blob, 'gpx-files.zip');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,34 +2,33 @@
|
|||||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index';
|
import { ScrollArea } from '$lib/components/ui/scroll-area/index';
|
||||||
import * as ContextMenu from '$lib/components/ui/context-menu';
|
import * as ContextMenu from '$lib/components/ui/context-menu';
|
||||||
import FileListNode from './FileListNode.svelte';
|
import FileListNode from './FileListNode.svelte';
|
||||||
import { fileObservers, settings } from '$lib/db';
|
import { onMount, setContext } from 'svelte';
|
||||||
import { setContext } from 'svelte';
|
import { ListFileItem, ListLevel, ListRootItem } from './file-list';
|
||||||
import { ListFileItem, ListLevel, ListRootItem, allowedPastes } from './FileList';
|
import { ClipboardPaste, FileStack, Plus } from '@lucide/svelte';
|
||||||
import { copied, pasteSelection, selectAll, selection } from './Selection';
|
|
||||||
import { ClipboardPaste, FileStack, Plus } from 'lucide-svelte';
|
|
||||||
import Shortcut from '$lib/components/Shortcut.svelte';
|
import Shortcut from '$lib/components/Shortcut.svelte';
|
||||||
import { _ } from 'svelte-i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import { createFile } from '$lib/stores';
|
import { fileStateCollection } from '$lib/logic/file-state';
|
||||||
|
import { createFile, pasteSelection } from '$lib/logic/file-actions';
|
||||||
|
import { selection, copied } from '$lib/logic/selection';
|
||||||
|
import { allowedPastes } from './sortable-file-list';
|
||||||
|
|
||||||
export let orientation: 'vertical' | 'horizontal';
|
let {
|
||||||
export let recursive = false;
|
orientation,
|
||||||
|
recursive = false,
|
||||||
|
class: className = '',
|
||||||
|
style = '',
|
||||||
|
}: {
|
||||||
|
orientation: 'vertical' | 'horizontal';
|
||||||
|
recursive?: boolean;
|
||||||
|
class?: string;
|
||||||
|
style?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
setContext('orientation', orientation);
|
setContext('orientation', orientation);
|
||||||
setContext('recursive', recursive);
|
setContext('recursive', recursive);
|
||||||
|
|
||||||
const { treeFileView } = settings;
|
onMount(() => {
|
||||||
|
if (orientation === 'horizontal') {
|
||||||
treeFileView.subscribe(($vertical) => {
|
|
||||||
if ($vertical) {
|
|
||||||
selection.update(($selection) => {
|
|
||||||
$selection.forEach((item) => {
|
|
||||||
if ($selection.hasAnyChildren(item, false)) {
|
|
||||||
$selection.toggle(item);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return $selection;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
selection.update(($selection) => {
|
selection.update(($selection) => {
|
||||||
$selection.forEach((item) => {
|
$selection.forEach((item) => {
|
||||||
if (!(item instanceof ListFileItem)) {
|
if (!(item instanceof ListFileItem)) {
|
||||||
@@ -46,29 +45,32 @@
|
|||||||
<ScrollArea
|
<ScrollArea
|
||||||
class="shrink-0 {orientation === 'vertical' ? 'p-0 pr-3' : 'h-10 px-1'}"
|
class="shrink-0 {orientation === 'vertical' ? 'p-0 pr-3' : 'h-10 px-1'}"
|
||||||
{orientation}
|
{orientation}
|
||||||
scrollbarXClasses={orientation === 'vertical' ? '' : 'mt-1 h-2'}
|
scrollbarXClasses={orientation === 'vertical' ? '' : 'hidden'}
|
||||||
scrollbarYClasses={orientation === 'vertical' ? '' : ''}
|
scrollbarYClasses={orientation === 'vertical' ? '' : ''}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex {orientation === 'vertical'
|
class="flex {orientation === 'vertical'
|
||||||
? 'flex-col py-1 pl-1 min-h-screen'
|
? 'flex-col py-1 pl-1 min-h-screen'
|
||||||
: 'flex-row'} {$$props.class ?? ''}"
|
: 'flex-row'} {className ?? ''}"
|
||||||
{...$$restProps}
|
{style}
|
||||||
>
|
>
|
||||||
<FileListNode bind:node={$fileObservers} item={new ListRootItem()} />
|
<FileListNode node={$fileStateCollection} item={new ListRootItem()} />
|
||||||
{#if orientation === 'vertical'}
|
{#if orientation === 'vertical'}
|
||||||
<ContextMenu.Root>
|
<ContextMenu.Root>
|
||||||
<ContextMenu.Trigger class="grow" />
|
<ContextMenu.Trigger class="grow" />
|
||||||
<ContextMenu.Content>
|
<ContextMenu.Content>
|
||||||
<ContextMenu.Item on:click={createFile}>
|
<ContextMenu.Item onclick={createFile}>
|
||||||
<Plus size="16" class="mr-1" />
|
<Plus size="16" />
|
||||||
{$_('menu.new_file')}
|
{i18n._('menu.new_file')}
|
||||||
<Shortcut key="+" ctrl={true} />
|
<Shortcut key="+" ctrl={true} />
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
<ContextMenu.Separator />
|
<ContextMenu.Separator />
|
||||||
<ContextMenu.Item on:click={selectAll} disabled={$fileObservers.size === 0}>
|
<ContextMenu.Item
|
||||||
<FileStack size="16" class="mr-1" />
|
onclick={() => selection.selectAll()}
|
||||||
{$_('menu.select_all')}
|
disabled={$fileStateCollection.size === 0}
|
||||||
|
>
|
||||||
|
<FileStack size="16" />
|
||||||
|
{i18n._('menu.select_all')}
|
||||||
<Shortcut key="A" ctrl={true} />
|
<Shortcut key="A" ctrl={true} />
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
<ContextMenu.Separator />
|
<ContextMenu.Separator />
|
||||||
@@ -76,10 +78,10 @@
|
|||||||
disabled={$copied === undefined ||
|
disabled={$copied === undefined ||
|
||||||
$copied.length === 0 ||
|
$copied.length === 0 ||
|
||||||
!allowedPastes[$copied[0].level].includes(ListLevel.ROOT)}
|
!allowedPastes[$copied[0].level].includes(ListLevel.ROOT)}
|
||||||
on:click={pasteSelection}
|
onclick={pasteSelection}
|
||||||
>
|
>
|
||||||
<ClipboardPaste size="16" class="mr-1" />
|
<ClipboardPaste size="16" />
|
||||||
{$_('menu.paste')}
|
{i18n._('menu.paste')}
|
||||||
<Shortcut key="V" ctrl={true} />
|
<Shortcut key="V" ctrl={true} />
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
</ContextMenu.Content>
|
</ContextMenu.Content>
|
||||||
|
|||||||
@@ -1,483 +0,0 @@
|
|||||||
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,
|
|
||||||
FILE,
|
|
||||||
TRACK,
|
|
||||||
SEGMENT,
|
|
||||||
WAYPOINTS,
|
|
||||||
WAYPOINT,
|
|
||||||
}
|
|
||||||
|
|
||||||
export const allowedMoves: Record<ListLevel, ListLevel[]> = {
|
|
||||||
[ListLevel.ROOT]: [],
|
|
||||||
[ListLevel.FILE]: [ListLevel.FILE],
|
|
||||||
[ListLevel.TRACK]: [ListLevel.FILE, ListLevel.TRACK],
|
|
||||||
[ListLevel.SEGMENT]: [ListLevel.FILE, ListLevel.TRACK, ListLevel.SEGMENT],
|
|
||||||
[ListLevel.WAYPOINTS]: [ListLevel.WAYPOINTS],
|
|
||||||
[ListLevel.WAYPOINT]: [ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const allowedPastes: Record<ListLevel, ListLevel[]> = {
|
|
||||||
[ListLevel.ROOT]: [],
|
|
||||||
[ListLevel.FILE]: [ListLevel.ROOT, ListLevel.FILE],
|
|
||||||
[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],
|
|
||||||
};
|
|
||||||
|
|
||||||
export abstract class ListItem {
|
|
||||||
level: ListLevel;
|
|
||||||
|
|
||||||
constructor(level: ListLevel) {
|
|
||||||
this.level = level;
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract getId(): string | number;
|
|
||||||
abstract getFullId(): string;
|
|
||||||
abstract getIdAtLevel(level: ListLevel): string | number | undefined;
|
|
||||||
abstract getFileId(): string;
|
|
||||||
abstract getParent(): ListItem;
|
|
||||||
abstract extend(id: string | number): ListItem;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ListRootItem extends ListItem {
|
|
||||||
constructor() {
|
|
||||||
super(ListLevel.ROOT);
|
|
||||||
}
|
|
||||||
|
|
||||||
getId(): string {
|
|
||||||
return 'root';
|
|
||||||
}
|
|
||||||
|
|
||||||
getFullId(): string {
|
|
||||||
return 'root';
|
|
||||||
}
|
|
||||||
|
|
||||||
getIdAtLevel(level: ListLevel): string | number | undefined {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
getFileId(): string {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
getParent(): ListItem {
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
extend(id: string): ListFileItem {
|
|
||||||
return new ListFileItem(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ListFileItem extends ListItem {
|
|
||||||
fileId: string;
|
|
||||||
|
|
||||||
constructor(fileId: string) {
|
|
||||||
super(ListLevel.FILE);
|
|
||||||
this.fileId = fileId;
|
|
||||||
}
|
|
||||||
|
|
||||||
getId(): string {
|
|
||||||
return this.fileId;
|
|
||||||
}
|
|
||||||
|
|
||||||
getFullId(): string {
|
|
||||||
return this.fileId;
|
|
||||||
}
|
|
||||||
|
|
||||||
getIdAtLevel(level: ListLevel): string | number | undefined {
|
|
||||||
switch (level) {
|
|
||||||
case ListLevel.ROOT:
|
|
||||||
return this.fileId;
|
|
||||||
default:
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getFileId(): string {
|
|
||||||
return this.fileId;
|
|
||||||
}
|
|
||||||
|
|
||||||
getParent(): ListItem {
|
|
||||||
return new ListRootItem();
|
|
||||||
}
|
|
||||||
|
|
||||||
extend(id: number | 'waypoints'): ListTrackItem | ListWaypointsItem {
|
|
||||||
if (id === 'waypoints') {
|
|
||||||
return new ListWaypointsItem(this.fileId);
|
|
||||||
} else {
|
|
||||||
return new ListTrackItem(this.fileId, id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ListTrackItem extends ListItem {
|
|
||||||
fileId: string;
|
|
||||||
trackIndex: number;
|
|
||||||
|
|
||||||
constructor(fileId: string, trackIndex: number) {
|
|
||||||
super(ListLevel.TRACK);
|
|
||||||
this.fileId = fileId;
|
|
||||||
this.trackIndex = trackIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
getId(): number {
|
|
||||||
return this.trackIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
getFullId(): string {
|
|
||||||
return `${this.fileId}-track-${this.trackIndex}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
getIdAtLevel(level: ListLevel): string | number | undefined {
|
|
||||||
switch (level) {
|
|
||||||
case ListLevel.ROOT:
|
|
||||||
return this.fileId;
|
|
||||||
case ListLevel.FILE:
|
|
||||||
return this.trackIndex;
|
|
||||||
default:
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getFileId(): string {
|
|
||||||
return this.fileId;
|
|
||||||
}
|
|
||||||
|
|
||||||
getTrackIndex(): number {
|
|
||||||
return this.trackIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
getParent(): ListItem {
|
|
||||||
return new ListFileItem(this.fileId);
|
|
||||||
}
|
|
||||||
|
|
||||||
extend(id: number): ListTrackSegmentItem {
|
|
||||||
return new ListTrackSegmentItem(this.fileId, this.trackIndex, id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ListTrackSegmentItem extends ListItem {
|
|
||||||
fileId: string;
|
|
||||||
trackIndex: number;
|
|
||||||
segmentIndex: number;
|
|
||||||
|
|
||||||
constructor(fileId: string, trackIndex: number, segmentIndex: number) {
|
|
||||||
super(ListLevel.SEGMENT);
|
|
||||||
this.fileId = fileId;
|
|
||||||
this.trackIndex = trackIndex;
|
|
||||||
this.segmentIndex = segmentIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
getId(): number {
|
|
||||||
return this.segmentIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
getFullId(): string {
|
|
||||||
return `${this.fileId}-track-${this.trackIndex}--${this.segmentIndex}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
getIdAtLevel(level: ListLevel): string | number | undefined {
|
|
||||||
switch (level) {
|
|
||||||
case ListLevel.ROOT:
|
|
||||||
return this.fileId;
|
|
||||||
case ListLevel.FILE:
|
|
||||||
return this.trackIndex;
|
|
||||||
case ListLevel.TRACK:
|
|
||||||
return this.segmentIndex;
|
|
||||||
default:
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getFileId(): string {
|
|
||||||
return this.fileId;
|
|
||||||
}
|
|
||||||
|
|
||||||
getTrackIndex(): number {
|
|
||||||
return this.trackIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
getSegmentIndex(): number {
|
|
||||||
return this.segmentIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
getParent(): ListItem {
|
|
||||||
return new ListTrackItem(this.fileId, this.trackIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
extend(): ListTrackSegmentItem {
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ListWaypointsItem extends ListItem {
|
|
||||||
fileId: string;
|
|
||||||
|
|
||||||
constructor(fileId: string) {
|
|
||||||
super(ListLevel.WAYPOINTS);
|
|
||||||
this.fileId = fileId;
|
|
||||||
}
|
|
||||||
|
|
||||||
getId(): string {
|
|
||||||
return 'waypoints';
|
|
||||||
}
|
|
||||||
|
|
||||||
getFullId(): string {
|
|
||||||
return `${this.fileId}-waypoints`;
|
|
||||||
}
|
|
||||||
|
|
||||||
getIdAtLevel(level: ListLevel): string | number | undefined {
|
|
||||||
switch (level) {
|
|
||||||
case ListLevel.ROOT:
|
|
||||||
return this.fileId;
|
|
||||||
case ListLevel.FILE:
|
|
||||||
return 'waypoints';
|
|
||||||
default:
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getFileId(): string {
|
|
||||||
return this.fileId;
|
|
||||||
}
|
|
||||||
|
|
||||||
getParent(): ListItem {
|
|
||||||
return new ListFileItem(this.fileId);
|
|
||||||
}
|
|
||||||
|
|
||||||
extend(id: number): ListWaypointItem {
|
|
||||||
return new ListWaypointItem(this.fileId, id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ListWaypointItem extends ListItem {
|
|
||||||
fileId: string;
|
|
||||||
waypointIndex: number;
|
|
||||||
|
|
||||||
constructor(fileId: string, waypointIndex: number) {
|
|
||||||
super(ListLevel.WAYPOINT);
|
|
||||||
this.fileId = fileId;
|
|
||||||
this.waypointIndex = waypointIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
getId(): number {
|
|
||||||
return this.waypointIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
getFullId(): string {
|
|
||||||
return `${this.fileId}-waypoint-${this.waypointIndex}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
getIdAtLevel(level: ListLevel): string | number | undefined {
|
|
||||||
switch (level) {
|
|
||||||
case ListLevel.ROOT:
|
|
||||||
return this.fileId;
|
|
||||||
case ListLevel.FILE:
|
|
||||||
return 'waypoints';
|
|
||||||
case ListLevel.WAYPOINTS:
|
|
||||||
return this.waypointIndex;
|
|
||||||
default:
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getFileId(): string {
|
|
||||||
return this.fileId;
|
|
||||||
}
|
|
||||||
|
|
||||||
getWaypointIndex(): number {
|
|
||||||
return this.waypointIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
getParent(): ListItem {
|
|
||||||
return new ListWaypointsItem(this.fileId);
|
|
||||||
}
|
|
||||||
|
|
||||||
extend(): ListWaypointItem {
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sortItems(items: ListItem[], reverse: boolean = false) {
|
|
||||||
items.sort((a, b) => {
|
|
||||||
if (a instanceof ListTrackItem && b instanceof ListTrackItem) {
|
|
||||||
return a.getTrackIndex() - b.getTrackIndex();
|
|
||||||
} else if (a instanceof ListTrackSegmentItem && b instanceof ListTrackSegmentItem) {
|
|
||||||
return a.getSegmentIndex() - b.getSegmentIndex();
|
|
||||||
} else if (a instanceof ListWaypointItem && b instanceof ListWaypointItem) {
|
|
||||||
return a.getWaypointIndex() - b.getWaypointIndex();
|
|
||||||
}
|
|
||||||
return a.level - b.level;
|
|
||||||
});
|
|
||||||
if (reverse) {
|
|
||||||
items.reverse();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function moveItems(
|
|
||||||
fromParent: ListItem,
|
|
||||||
toParent: ListItem,
|
|
||||||
fromItems: ListItem[],
|
|
||||||
toItems: ListItem[],
|
|
||||||
remove: boolean = true
|
|
||||||
) {
|
|
||||||
if (fromItems.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
sortItems(fromItems, false);
|
|
||||||
sortItems(toItems, false);
|
|
||||||
|
|
||||||
let context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[] = [];
|
|
||||||
fromItems.forEach((item) => {
|
|
||||||
let file = getFile(item.getFileId());
|
|
||||||
if (file) {
|
|
||||||
if (item instanceof ListFileItem) {
|
|
||||||
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
|
|
||||||
) {
|
|
||||||
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
|
|
||||||
) {
|
|
||||||
context.push(file.wpt[item.getWaypointIndex()].clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (remove && !(fromParent instanceof ListRootItem)) {
|
|
||||||
sortItems(fromItems, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
let files = [fromParent.getFileId(), toParent.getFileId()];
|
|
||||||
let callbacks = [
|
|
||||||
(file, context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[]) => {
|
|
||||||
fromItems.forEach((item) => {
|
|
||||||
if (item instanceof ListTrackItem) {
|
|
||||||
file.replaceTracks(item.getTrackIndex(), item.getTrackIndex(), []);
|
|
||||||
} else if (item instanceof ListTrackSegmentItem) {
|
|
||||||
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) {
|
|
||||||
file.replaceWaypoints(item.getWaypointIndex(), item.getWaypointIndex(), []);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
(file, context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[]) => {
|
|
||||||
toItems.forEach((item, i) => {
|
|
||||||
if (item instanceof ListTrackItem) {
|
|
||||||
if (context[i] instanceof Track) {
|
|
||||||
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]],
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
} 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
|
|
||||||
) {
|
|
||||||
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],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (fromParent instanceof ListRootItem) {
|
|
||||||
files = [];
|
|
||||||
callbacks = [];
|
|
||||||
} else if (!remove) {
|
|
||||||
files.splice(0, 1);
|
|
||||||
callbacks.splice(0, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
dbUtils.applyEachToFilesAndGlobal(
|
|
||||||
files,
|
|
||||||
callbacks,
|
|
||||||
(files, context: (GPXFile | Track | TrackSegment | Waypoint[] | Waypoint)[]) => {
|
|
||||||
toItems.forEach((item, i) => {
|
|
||||||
if (item instanceof ListFileItem) {
|
|
||||||
if (context[i] instanceof GPXFile) {
|
|
||||||
let newFile = context[i];
|
|
||||||
if (remove) {
|
|
||||||
files.delete(newFile._data.id);
|
|
||||||
}
|
|
||||||
newFile._data.id = item.getFileId();
|
|
||||||
files.set(item.getFileId(), freeze(newFile));
|
|
||||||
} else if (context[i] instanceof Track) {
|
|
||||||
let newFile = newGPXFile();
|
|
||||||
newFile._data.id = item.getFileId();
|
|
||||||
if (context[i].name) {
|
|
||||||
newFile.metadata.name = context[i].name;
|
|
||||||
}
|
|
||||||
newFile.replaceTracks(0, 0, [context[i]]);
|
|
||||||
files.set(item.getFileId(), freeze(newFile));
|
|
||||||
} else if (context[i] instanceof TrackSegment) {
|
|
||||||
let newFile = newGPXFile();
|
|
||||||
newFile._data.id = item.getFileId();
|
|
||||||
newFile.replaceTracks(0, 0, [
|
|
||||||
new Track({
|
|
||||||
trkseg: [context[i]],
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
files.set(item.getFileId(), freeze(newFile));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
context
|
|
||||||
);
|
|
||||||
|
|
||||||
selection.update(($selection) => {
|
|
||||||
$selection.clear();
|
|
||||||
toItems.forEach((item) => {
|
|
||||||
$selection.set(item, true);
|
|
||||||
});
|
|
||||||
return $selection;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -8,11 +8,10 @@
|
|||||||
type GPXTreeElement,
|
type GPXTreeElement,
|
||||||
} from 'gpx';
|
} from 'gpx';
|
||||||
import { CollapsibleTreeNode } from '$lib/components/collapsible-tree/index';
|
import { CollapsibleTreeNode } from '$lib/components/collapsible-tree/index';
|
||||||
import { settings, type GPXFileWithStatistics } from '$lib/db';
|
import { type Readable } from 'svelte/store';
|
||||||
import { get, type Readable } from 'svelte/store';
|
|
||||||
import FileListNodeContent from './FileListNodeContent.svelte';
|
import FileListNodeContent from './FileListNodeContent.svelte';
|
||||||
import FileListNodeLabel from './FileListNodeLabel.svelte';
|
import FileListNodeLabel from './FileListNodeLabel.svelte';
|
||||||
import { afterUpdate, getContext } from 'svelte';
|
import { getContext } from 'svelte';
|
||||||
import {
|
import {
|
||||||
ListFileItem,
|
ListFileItem,
|
||||||
ListTrackSegmentItem,
|
ListTrackSegmentItem,
|
||||||
@@ -20,48 +19,50 @@
|
|||||||
ListWaypointsItem,
|
ListWaypointsItem,
|
||||||
type ListItem,
|
type ListItem,
|
||||||
type ListTrackItem,
|
type ListTrackItem,
|
||||||
} from './FileList';
|
} from './file-list';
|
||||||
import { _ } from 'svelte-i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import { selection } from './Selection';
|
import { settings } from '$lib/logic/settings';
|
||||||
|
import type { GPXFileWithStatistics } from '$lib/logic/statistics-tree';
|
||||||
|
import { selection } from '$lib/logic/selection';
|
||||||
|
|
||||||
export let node:
|
let {
|
||||||
| Map<string, Readable<GPXFileWithStatistics | undefined>>
|
node,
|
||||||
| GPXTreeElement<AnyGPXTreeElement>
|
item,
|
||||||
| Waypoint[]
|
}: {
|
||||||
| Waypoint;
|
node:
|
||||||
export let item: ListItem;
|
| Map<string, Readable<GPXFileWithStatistics | undefined>>
|
||||||
|
| GPXTreeElement<AnyGPXTreeElement>
|
||||||
|
| Waypoint[]
|
||||||
|
| Waypoint;
|
||||||
|
item: ListItem;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
let recursive = getContext<boolean>('recursive');
|
let recursive = getContext<boolean>('recursive');
|
||||||
|
|
||||||
let collapsible: CollapsibleTreeNode;
|
let collapsible: CollapsibleTreeNode | undefined = $state();
|
||||||
|
|
||||||
$: label =
|
let label = $derived(
|
||||||
node instanceof GPXFile && item instanceof ListFileItem
|
node instanceof GPXFile && item instanceof ListFileItem
|
||||||
? node.metadata.name
|
? node.metadata.name
|
||||||
: node instanceof Track
|
: node instanceof Track
|
||||||
? (node.name ?? `${$_('gpx.track')} ${(item as ListTrackItem).trackIndex + 1}`)
|
? (node.name ?? `${i18n._('gpx.track')} ${(item as ListTrackItem).trackIndex + 1}`)
|
||||||
: node instanceof TrackSegment
|
: node instanceof TrackSegment
|
||||||
? `${$_('gpx.segment')} ${(item as ListTrackSegmentItem).segmentIndex + 1}`
|
? `${i18n._('gpx.segment')} ${(item as ListTrackSegmentItem).segmentIndex + 1}`
|
||||||
: node instanceof Waypoint
|
: node instanceof Waypoint
|
||||||
? (node.name ??
|
? (node.name ??
|
||||||
`${$_('gpx.waypoint')} ${(item as ListWaypointItem).waypointIndex + 1}`)
|
`${i18n._('gpx.waypoint')} ${(item as ListWaypointItem).waypointIndex + 1}`)
|
||||||
: node instanceof GPXFile && item instanceof ListWaypointsItem
|
: node instanceof GPXFile && item instanceof ListWaypointsItem
|
||||||
? $_('gpx.waypoints')
|
? i18n._('gpx.waypoints')
|
||||||
: '';
|
: ''
|
||||||
|
);
|
||||||
|
|
||||||
const { treeFileView } = settings;
|
const { treeFileView } = settings;
|
||||||
|
|
||||||
function openIfSelectedChild() {
|
$effect(() => {
|
||||||
if (collapsible && get(treeFileView) && $selection.hasAnyChildren(item, false)) {
|
if (collapsible && $treeFileView && $selection.hasAnyChildren(item, false)) {
|
||||||
collapsible.openNode();
|
collapsible.openNode();
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
if ($selection) {
|
|
||||||
openIfSelectedChild();
|
|
||||||
}
|
|
||||||
|
|
||||||
afterUpdate(openIfSelectedChild);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if node instanceof Map}
|
{#if node instanceof Map}
|
||||||
@@ -72,12 +73,16 @@
|
|||||||
<FileListNodeLabel {node} {item} {label} />
|
<FileListNodeLabel {node} {item} {label} />
|
||||||
{:else if recursive}
|
{:else if recursive}
|
||||||
<CollapsibleTreeNode id={item.getId()} bind:this={collapsible}>
|
<CollapsibleTreeNode id={item.getId()} bind:this={collapsible}>
|
||||||
<FileListNodeLabel {node} {item} {label} slot="trigger" />
|
{#snippet trigger()}
|
||||||
<div slot="content" class="ml-2">
|
<FileListNodeLabel {node} {item} {label} />
|
||||||
{#key node}
|
{/snippet}
|
||||||
<FileListNodeContent {node} {item} />
|
{#snippet content()}
|
||||||
{/key}
|
<div class="ml-4">
|
||||||
</div>
|
{#key node}
|
||||||
|
<FileListNodeContent {node} {item} />
|
||||||
|
{/key}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
</CollapsibleTreeNode>
|
</CollapsibleTreeNode>
|
||||||
{:else}
|
{:else}
|
||||||
<FileListNodeLabel {node} {item} {label} />
|
<FileListNodeLabel {node} {item} {label} />
|
||||||
|
|||||||
@@ -1,39 +1,29 @@
|
|||||||
<script lang="ts" context="module">
|
|
||||||
let dragging: Writable<ListLevel | null> = writable(null);
|
|
||||||
|
|
||||||
let updating = false;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { GPXFile, Track, Waypoint, type AnyGPXTreeElement, type GPXTreeElement } from 'gpx';
|
import { GPXFile, Track, Waypoint, type AnyGPXTreeElement, type GPXTreeElement } from 'gpx';
|
||||||
import { afterUpdate, getContext, onDestroy, onMount } from 'svelte';
|
import { getContext, onDestroy, onMount } from 'svelte';
|
||||||
import Sortable from 'sortablejs/Sortable';
|
import { type Readable } from 'svelte/store';
|
||||||
import { getFileIds, settings, type GPXFileWithStatistics } from '$lib/db';
|
|
||||||
import { get, writable, type Readable, type Writable } from 'svelte/store';
|
|
||||||
import FileListNodeStore from './FileListNodeStore.svelte';
|
import FileListNodeStore from './FileListNodeStore.svelte';
|
||||||
import FileListNode from './FileListNode.svelte';
|
import FileListNode from './FileListNode.svelte';
|
||||||
import {
|
import FileListNodeContent from './FileListNodeContent.svelte';
|
||||||
ListFileItem,
|
import { ListFileItem, ListLevel, ListWaypointsItem, type ListItem } from './file-list';
|
||||||
ListLevel,
|
import type { GPXFileWithStatistics } from '$lib/logic/statistics-tree';
|
||||||
ListRootItem,
|
import { allowedMoves, dragging, SortableFileList } from './sortable-file-list';
|
||||||
ListWaypointsItem,
|
|
||||||
allowedMoves,
|
|
||||||
moveItems,
|
|
||||||
type ListItem,
|
|
||||||
} from './FileList';
|
|
||||||
import { selection } from './Selection';
|
|
||||||
import { isMac } from '$lib/utils';
|
|
||||||
import { _ } from 'svelte-i18n';
|
|
||||||
|
|
||||||
export let node:
|
let {
|
||||||
| Map<string, Readable<GPXFileWithStatistics | undefined>>
|
node,
|
||||||
| GPXTreeElement<AnyGPXTreeElement>
|
item,
|
||||||
| Waypoint;
|
waypointRoot = false,
|
||||||
export let item: ListItem;
|
}: {
|
||||||
export let waypointRoot: boolean = false;
|
node:
|
||||||
|
| Map<string, Readable<GPXFileWithStatistics | undefined>>
|
||||||
|
| GPXTreeElement<AnyGPXTreeElement>
|
||||||
|
| Waypoint[]
|
||||||
|
| Waypoint;
|
||||||
|
item: ListItem;
|
||||||
|
waypointRoot?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
let container: HTMLElement;
|
let container: HTMLElement;
|
||||||
let elements: { [id: string]: HTMLElement } = {};
|
|
||||||
let sortableLevel: ListLevel =
|
let sortableLevel: ListLevel =
|
||||||
node instanceof Map
|
node instanceof Map
|
||||||
? ListLevel.FILE
|
? ListLevel.FILE
|
||||||
@@ -46,255 +36,32 @@
|
|||||||
: node instanceof Track
|
: node instanceof Track
|
||||||
? ListLevel.SEGMENT
|
? ListLevel.SEGMENT
|
||||||
: ListLevel.WAYPOINT;
|
: ListLevel.WAYPOINT;
|
||||||
let sortable: Sortable;
|
|
||||||
let orientation = getContext<'vertical' | 'horizontal'>('orientation');
|
let orientation = getContext<'vertical' | 'horizontal'>('orientation');
|
||||||
|
|
||||||
let destroyed = false;
|
let canDrop = $derived($dragging !== null && allowedMoves[$dragging].includes(sortableLevel));
|
||||||
let lastUpdateStart = 0;
|
|
||||||
function updateToSelection(e) {
|
|
||||||
if (destroyed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
lastUpdateStart = Date.now();
|
let sortable: SortableFileList;
|
||||||
setTimeout(() => {
|
|
||||||
if (Date.now() - lastUpdateStart >= 40) {
|
|
||||||
if (updating) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
updating = true;
|
|
||||||
// Sortable updates selection
|
|
||||||
let changed = getChangedIds();
|
|
||||||
if (changed.length > 0) {
|
|
||||||
selection.update(($selection) => {
|
|
||||||
$selection.clear();
|
|
||||||
Object.entries(elements).forEach(([id, element]) => {
|
|
||||||
$selection.set(
|
|
||||||
item.extend(getRealId(id)),
|
|
||||||
element.classList.contains('sortable-selected')
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (
|
|
||||||
e.originalEvent &&
|
|
||||||
!(
|
|
||||||
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();
|
|
||||||
$selection.set(item.extend(getRealId(changed[0])), true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $selection;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
updating = false;
|
|
||||||
}
|
|
||||||
}, 50);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateFromSelection() {
|
|
||||||
if (destroyed || updating) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
updating = true;
|
|
||||||
// Selection updates sortable
|
|
||||||
let changed = getChangedIds();
|
|
||||||
for (let id of changed) {
|
|
||||||
let element = elements[id];
|
|
||||||
if (element) {
|
|
||||||
if ($selection.has(item.extend(id))) {
|
|
||||||
Sortable.utils.select(element);
|
|
||||||
element.scrollIntoView({
|
|
||||||
behavior: 'smooth',
|
|
||||||
block: 'nearest',
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
Sortable.utils.deselect(element);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
updating = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$: if ($selection) {
|
|
||||||
updateFromSelection();
|
|
||||||
}
|
|
||||||
|
|
||||||
const { fileOrder } = settings;
|
|
||||||
function syncFileOrder() {
|
|
||||||
if (!sortable || sortableLevel !== ListLevel.FILE) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentOrder = sortable.toArray();
|
|
||||||
if (currentOrder.length !== $fileOrder.length) {
|
|
||||||
sortable.sort($fileOrder);
|
|
||||||
} else {
|
|
||||||
for (let i = 0; i < currentOrder.length; i++) {
|
|
||||||
if (currentOrder[i] !== $fileOrder[i]) {
|
|
||||||
sortable.sort($fileOrder);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$: if ($fileOrder) {
|
|
||||||
syncFileOrder();
|
|
||||||
}
|
|
||||||
|
|
||||||
function createSortable() {
|
|
||||||
sortable = Sortable.create(container, {
|
|
||||||
group: {
|
|
||||||
name: sortableLevel,
|
|
||||||
pull: allowedMoves[sortableLevel],
|
|
||||||
put: true,
|
|
||||||
},
|
|
||||||
direction: orientation,
|
|
||||||
forceAutoScrollFallback: true,
|
|
||||||
multiDrag: true,
|
|
||||||
multiDragKey: isMac() ? 'Meta' : 'Ctrl',
|
|
||||||
avoidImplicitDeselect: true,
|
|
||||||
onSelect: updateToSelection,
|
|
||||||
onDeselect: updateToSelection,
|
|
||||||
onStart: () => {
|
|
||||||
dragging.set(sortableLevel);
|
|
||||||
},
|
|
||||||
onEnd: () => {
|
|
||||||
dragging.set(null);
|
|
||||||
},
|
|
||||||
onSort: (e) => {
|
|
||||||
if (sortableLevel === ListLevel.FILE) {
|
|
||||||
let newFileOrder = sortable.toArray();
|
|
||||||
if (newFileOrder.length !== get(fileOrder).length) {
|
|
||||||
fileOrder.set(newFileOrder);
|
|
||||||
} else {
|
|
||||||
for (let i = 0; i < newFileOrder.length; i++) {
|
|
||||||
if (newFileOrder[i] !== get(fileOrder)[i]) {
|
|
||||||
fileOrder.set(newFileOrder);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let fromItem = Sortable.get(e.from)._item;
|
|
||||||
let toItem = Sortable.get(e.to)._item;
|
|
||||||
|
|
||||||
if (item === toItem && !(fromItem instanceof ListRootItem)) {
|
|
||||||
// Event is triggered on source and destination list, only handle it once
|
|
||||||
let fromItems = [];
|
|
||||||
let toItems = [];
|
|
||||||
|
|
||||||
if (Sortable.get(e.from)._waypointRoot) {
|
|
||||||
fromItems = [fromItem.extend('waypoints')];
|
|
||||||
} else {
|
|
||||||
let oldIndices: number[] =
|
|
||||||
e.oldIndicies.length > 0
|
|
||||||
? e.oldIndicies.map((i) => i.index)
|
|
||||||
: [e.oldIndex];
|
|
||||||
oldIndices = oldIndices.filter((i) => i >= 0);
|
|
||||||
oldIndices.sort((a, b) => a - b);
|
|
||||||
|
|
||||||
fromItems = oldIndices.map((i) => fromItem.extend(i));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Sortable.get(e.from)._waypointRoot && Sortable.get(e.to)._waypointRoot) {
|
|
||||||
toItems = [toItem.extend('waypoints')];
|
|
||||||
} else {
|
|
||||||
if (Sortable.get(e.to)._waypointRoot) {
|
|
||||||
toItem = toItem.extend('waypoints');
|
|
||||||
}
|
|
||||||
|
|
||||||
let newIndices: number[] =
|
|
||||||
e.newIndicies.length > 0
|
|
||||||
? e.newIndicies.map((i) => i.index)
|
|
||||||
: [e.newIndex];
|
|
||||||
newIndices = newIndices.filter((i) => i >= 0);
|
|
||||||
newIndices.sort((a, b) => a - b);
|
|
||||||
|
|
||||||
if (toItem instanceof ListRootItem) {
|
|
||||||
let newFileIds = getFileIds(newIndices.length);
|
|
||||||
toItems = newIndices.map((i, index) => {
|
|
||||||
$fileOrder.splice(i, 0, newFileIds[index]);
|
|
||||||
return item.extend(newFileIds[index]);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
toItems = newIndices.map((i) => toItem.extend(i));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
moveItems(fromItem, toItem, fromItems, toItems);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Object.defineProperty(sortable, '_item', {
|
|
||||||
value: item,
|
|
||||||
writable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.defineProperty(sortable, '_waypointRoot', {
|
|
||||||
value: waypointRoot,
|
|
||||||
writable: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
createSortable();
|
sortable = new SortableFileList(
|
||||||
destroyed = false;
|
container,
|
||||||
|
node,
|
||||||
|
item,
|
||||||
|
waypointRoot,
|
||||||
|
sortableLevel,
|
||||||
|
orientation
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterUpdate(() => {
|
$effect(() => {
|
||||||
elements = {};
|
if (sortable && node) {
|
||||||
container.childNodes.forEach((element) => {
|
sortable.updateElements();
|
||||||
if (element instanceof HTMLElement) {
|
}
|
||||||
let attr = element.getAttribute('data-id');
|
|
||||||
if (attr) {
|
|
||||||
if (node instanceof Map && !node.has(attr)) {
|
|
||||||
element.remove();
|
|
||||||
} else {
|
|
||||||
elements[attr] = element;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
syncFileOrder();
|
|
||||||
updateFromSelection();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
destroyed = true;
|
sortable.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
function getChangedIds() {
|
|
||||||
let changed: (string | number)[] = [];
|
|
||||||
Object.entries(elements).forEach(([id, element]) => {
|
|
||||||
let realId = getRealId(id);
|
|
||||||
let realItem = item.extend(realId);
|
|
||||||
let inSelection = get(selection).has(realItem);
|
|
||||||
let isSelected = element.classList.contains('sortable-selected');
|
|
||||||
if (inSelection !== isSelected) {
|
|
||||||
changed.push(realId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return changed;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRealId(id: string | number) {
|
|
||||||
return sortableLevel === ListLevel.FILE || sortableLevel === ListLevel.WAYPOINTS
|
|
||||||
? id
|
|
||||||
: parseInt(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
$: canDrop = $dragging !== null && allowedMoves[$dragging].includes(sortableLevel);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -340,11 +107,13 @@
|
|||||||
|
|
||||||
{#if node instanceof GPXFile && item instanceof ListFileItem}
|
{#if node instanceof GPXFile && item instanceof ListFileItem}
|
||||||
{#if !waypointRoot}
|
{#if !waypointRoot}
|
||||||
<svelte:self {node} {item} waypointRoot={true} />
|
<FileListNodeContent {node} {item} waypointRoot={true} />
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style lang="postcss">
|
<style lang="postcss">
|
||||||
|
@reference "../../../app.css";
|
||||||
|
|
||||||
.sortable > div {
|
.sortable > div {
|
||||||
@apply rounded-md;
|
@apply rounded-md;
|
||||||
@apply h-fit;
|
@apply h-fit;
|
||||||
@@ -352,20 +121,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.vertical :global(button) {
|
.vertical :global(button) {
|
||||||
@apply hover:bg-muted;
|
@apply hover:bg-[var(--selection)];
|
||||||
}
|
|
||||||
|
|
||||||
.vertical :global(.sortable-selected button) {
|
|
||||||
@apply hover:bg-accent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.vertical :global(.sortable-selected) {
|
.vertical :global(.sortable-selected) {
|
||||||
@apply bg-accent;
|
@apply bg-[var(--selection)];
|
||||||
}
|
}
|
||||||
|
|
||||||
.horizontal :global(button) {
|
.horizontal :global(button) {
|
||||||
@apply bg-accent;
|
@apply bg-[var(--selection)];
|
||||||
@apply hover:bg-muted;
|
@apply hover:bg-background;
|
||||||
}
|
}
|
||||||
|
|
||||||
.horizontal :global(.sortable-selected button) {
|
.horizontal :global(.sortable-selected button) {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import * as ContextMenu from '$lib/components/ui/context-menu';
|
import * as ContextMenu from '$lib/components/ui/context-menu';
|
||||||
import Shortcut from '$lib/components/Shortcut.svelte';
|
import Shortcut from '$lib/components/Shortcut.svelte';
|
||||||
import { dbUtils, getFile } from '$lib/db';
|
|
||||||
import {
|
import {
|
||||||
Copy,
|
Copy,
|
||||||
Info,
|
Info,
|
||||||
@@ -18,107 +17,107 @@
|
|||||||
Maximize,
|
Maximize,
|
||||||
Scissors,
|
Scissors,
|
||||||
FileStack,
|
FileStack,
|
||||||
FileX,
|
} from '@lucide/svelte';
|
||||||
} from 'lucide-svelte';
|
|
||||||
import {
|
import {
|
||||||
ListFileItem,
|
ListFileItem,
|
||||||
ListLevel,
|
ListLevel,
|
||||||
ListTrackItem,
|
ListTrackItem,
|
||||||
ListWaypointItem,
|
ListWaypointItem,
|
||||||
allowedPastes,
|
|
||||||
type ListItem,
|
type ListItem,
|
||||||
} from './FileList';
|
} from './file-list';
|
||||||
import {
|
|
||||||
copied,
|
|
||||||
copySelection,
|
|
||||||
cut,
|
|
||||||
cutSelection,
|
|
||||||
pasteSelection,
|
|
||||||
selectAll,
|
|
||||||
selectItem,
|
|
||||||
selection,
|
|
||||||
} from './Selection';
|
|
||||||
import { getContext } from 'svelte';
|
import { getContext } from 'svelte';
|
||||||
import { get } from 'svelte/store';
|
|
||||||
import {
|
|
||||||
allHidden,
|
|
||||||
editMetadata,
|
|
||||||
editStyle,
|
|
||||||
embedding,
|
|
||||||
centerMapOnSelection,
|
|
||||||
gpxLayers,
|
|
||||||
map,
|
|
||||||
} from '$lib/stores';
|
|
||||||
import { GPXTreeElement, Track, type AnyGPXTreeElement, Waypoint, GPXFile } from 'gpx';
|
import { GPXTreeElement, Track, type AnyGPXTreeElement, Waypoint, GPXFile } from 'gpx';
|
||||||
import { _ } from 'svelte-i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import MetadataDialog from './MetadataDialog.svelte';
|
import MetadataDialog from '$lib/components/file-list/metadata/MetadataDialog.svelte';
|
||||||
import StyleDialog from './StyleDialog.svelte';
|
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
|
||||||
import { waypointPopup } from '$lib/components/gpx-layer/GPXLayerPopup';
|
import StyleDialog from '$lib/components/file-list/style/StyleDialog.svelte';
|
||||||
|
import { editStyle } from '$lib/components/file-list/style/utils.svelte';
|
||||||
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
||||||
|
import { selection, copied, cut } from '$lib/logic/selection';
|
||||||
|
import { map } from '$lib/components/map/map';
|
||||||
|
import { fileActions, pasteSelection } from '$lib/logic/file-actions';
|
||||||
|
import { allHidden } from '$lib/logic/hidden';
|
||||||
|
import { boundsManager } from '$lib/logic/bounds';
|
||||||
|
import { gpxLayers } from '$lib/components/map/gpx-layer/gpx-layers';
|
||||||
|
import { fileStateCollection } from '$lib/logic/file-state';
|
||||||
|
import { waypointPopup } from '$lib/components/map/gpx-layer/gpx-layer-popup';
|
||||||
|
import { allowedPastes } from './sortable-file-list';
|
||||||
|
|
||||||
export let node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint;
|
let {
|
||||||
export let item: ListItem;
|
node,
|
||||||
export let label: string | undefined;
|
item,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint;
|
||||||
|
item: ListItem;
|
||||||
|
label: string | undefined;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
let orientation = getContext<'vertical' | 'horizontal'>('orientation');
|
let orientation = getContext<'vertical' | 'horizontal'>('orientation');
|
||||||
|
let embedding = getContext<boolean>('embedding');
|
||||||
|
|
||||||
$: singleSelection = $selection.size === 1;
|
let singleSelection = $derived($selection.size === 1);
|
||||||
|
|
||||||
let nodeColors: string[] = [];
|
let nodeColors: string[] = $state([]);
|
||||||
|
|
||||||
$: if (node && $map) {
|
$effect.pre(() => {
|
||||||
nodeColors = [];
|
let colors: string[] = [];
|
||||||
|
if (node && $map) {
|
||||||
|
if (node instanceof GPXFile) {
|
||||||
|
let defaultColor = undefined;
|
||||||
|
|
||||||
if (node instanceof GPXFile) {
|
let layer = gpxLayers.getLayer(item.getFileId());
|
||||||
let defaultColor = undefined;
|
|
||||||
|
|
||||||
let layer = gpxLayers.get(item.getFileId());
|
|
||||||
if (layer) {
|
|
||||||
defaultColor = layer.layerColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
let style = node.getStyle(defaultColor);
|
|
||||||
style.color.forEach((c) => {
|
|
||||||
if (!nodeColors.includes(c)) {
|
|
||||||
nodeColors.push(c);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else if (node instanceof Track) {
|
|
||||||
let style = node.getStyle();
|
|
||||||
if (style) {
|
|
||||||
if (style['gpx_style:color'] && !nodeColors.includes(style['gpx_style:color'])) {
|
|
||||||
nodeColors.push(style['gpx_style:color']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (nodeColors.length === 0) {
|
|
||||||
let layer = gpxLayers.get(item.getFileId());
|
|
||||||
if (layer) {
|
if (layer) {
|
||||||
nodeColors.push(layer.layerColor);
|
defaultColor = layer.layerColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
let style = node.getStyle(defaultColor);
|
||||||
|
style.color.forEach((c) => {
|
||||||
|
if (!colors.includes(c)) {
|
||||||
|
colors.push(c);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (node instanceof Track) {
|
||||||
|
let style = node.getStyle();
|
||||||
|
if (style) {
|
||||||
|
if (style['gpx_style:color'] && !colors.includes(style['gpx_style:color'])) {
|
||||||
|
colors.push(style['gpx_style:color']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (colors.length === 0) {
|
||||||
|
let layer = gpxLayers.getLayer(item.getFileId());
|
||||||
|
if (layer) {
|
||||||
|
colors.push(layer.layerColor);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
nodeColors = colors;
|
||||||
|
});
|
||||||
|
|
||||||
$: symbolKey = node instanceof Waypoint ? getSymbolKey(node.sym) : undefined;
|
let symbolKey = $derived(node instanceof Waypoint ? getSymbolKey(node.sym) : undefined);
|
||||||
|
|
||||||
let openEditMetadata: boolean = false;
|
let openEditMetadata: boolean = $derived(
|
||||||
let openEditStyle: boolean = false;
|
editMetadata.current && singleSelection && $selection.has(item)
|
||||||
|
);
|
||||||
|
let openEditStyle: boolean = $derived(
|
||||||
|
editStyle.current &&
|
||||||
|
$selection.has(item) &&
|
||||||
|
$selection.getSelected().findIndex((i) => i.getFullId() === item.getFullId()) === 0
|
||||||
|
);
|
||||||
|
|
||||||
$: openEditMetadata = $editMetadata && singleSelection && $selection.has(item);
|
let hidden = $derived(
|
||||||
$: openEditStyle =
|
item.level === ListLevel.WAYPOINTS ? node._data.hiddenWpt : node._data.hidden
|
||||||
$editStyle &&
|
);
|
||||||
$selection.has(item) &&
|
|
||||||
$selection.getSelected().findIndex((i) => i.getFullId() === item.getFullId()) === 0;
|
|
||||||
$: hidden = item.level === ListLevel.WAYPOINTS ? node._data.hiddenWpt : node._data.hidden;
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<ContextMenu.Root
|
<ContextMenu.Root
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
if (!get(selection).has(item)) {
|
if (!$selection.has(item)) {
|
||||||
selectItem(item);
|
selection.selectItem(item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -126,7 +125,7 @@
|
|||||||
<ContextMenu.Trigger class="grow truncate">
|
<ContextMenu.Trigger class="grow truncate">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="relative w-full p-0 px-1 border-none overflow-hidden focus-visible:ring-0 focus-visible:ring-offset-0 {orientation ===
|
class="relative w-full p-0 overflow-hidden focus-visible:ring-0 focus-visible:ring-offset-0 {orientation ===
|
||||||
'vertical'
|
'vertical'
|
||||||
? 'h-fit'
|
? 'h-fit'
|
||||||
: 'h-9 px-1.5 shadow-md'} pointer-events-auto"
|
: 'h-9 px-1.5 shadow-md'} pointer-events-auto"
|
||||||
@@ -148,7 +147,7 @@
|
|||||||
`${c} ${Math.floor((100 * i) / nodeColors.length)}% ${Math.floor((100 * (i + 1)) / nodeColors.length)}%`
|
`${c} ${Math.floor((100 * i) / nodeColors.length)}% ${Math.floor((100 * (i + 1)) / nodeColors.length)}%`
|
||||||
)
|
)
|
||||||
.join(',')})"
|
.join(',')})"
|
||||||
/>
|
></div>
|
||||||
{/if}
|
{/if}
|
||||||
<span
|
<span
|
||||||
class="w-full text-left truncate py-1 flex flex-row items-center {hidden
|
class="w-full text-left truncate py-1 flex flex-row items-center {hidden
|
||||||
@@ -156,8 +155,8 @@
|
|||||||
: ''} {$cut && $copied?.some((i) => i.getFullId() === item.getFullId())
|
: ''} {$cut && $copied?.some((i) => i.getFullId() === item.getFullId())
|
||||||
? 'text-muted-foreground'
|
? 'text-muted-foreground'
|
||||||
: ''}"
|
: ''}"
|
||||||
on:contextmenu={(e) => {
|
oncontextmenu={(e) => {
|
||||||
if ($embedding) {
|
if (embedding) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
return;
|
return;
|
||||||
@@ -170,13 +169,13 @@
|
|||||||
$selection = $selection;
|
$selection = $selection;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
on:mouseenter={() => {
|
onmouseenter={() => {
|
||||||
if (item instanceof ListWaypointItem) {
|
if (item instanceof ListWaypointItem) {
|
||||||
let layer = gpxLayers.get(item.getFileId());
|
let layer = gpxLayers.getLayer(item.getFileId());
|
||||||
let file = getFile(item.getFileId());
|
let file = fileStateCollection.getFile(item.getFileId());
|
||||||
if (layer && file) {
|
if (layer && file) {
|
||||||
let waypoint = file.wpt[item.getWaypointIndex()];
|
let waypoint = file.wpt[item.getWaypointIndex()];
|
||||||
if (waypoint) {
|
if (waypoint && !waypoint._data.hidden) {
|
||||||
waypointPopup?.setItem({
|
waypointPopup?.setItem({
|
||||||
item: waypoint,
|
item: waypoint,
|
||||||
fileId: item.getFileId(),
|
fileId: item.getFileId(),
|
||||||
@@ -185,9 +184,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
on:mouseleave={() => {
|
onmouseleave={() => {
|
||||||
if (item instanceof ListWaypointItem) {
|
if (item instanceof ListWaypointItem) {
|
||||||
let layer = gpxLayers.get(item.getFileId());
|
let layer = gpxLayers.getLayer(item.getFileId());
|
||||||
if (layer) {
|
if (layer) {
|
||||||
waypointPopup?.setItem(null);
|
waypointPopup?.setItem(null);
|
||||||
}
|
}
|
||||||
@@ -195,16 +194,13 @@
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{#if item.level === ListLevel.SEGMENT}
|
{#if item.level === ListLevel.SEGMENT}
|
||||||
<Waypoints size="16" class="mr-1 shrink-0" />
|
<Waypoints size="16" class="mx-1 shrink-0" />
|
||||||
{:else if item.level === ListLevel.WAYPOINT}
|
{:else if item.level === ListLevel.WAYPOINT}
|
||||||
{#if symbolKey && symbols[symbolKey].icon}
|
{#if symbolKey && symbols[symbolKey].icon}
|
||||||
<svelte:component
|
{@const SymbolIcon = symbols[symbolKey].icon}
|
||||||
this={symbols[symbolKey].icon}
|
<SymbolIcon size="16" class="mx-1 shrink-0" />
|
||||||
size="16"
|
|
||||||
class="mr-1 shrink-0"
|
|
||||||
/>
|
|
||||||
{:else}
|
{:else}
|
||||||
<MapPin size="16" class="mr-1 shrink-0" />
|
<MapPin size="16" class="mx-1 shrink-0" />
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
<span
|
<span
|
||||||
@@ -216,13 +212,10 @@
|
|||||||
</span>
|
</span>
|
||||||
{#if hidden}
|
{#if hidden}
|
||||||
<EyeOff
|
<EyeOff
|
||||||
size="12"
|
size="10"
|
||||||
class="shrink-0 mt-1 ml-1 {orientation === 'vertical'
|
class="shrink-0 size-3.5 ml-1 {orientation === 'vertical'
|
||||||
? 'mr-2'
|
|
||||||
: ''} {item.level === ListLevel.SEGMENT ||
|
|
||||||
item.level === ListLevel.WAYPOINT
|
|
||||||
? 'mr-3'
|
? 'mr-3'
|
||||||
: ''}"
|
: 'mt-0.5'}"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
@@ -230,31 +223,34 @@
|
|||||||
</ContextMenu.Trigger>
|
</ContextMenu.Trigger>
|
||||||
<ContextMenu.Content>
|
<ContextMenu.Content>
|
||||||
{#if item instanceof ListFileItem || item instanceof ListTrackItem}
|
{#if item instanceof ListFileItem || item instanceof ListTrackItem}
|
||||||
<ContextMenu.Item disabled={!singleSelection} on:click={() => ($editMetadata = true)}>
|
<ContextMenu.Item
|
||||||
<Info size="16" class="mr-1" />
|
disabled={!singleSelection}
|
||||||
{$_('menu.metadata.button')}
|
onclick={() => (editMetadata.current = true)}
|
||||||
|
>
|
||||||
|
<Info size="16" />
|
||||||
|
{i18n._('menu.metadata.button')}
|
||||||
<Shortcut key="I" ctrl={true} />
|
<Shortcut key="I" ctrl={true} />
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
<ContextMenu.Item on:click={() => ($editStyle = true)}>
|
<ContextMenu.Item onclick={() => (editStyle.current = true)}>
|
||||||
<PaintBucket size="16" class="mr-1" />
|
<PaintBucket size="16" />
|
||||||
{$_('menu.style.button')}
|
{i18n._('menu.style.button')}
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
{/if}
|
{/if}
|
||||||
<ContextMenu.Item
|
<ContextMenu.Item
|
||||||
on:click={() => {
|
onclick={() => {
|
||||||
if ($allHidden) {
|
if ($allHidden) {
|
||||||
dbUtils.setHiddenToSelection(false);
|
fileActions.setHiddenToSelection(false);
|
||||||
} else {
|
} else {
|
||||||
dbUtils.setHiddenToSelection(true);
|
fileActions.setHiddenToSelection(true);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{#if $allHidden}
|
{#if $allHidden}
|
||||||
<Eye size="16" class="mr-1" />
|
<Eye size="16" />
|
||||||
{$_('menu.unhide')}
|
{i18n._('menu.unhide')}
|
||||||
{:else}
|
{:else}
|
||||||
<EyeOff size="16" class="mr-1" />
|
<EyeOff size="16" />
|
||||||
{$_('menu.hide')}
|
{i18n._('menu.hide')}
|
||||||
{/if}
|
{/if}
|
||||||
<Shortcut key="H" ctrl={true} />
|
<Shortcut key="H" ctrl={true} />
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
@@ -263,72 +259,68 @@
|
|||||||
{#if item instanceof ListFileItem}
|
{#if item instanceof ListFileItem}
|
||||||
<ContextMenu.Item
|
<ContextMenu.Item
|
||||||
disabled={!singleSelection}
|
disabled={!singleSelection}
|
||||||
on:click={() => dbUtils.addNewTrack(item.getFileId())}
|
onclick={() => fileActions.addNewTrack(item.getFileId())}
|
||||||
>
|
>
|
||||||
<Plus size="16" class="mr-1" />
|
<Plus size="16" />
|
||||||
{$_('menu.new_track')}
|
{i18n._('menu.new_track')}
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
<ContextMenu.Separator />
|
<ContextMenu.Separator />
|
||||||
{:else if item instanceof ListTrackItem}
|
{:else if item instanceof ListTrackItem}
|
||||||
<ContextMenu.Item
|
<ContextMenu.Item
|
||||||
disabled={!singleSelection}
|
disabled={!singleSelection}
|
||||||
on:click={() => dbUtils.addNewSegment(item.getFileId(), item.getTrackIndex())}
|
onclick={() =>
|
||||||
|
fileActions.addNewSegment(item.getFileId(), item.getTrackIndex())}
|
||||||
>
|
>
|
||||||
<Plus size="16" class="mr-1" />
|
<Plus size="16" />
|
||||||
{$_('menu.new_segment')}
|
{i18n._('menu.new_segment')}
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
<ContextMenu.Separator />
|
<ContextMenu.Separator />
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{#if item.level !== ListLevel.WAYPOINTS}
|
{#if item.level !== ListLevel.WAYPOINTS}
|
||||||
<ContextMenu.Item on:click={selectAll}>
|
<ContextMenu.Item onclick={() => selection.selectAll()}>
|
||||||
<FileStack size="16" class="mr-1" />
|
<FileStack size="16" />
|
||||||
{$_('menu.select_all')}
|
{i18n._('menu.select_all')}
|
||||||
<Shortcut key="A" ctrl={true} />
|
<Shortcut key="A" ctrl={true} />
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
{/if}
|
{/if}
|
||||||
<ContextMenu.Item on:click={centerMapOnSelection}>
|
<ContextMenu.Item onclick={() => boundsManager.centerMapOnSelection()}>
|
||||||
<Maximize size="16" class="mr-1" />
|
<Maximize size="16" />
|
||||||
{$_('menu.center')}
|
{i18n._('menu.center')}
|
||||||
<Shortcut key="⏎" ctrl={true} />
|
<Shortcut key="⏎" ctrl={true} />
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
<ContextMenu.Separator />
|
<ContextMenu.Separator />
|
||||||
<ContextMenu.Item on:click={dbUtils.duplicateSelection}>
|
<ContextMenu.Item onclick={fileActions.duplicateSelection}>
|
||||||
<Copy size="16" class="mr-1" />
|
<Copy size="16" />
|
||||||
{$_('menu.duplicate')}
|
{i18n._('menu.duplicate')}
|
||||||
<Shortcut key="D" ctrl={true} /></ContextMenu.Item
|
<Shortcut key="D" ctrl={true} />
|
||||||
>
|
</ContextMenu.Item>
|
||||||
{#if orientation === 'vertical'}
|
{#if orientation === 'vertical'}
|
||||||
<ContextMenu.Item on:click={copySelection}>
|
<ContextMenu.Item onclick={() => selection.copySelection()}>
|
||||||
<ClipboardCopy size="16" class="mr-1" />
|
<ClipboardCopy size="16" />
|
||||||
{$_('menu.copy')}
|
{i18n._('menu.copy')}
|
||||||
<Shortcut key="C" ctrl={true} />
|
<Shortcut key="C" ctrl={true} />
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
<ContextMenu.Item on:click={cutSelection}>
|
<ContextMenu.Item onclick={() => selection.cutSelection()}>
|
||||||
<Scissors size="16" class="mr-1" />
|
<Scissors size="16" />
|
||||||
{$_('menu.cut')}
|
{i18n._('menu.cut')}
|
||||||
<Shortcut key="X" ctrl={true} />
|
<Shortcut key="X" ctrl={true} />
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
<ContextMenu.Item
|
<ContextMenu.Item
|
||||||
disabled={$copied === undefined ||
|
disabled={$copied === undefined ||
|
||||||
$copied.length === 0 ||
|
$copied.length === 0 ||
|
||||||
!allowedPastes[$copied[0].level].includes(item.level)}
|
!allowedPastes[$copied[0].level].includes(item.level)}
|
||||||
on:click={pasteSelection}
|
onclick={pasteSelection}
|
||||||
>
|
>
|
||||||
<ClipboardPaste size="16" class="mr-1" />
|
<ClipboardPaste size="16" />
|
||||||
{$_('menu.paste')}
|
{i18n._('menu.paste')}
|
||||||
<Shortcut key="V" ctrl={true} />
|
<Shortcut key="V" ctrl={true} />
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
{/if}
|
{/if}
|
||||||
<ContextMenu.Separator />
|
<ContextMenu.Separator />
|
||||||
<ContextMenu.Item on:click={dbUtils.deleteSelection}>
|
<ContextMenu.Item onclick={fileActions.deleteSelection}>
|
||||||
{#if item instanceof ListFileItem}
|
<Trash2 size="16" />
|
||||||
<FileX size="16" class="mr-1" />
|
{i18n._('menu.delete')}
|
||||||
{$_('menu.close')}
|
|
||||||
{:else}
|
|
||||||
<Trash2 size="16" class="mr-1" />
|
|
||||||
{$_('menu.delete')}
|
|
||||||
{/if}
|
|
||||||
<Shortcut key="⌫" ctrl={true} />
|
<Shortcut key="⌫" ctrl={true} />
|
||||||
</ContextMenu.Item>
|
</ContextMenu.Item>
|
||||||
</ContextMenu.Content>
|
</ContextMenu.Content>
|
||||||
|
|||||||
@@ -2,12 +2,16 @@
|
|||||||
import CollapsibleTree from '$lib/components/collapsible-tree/CollapsibleTree.svelte';
|
import CollapsibleTree from '$lib/components/collapsible-tree/CollapsibleTree.svelte';
|
||||||
import FileListNode from '$lib/components/file-list/FileListNode.svelte';
|
import FileListNode from '$lib/components/file-list/FileListNode.svelte';
|
||||||
|
|
||||||
import type { GPXFileWithStatistics } from '$lib/db';
|
|
||||||
import { getContext } from 'svelte';
|
import { getContext } from 'svelte';
|
||||||
import type { Readable } from 'svelte/store';
|
import type { Readable } from 'svelte/store';
|
||||||
import { ListFileItem } from './FileList';
|
import { ListFileItem } from './file-list';
|
||||||
|
import type { GPXFileWithStatistics } from '$lib/logic/statistics-tree';
|
||||||
|
|
||||||
export let file: Readable<GPXFileWithStatistics | undefined>;
|
let {
|
||||||
|
file,
|
||||||
|
}: {
|
||||||
|
file: Readable<GPXFileWithStatistics | undefined>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
let recursive = getContext<boolean>('recursive');
|
let recursive = getContext<boolean>('recursive');
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,375 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
||||||
size: number = 0;
|
|
||||||
|
|
||||||
constructor(item: ListItem) {
|
|
||||||
this.item = item;
|
|
||||||
this.selected = false;
|
|
||||||
this.children = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
clear() {
|
|
||||||
this.selected = false;
|
|
||||||
for (let key in this.children) {
|
|
||||||
this.children[key].clear();
|
|
||||||
}
|
|
||||||
this.size = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
_setOrToggle(item: ListItem, value?: boolean) {
|
|
||||||
if (item.level === this.item.level) {
|
|
||||||
let newSelected = value === undefined ? !this.selected : value;
|
|
||||||
if (this.selected !== newSelected) {
|
|
||||||
this.selected = newSelected;
|
|
||||||
this.size += this.selected ? 1 : -1;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let id = item.getIdAtLevel(this.item.level);
|
|
||||||
if (id !== undefined) {
|
|
||||||
if (!this.children.hasOwnProperty(id)) {
|
|
||||||
this.children[id] = new SelectionTreeType(this.item.extend(id));
|
|
||||||
}
|
|
||||||
this.size -= this.children[id].size;
|
|
||||||
this.children[id]._setOrToggle(item, value);
|
|
||||||
this.size += this.children[id].size;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
set(item: ListItem, value: boolean) {
|
|
||||||
this._setOrToggle(item, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
toggle(item: ListItem) {
|
|
||||||
this._setOrToggle(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
has(item: ListItem): boolean {
|
|
||||||
if (item.level === this.item.level) {
|
|
||||||
return this.selected;
|
|
||||||
} else {
|
|
||||||
let id = item.getIdAtLevel(this.item.level);
|
|
||||||
if (id !== undefined) {
|
|
||||||
if (this.children.hasOwnProperty(id)) {
|
|
||||||
return this.children[id].has(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
hasAnyParent(item: ListItem, self: boolean = true): boolean {
|
|
||||||
if (
|
|
||||||
this.selected &&
|
|
||||||
this.item.level <= item.level &&
|
|
||||||
(self || this.item.level < item.level)
|
|
||||||
) {
|
|
||||||
return this.selected;
|
|
||||||
}
|
|
||||||
let id = item.getIdAtLevel(this.item.level);
|
|
||||||
if (id !== undefined) {
|
|
||||||
if (this.children.hasOwnProperty(id)) {
|
|
||||||
return this.children[id].hasAnyParent(item, self);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
hasAnyChildren(item: ListItem, self: boolean = true, ignoreIds?: (string | number)[]): boolean {
|
|
||||||
if (
|
|
||||||
this.selected &&
|
|
||||||
this.item.level >= item.level &&
|
|
||||||
(self || this.item.level > item.level)
|
|
||||||
) {
|
|
||||||
return this.selected;
|
|
||||||
}
|
|
||||||
let id = item.getIdAtLevel(this.item.level);
|
|
||||||
if (id !== undefined) {
|
|
||||||
if (ignoreIds === undefined || ignoreIds.indexOf(id) === -1) {
|
|
||||||
if (this.children.hasOwnProperty(id)) {
|
|
||||||
return this.children[id].hasAnyChildren(item, self, ignoreIds);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (let key in this.children) {
|
|
||||||
if (ignoreIds === undefined || ignoreIds.indexOf(key) === -1) {
|
|
||||||
if (this.children[key].hasAnyChildren(item, self, ignoreIds)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
getSelected(selection: ListItem[] = []): ListItem[] {
|
|
||||||
if (this.selected) {
|
|
||||||
selection.push(this.item);
|
|
||||||
}
|
|
||||||
for (let key in this.children) {
|
|
||||||
this.children[key].getSelected(selection);
|
|
||||||
}
|
|
||||||
return selection;
|
|
||||||
}
|
|
||||||
|
|
||||||
forEach(callback: (item: ListItem) => void) {
|
|
||||||
if (this.selected) {
|
|
||||||
callback(this.item);
|
|
||||||
}
|
|
||||||
for (let key in this.children) {
|
|
||||||
this.children[key].forEach(callback);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getChild(id: string | number): SelectionTreeType | undefined {
|
|
||||||
return this.children[id];
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteChild(id: string | number) {
|
|
||||||
if (this.children.hasOwnProperty(id)) {
|
|
||||||
this.size -= this.children[id].size;
|
|
||||||
delete this.children[id];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const selection = writable<SelectionTreeType>(new SelectionTreeType(new ListRootItem()));
|
|
||||||
|
|
||||||
export function selectItem(item: ListItem) {
|
|
||||||
selection.update(($selection) => {
|
|
||||||
$selection.clear();
|
|
||||||
$selection.set(item, true);
|
|
||||||
return $selection;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function selectFile(fileId: string) {
|
|
||||||
selectItem(new ListFileItem(fileId));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addSelectItem(item: ListItem) {
|
|
||||||
selection.update(($selection) => {
|
|
||||||
$selection.toggle(item);
|
|
||||||
return $selection;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addSelectFile(fileId: string) {
|
|
||||||
addSelectItem(new ListFileItem(fileId));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function selectAll() {
|
|
||||||
selection.update(($selection) => {
|
|
||||||
let item: ListItem = new ListRootItem();
|
|
||||||
$selection.forEach((i) => {
|
|
||||||
item = i;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (item instanceof ListRootItem || item instanceof ListFileItem) {
|
|
||||||
$selection.clear();
|
|
||||||
get(fileObservers).forEach((_file, fileId) => {
|
|
||||||
$selection.set(new ListFileItem(fileId), true);
|
|
||||||
});
|
|
||||||
} else if (item instanceof ListTrackItem) {
|
|
||||||
let file = getFile(item.getFileId());
|
|
||||||
if (file) {
|
|
||||||
file.trk.forEach((_track, trackId) => {
|
|
||||||
$selection.set(new ListTrackItem(item.getFileId(), trackId), true);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (item instanceof ListTrackSegmentItem) {
|
|
||||||
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
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (item instanceof ListWaypointItem) {
|
|
||||||
let file = getFile(item.getFileId());
|
|
||||||
if (file) {
|
|
||||||
file.wpt.forEach((_waypoint, waypointId) => {
|
|
||||||
$selection.set(new ListWaypointItem(item.getFileId(), waypointId), true);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $selection;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getOrderedSelection(reverse: boolean = false): ListItem[] {
|
|
||||||
let selected: ListItem[] = [];
|
|
||||||
applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
|
|
||||||
selected.push(...items);
|
|
||||||
}, reverse);
|
|
||||||
return selected;
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
) {
|
|
||||||
items.push(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (items.length > 0) {
|
|
||||||
sortItems(items, reverse);
|
|
||||||
callback(fileId, level, items);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyToOrderedSelectedItemsFromFile(
|
|
||||||
callback: (fileId: string, level: ListLevel | undefined, items: ListItem[]) => void,
|
|
||||||
reverse: boolean = true
|
|
||||||
) {
|
|
||||||
applyToOrderedItemsFromFile(get(selection).getSelected(), callback, reverse);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const copied = writable<ListItem[] | undefined>(undefined);
|
|
||||||
export const cut = writable(false);
|
|
||||||
|
|
||||||
export function copySelection(): boolean {
|
|
||||||
let selected = get(selection).getSelected();
|
|
||||||
if (selected.length > 0) {
|
|
||||||
copied.set(selected);
|
|
||||||
cut.set(false);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function cutSelection() {
|
|
||||||
if (copySelection()) {
|
|
||||||
cut.set(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetCopied() {
|
|
||||||
copied.set(undefined);
|
|
||||||
cut.set(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pasteSelection() {
|
|
||||||
let fromItems = get(copied);
|
|
||||||
if (fromItems === undefined || fromItems.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let selected = get(selection).getSelected();
|
|
||||||
if (selected.length === 0) {
|
|
||||||
selected = [new ListRootItem()];
|
|
||||||
}
|
|
||||||
|
|
||||||
let fromParent = fromItems[0].getParent();
|
|
||||||
let toParent = selected[selected.length - 1];
|
|
||||||
|
|
||||||
let startIndex: number | undefined = undefined;
|
|
||||||
|
|
||||||
if (fromItems[0].level === toParent.level) {
|
|
||||||
if (
|
|
||||||
toParent instanceof ListTrackItem ||
|
|
||||||
toParent instanceof ListTrackSegmentItem ||
|
|
||||||
toParent instanceof ListWaypointItem
|
|
||||||
) {
|
|
||||||
startIndex = toParent.getId() + 1;
|
|
||||||
}
|
|
||||||
toParent = toParent.getParent();
|
|
||||||
}
|
|
||||||
|
|
||||||
let toItems: ListItem[] = [];
|
|
||||||
if (toParent.level === ListLevel.ROOT) {
|
|
||||||
let fileIds = getFileIds(fromItems.length);
|
|
||||||
fileIds.forEach((fileId) => {
|
|
||||||
toItems.push(new ListFileItem(fileId));
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
let toFile = getFile(toParent.getFileId());
|
|
||||||
if (toFile) {
|
|
||||||
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
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} 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
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} 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
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (toParent instanceof ListWaypointsItem) {
|
|
||||||
if (item instanceof ListWaypointItem) {
|
|
||||||
toItems.push(
|
|
||||||
new ListWaypointItem(
|
|
||||||
toParent.getFileId(),
|
|
||||||
(startIndex ?? toFile.wpt.length) + index
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fromItems.length === toItems.length) {
|
|
||||||
moveItems(fromParent, toParent, fromItems, toItems, get(cut));
|
|
||||||
resetCopied();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { Button } from '$lib/components/ui/button';
|
|
||||||
import { Input } from '$lib/components/ui/input';
|
|
||||||
import { Label } from '$lib/components/ui/label/index.js';
|
|
||||||
import { Slider } from '$lib/components/ui/slider';
|
|
||||||
import * as Popover from '$lib/components/ui/popover';
|
|
||||||
import { dbUtils, getFile, settings } from '$lib/db';
|
|
||||||
import { Save } from 'lucide-svelte';
|
|
||||||
import { ListFileItem, ListTrackItem, type ListItem } from './FileList';
|
|
||||||
import { selection } from './Selection';
|
|
||||||
import { editStyle, gpxLayers } from '$lib/stores';
|
|
||||||
import { _ } from 'svelte-i18n';
|
|
||||||
|
|
||||||
export let item: ListItem;
|
|
||||||
export let open = false;
|
|
||||||
|
|
||||||
const { defaultOpacity, defaultWidth } = settings;
|
|
||||||
|
|
||||||
let colors: string[] = [];
|
|
||||||
let color: string | undefined = undefined;
|
|
||||||
let opacity: number[] = [];
|
|
||||||
let width: number[] = [];
|
|
||||||
let colorChanged = false;
|
|
||||||
let opacityChanged = false;
|
|
||||||
let widthChanged = false;
|
|
||||||
|
|
||||||
function setStyleInputs() {
|
|
||||||
colors = [];
|
|
||||||
opacity = [];
|
|
||||||
width = [];
|
|
||||||
|
|
||||||
$selection.forEach((item) => {
|
|
||||||
if (item instanceof ListFileItem) {
|
|
||||||
let file = getFile(item.getFileId());
|
|
||||||
let layer = gpxLayers.get(item.getFileId());
|
|
||||||
if (file && layer) {
|
|
||||||
let style = file.getStyle();
|
|
||||||
style.color.push(layer.layerColor);
|
|
||||||
|
|
||||||
style.color.forEach((c) => {
|
|
||||||
if (!colors.includes(c)) {
|
|
||||||
colors.push(c);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
style.opacity.forEach((o) => {
|
|
||||||
if (!opacity.includes(o)) {
|
|
||||||
opacity.push(o);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
style.width.forEach((w) => {
|
|
||||||
if (!width.includes(w)) {
|
|
||||||
width.push(w);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (item instanceof ListTrackItem) {
|
|
||||||
let file = getFile(item.getFileId());
|
|
||||||
let layer = gpxLayers.get(item.getFileId());
|
|
||||||
if (file && layer) {
|
|
||||||
let track = file.trk[item.getTrackIndex()];
|
|
||||||
let style = track.getStyle();
|
|
||||||
if (style) {
|
|
||||||
if (
|
|
||||||
style['gpx_style:color'] &&
|
|
||||||
!colors.includes(style['gpx_style:color'])
|
|
||||||
) {
|
|
||||||
colors.push(style['gpx_style:color']);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
style['gpx_style:opacity'] &&
|
|
||||||
!opacity.includes(style['gpx_style:opacity'])
|
|
||||||
) {
|
|
||||||
opacity.push(style['gpx_style:opacity']);
|
|
||||||
}
|
|
||||||
if (style['gpx_style:width'] && !width.includes(style['gpx_style:width'])) {
|
|
||||||
width.push(style['gpx_style:width']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!colors.includes(layer.layerColor)) {
|
|
||||||
colors.push(layer.layerColor);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
color = colors[0];
|
|
||||||
opacity = [opacity[0] ?? $defaultOpacity];
|
|
||||||
width = [width[0] ?? $defaultWidth];
|
|
||||||
|
|
||||||
colorChanged = false;
|
|
||||||
opacityChanged = false;
|
|
||||||
widthChanged = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$: if ($selection && open) {
|
|
||||||
setStyleInputs();
|
|
||||||
}
|
|
||||||
|
|
||||||
$: if (!open) {
|
|
||||||
$editStyle = false;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Popover.Root bind:open>
|
|
||||||
<Popover.Trigger />
|
|
||||||
<Popover.Content side="top" sideOffset={22} alignOffset={30} class="flex flex-col gap-3">
|
|
||||||
<Label class="flex flex-row gap-2 items-center justify-between">
|
|
||||||
{$_('menu.style.color')}
|
|
||||||
<Input
|
|
||||||
bind:value={color}
|
|
||||||
type="color"
|
|
||||||
class="p-0 h-6 w-40"
|
|
||||||
on:change={() => (colorChanged = true)}
|
|
||||||
/>
|
|
||||||
</Label>
|
|
||||||
<Label class="flex flex-row gap-2 items-center justify-between">
|
|
||||||
{$_('menu.style.opacity')}
|
|
||||||
<div class="w-40 p-2">
|
|
||||||
<Slider
|
|
||||||
bind:value={opacity}
|
|
||||||
min={0.3}
|
|
||||||
max={1}
|
|
||||||
step={0.1}
|
|
||||||
onValueChange={() => (opacityChanged = true)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Label>
|
|
||||||
<Label class="flex flex-row gap-2 items-center justify-between">
|
|
||||||
{$_('menu.style.width')}
|
|
||||||
<div class="w-40 p-2">
|
|
||||||
<Slider
|
|
||||||
bind:value={width}
|
|
||||||
id="width"
|
|
||||||
min={1}
|
|
||||||
max={10}
|
|
||||||
step={1}
|
|
||||||
onValueChange={() => (widthChanged = true)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Label>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
disabled={!colorChanged && !opacityChanged && !widthChanged}
|
|
||||||
on:click={() => {
|
|
||||||
let style = {};
|
|
||||||
if (colorChanged) {
|
|
||||||
style['gpx_style:color'] = color;
|
|
||||||
}
|
|
||||||
if (opacityChanged) {
|
|
||||||
style['gpx_style:opacity'] = opacity[0];
|
|
||||||
}
|
|
||||||
if (widthChanged) {
|
|
||||||
style['gpx_style:width'] = width[0];
|
|
||||||
}
|
|
||||||
dbUtils.setStyleToSelection(style);
|
|
||||||
|
|
||||||
if (item instanceof ListFileItem && $selection.size === gpxLayers.size) {
|
|
||||||
if (style['gpx_style:opacity']) {
|
|
||||||
$defaultOpacity = style['gpx_style:opacity'];
|
|
||||||
}
|
|
||||||
if (style['gpx_style:width']) {
|
|
||||||
$defaultWidth = style['gpx_style:width'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
open = false;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Save size="16" class="mr-1" />
|
|
||||||
{$_('menu.metadata.save')}
|
|
||||||
</Button>
|
|
||||||
</Popover.Content>
|
|
||||||
</Popover.Root>
|
|
||||||
300
website/src/lib/components/file-list/file-list.ts
Normal file
300
website/src/lib/components/file-list/file-list.ts
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
export enum ListLevel {
|
||||||
|
ROOT,
|
||||||
|
FILE,
|
||||||
|
TRACK,
|
||||||
|
SEGMENT,
|
||||||
|
WAYPOINTS,
|
||||||
|
WAYPOINT,
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class ListItem {
|
||||||
|
[x: string]: any;
|
||||||
|
level: ListLevel;
|
||||||
|
|
||||||
|
constructor(level: ListLevel) {
|
||||||
|
this.level = level;
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract getId(): string | number;
|
||||||
|
abstract getFullId(): string;
|
||||||
|
abstract getIdAtLevel(level: ListLevel): string | number | undefined;
|
||||||
|
abstract getFileId(): string;
|
||||||
|
abstract getParent(): ListItem;
|
||||||
|
abstract extend(id: string | number): ListItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListRootItem extends ListItem {
|
||||||
|
constructor() {
|
||||||
|
super(ListLevel.ROOT);
|
||||||
|
}
|
||||||
|
|
||||||
|
getId(): string {
|
||||||
|
return 'root';
|
||||||
|
}
|
||||||
|
|
||||||
|
getFullId(): string {
|
||||||
|
return 'root';
|
||||||
|
}
|
||||||
|
|
||||||
|
getIdAtLevel(level: ListLevel): string | number | undefined {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
getFileId(): string {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
getParent(): ListItem {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
extend(id: string): ListFileItem {
|
||||||
|
return new ListFileItem(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListFileItem extends ListItem {
|
||||||
|
fileId: string;
|
||||||
|
|
||||||
|
constructor(fileId: string) {
|
||||||
|
super(ListLevel.FILE);
|
||||||
|
this.fileId = fileId;
|
||||||
|
}
|
||||||
|
|
||||||
|
getId(): string {
|
||||||
|
return this.fileId;
|
||||||
|
}
|
||||||
|
|
||||||
|
getFullId(): string {
|
||||||
|
return this.fileId;
|
||||||
|
}
|
||||||
|
|
||||||
|
getIdAtLevel(level: ListLevel): string | number | undefined {
|
||||||
|
switch (level) {
|
||||||
|
case ListLevel.ROOT:
|
||||||
|
return this.fileId;
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getFileId(): string {
|
||||||
|
return this.fileId;
|
||||||
|
}
|
||||||
|
|
||||||
|
getParent(): ListItem {
|
||||||
|
return new ListRootItem();
|
||||||
|
}
|
||||||
|
|
||||||
|
extend(id: number | 'waypoints'): ListTrackItem | ListWaypointsItem {
|
||||||
|
if (id === 'waypoints') {
|
||||||
|
return new ListWaypointsItem(this.fileId);
|
||||||
|
} else {
|
||||||
|
return new ListTrackItem(this.fileId, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListTrackItem extends ListItem {
|
||||||
|
fileId: string;
|
||||||
|
trackIndex: number;
|
||||||
|
|
||||||
|
constructor(fileId: string, trackIndex: number) {
|
||||||
|
super(ListLevel.TRACK);
|
||||||
|
this.fileId = fileId;
|
||||||
|
this.trackIndex = trackIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
getId(): number {
|
||||||
|
return this.trackIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
getFullId(): string {
|
||||||
|
return `${this.fileId}-track-${this.trackIndex}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getIdAtLevel(level: ListLevel): string | number | undefined {
|
||||||
|
switch (level) {
|
||||||
|
case ListLevel.ROOT:
|
||||||
|
return this.fileId;
|
||||||
|
case ListLevel.FILE:
|
||||||
|
return this.trackIndex;
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getFileId(): string {
|
||||||
|
return this.fileId;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTrackIndex(): number {
|
||||||
|
return this.trackIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
getParent(): ListItem {
|
||||||
|
return new ListFileItem(this.fileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
extend(id: number): ListTrackSegmentItem {
|
||||||
|
return new ListTrackSegmentItem(this.fileId, this.trackIndex, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListTrackSegmentItem extends ListItem {
|
||||||
|
fileId: string;
|
||||||
|
trackIndex: number;
|
||||||
|
segmentIndex: number;
|
||||||
|
|
||||||
|
constructor(fileId: string, trackIndex: number, segmentIndex: number) {
|
||||||
|
super(ListLevel.SEGMENT);
|
||||||
|
this.fileId = fileId;
|
||||||
|
this.trackIndex = trackIndex;
|
||||||
|
this.segmentIndex = segmentIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
getId(): number {
|
||||||
|
return this.segmentIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
getFullId(): string {
|
||||||
|
return `${this.fileId}-track-${this.trackIndex}--${this.segmentIndex}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getIdAtLevel(level: ListLevel): string | number | undefined {
|
||||||
|
switch (level) {
|
||||||
|
case ListLevel.ROOT:
|
||||||
|
return this.fileId;
|
||||||
|
case ListLevel.FILE:
|
||||||
|
return this.trackIndex;
|
||||||
|
case ListLevel.TRACK:
|
||||||
|
return this.segmentIndex;
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getFileId(): string {
|
||||||
|
return this.fileId;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTrackIndex(): number {
|
||||||
|
return this.trackIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSegmentIndex(): number {
|
||||||
|
return this.segmentIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
getParent(): ListItem {
|
||||||
|
return new ListTrackItem(this.fileId, this.trackIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
extend(): ListTrackSegmentItem {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListWaypointsItem extends ListItem {
|
||||||
|
fileId: string;
|
||||||
|
|
||||||
|
constructor(fileId: string) {
|
||||||
|
super(ListLevel.WAYPOINTS);
|
||||||
|
this.fileId = fileId;
|
||||||
|
}
|
||||||
|
|
||||||
|
getId(): string {
|
||||||
|
return 'waypoints';
|
||||||
|
}
|
||||||
|
|
||||||
|
getFullId(): string {
|
||||||
|
return `${this.fileId}-waypoints`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getIdAtLevel(level: ListLevel): string | number | undefined {
|
||||||
|
switch (level) {
|
||||||
|
case ListLevel.ROOT:
|
||||||
|
return this.fileId;
|
||||||
|
case ListLevel.FILE:
|
||||||
|
return 'waypoints';
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getFileId(): string {
|
||||||
|
return this.fileId;
|
||||||
|
}
|
||||||
|
|
||||||
|
getParent(): ListItem {
|
||||||
|
return new ListFileItem(this.fileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
extend(id: number): ListWaypointItem {
|
||||||
|
return new ListWaypointItem(this.fileId, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListWaypointItem extends ListItem {
|
||||||
|
fileId: string;
|
||||||
|
waypointIndex: number;
|
||||||
|
|
||||||
|
constructor(fileId: string, waypointIndex: number) {
|
||||||
|
super(ListLevel.WAYPOINT);
|
||||||
|
this.fileId = fileId;
|
||||||
|
this.waypointIndex = waypointIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
getId(): number {
|
||||||
|
return this.waypointIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
getFullId(): string {
|
||||||
|
return `${this.fileId}-waypoint-${this.waypointIndex}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getIdAtLevel(level: ListLevel): string | number | undefined {
|
||||||
|
switch (level) {
|
||||||
|
case ListLevel.ROOT:
|
||||||
|
return this.fileId;
|
||||||
|
case ListLevel.FILE:
|
||||||
|
return 'waypoints';
|
||||||
|
case ListLevel.WAYPOINTS:
|
||||||
|
return this.waypointIndex;
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getFileId(): string {
|
||||||
|
return this.fileId;
|
||||||
|
}
|
||||||
|
|
||||||
|
getWaypointIndex(): number {
|
||||||
|
return this.waypointIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
getParent(): ListItem {
|
||||||
|
return new ListWaypointsItem(this.fileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
extend(): ListWaypointItem {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortItems(items: ListItem[], reverse: boolean = false) {
|
||||||
|
items.sort((a, b) => {
|
||||||
|
if (a instanceof ListTrackItem && b instanceof ListTrackItem) {
|
||||||
|
return a.getTrackIndex() - b.getTrackIndex();
|
||||||
|
} else if (a instanceof ListTrackSegmentItem && b instanceof ListTrackSegmentItem) {
|
||||||
|
return a.getSegmentIndex() - b.getSegmentIndex();
|
||||||
|
} else if (a instanceof ListWaypointItem && b instanceof ListWaypointItem) {
|
||||||
|
return a.getWaypointIndex() - b.getWaypointIndex();
|
||||||
|
}
|
||||||
|
return a.level - b.level;
|
||||||
|
});
|
||||||
|
if (reverse) {
|
||||||
|
items.reverse();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,46 +4,56 @@
|
|||||||
import { Textarea } from '$lib/components/ui/textarea';
|
import { Textarea } from '$lib/components/ui/textarea';
|
||||||
import { Label } from '$lib/components/ui/label/index.js';
|
import { Label } from '$lib/components/ui/label/index.js';
|
||||||
import * as Popover from '$lib/components/ui/popover';
|
import * as Popover from '$lib/components/ui/popover';
|
||||||
import { dbUtils } from '$lib/db';
|
import { Save } from '@lucide/svelte';
|
||||||
import { Save } from 'lucide-svelte';
|
import { ListFileItem, ListTrackItem, type ListItem } from '../file-list';
|
||||||
import { ListFileItem, ListTrackItem, type ListItem } from './FileList';
|
|
||||||
import { GPXTreeElement, Track, type AnyGPXTreeElement, Waypoint, GPXFile } from 'gpx';
|
import { GPXTreeElement, Track, type AnyGPXTreeElement, Waypoint, GPXFile } from 'gpx';
|
||||||
import { _ } from 'svelte-i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import { editMetadata } from '$lib/stores';
|
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
|
||||||
|
import { fileActionManager } from '$lib/logic/file-action-manager';
|
||||||
|
|
||||||
export let node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint;
|
let {
|
||||||
export let item: ListItem;
|
node,
|
||||||
export let open = false;
|
item,
|
||||||
|
open = $bindable(),
|
||||||
|
}: {
|
||||||
|
node: GPXTreeElement<AnyGPXTreeElement> | Waypoint[] | Waypoint;
|
||||||
|
item: ListItem;
|
||||||
|
open: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
let name: string =
|
let name: string = $derived(
|
||||||
node instanceof GPXFile
|
node instanceof GPXFile
|
||||||
? (node.metadata.name ?? '')
|
? (node.metadata.name ?? '')
|
||||||
: node instanceof Track
|
: node instanceof Track
|
||||||
? (node.name ?? '')
|
? (node.name ?? '')
|
||||||
: '';
|
: ''
|
||||||
let description: string =
|
);
|
||||||
|
let description: string = $derived(
|
||||||
node instanceof GPXFile
|
node instanceof GPXFile
|
||||||
? (node.metadata.desc ?? '')
|
? (node.metadata.desc ?? '')
|
||||||
: node instanceof Track
|
: node instanceof Track
|
||||||
? (node.desc ?? '')
|
? (node.desc ?? '')
|
||||||
: '';
|
: ''
|
||||||
|
);
|
||||||
|
|
||||||
$: if (!open) {
|
$effect(() => {
|
||||||
$editMetadata = false;
|
if (!open) {
|
||||||
}
|
editMetadata.current = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Popover.Root bind:open>
|
<Popover.Root bind:open>
|
||||||
<Popover.Trigger />
|
<Popover.Trigger class="-mx-1" />
|
||||||
<Popover.Content side="top" sideOffset={22} alignOffset={30} class="flex flex-col gap-3">
|
<Popover.Content side="top" sideOffset={22} alignOffset={30} class="flex flex-col gap-3">
|
||||||
<Label for="name">{$_('menu.metadata.name')}</Label>
|
<Label for="name">{i18n._('menu.metadata.name')}</Label>
|
||||||
<Input bind:value={name} id="name" class="font-semibold h-8" />
|
<Input bind:value={name} id="name" class="font-semibold h-8" />
|
||||||
<Label for="description">{$_('menu.metadata.description')}</Label>
|
<Label for="description">{i18n._('menu.metadata.description')}</Label>
|
||||||
<Textarea bind:value={description} id="description" />
|
<Textarea bind:value={description} id="description" />
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
on:click={() => {
|
onclick={() => {
|
||||||
dbUtils.applyToFile(item.getFileId(), (file) => {
|
fileActionManager.applyToFile(item.getFileId(), (file) => {
|
||||||
if (item instanceof ListFileItem && node instanceof GPXFile) {
|
if (item instanceof ListFileItem && node instanceof GPXFile) {
|
||||||
file.metadata.name = name;
|
file.metadata.name = name;
|
||||||
file.metadata.desc = description;
|
file.metadata.desc = description;
|
||||||
@@ -58,8 +68,8 @@
|
|||||||
open = false;
|
open = false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Save size="16" class="mr-1" />
|
<Save size="16" />
|
||||||
{$_('menu.metadata.save')}
|
{i18n._('menu.metadata.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</Popover.Content>
|
</Popover.Content>
|
||||||
</Popover.Root>
|
</Popover.Root>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export const editMetadata = $state({
|
||||||
|
current: false,
|
||||||
|
});
|
||||||
284
website/src/lib/components/file-list/sortable-file-list.ts
Normal file
284
website/src/lib/components/file-list/sortable-file-list.ts
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
import { isMac } from '$lib/utils';
|
||||||
|
import Sortable, { type Direction } from 'sortablejs/Sortable';
|
||||||
|
import { ListItem, ListLevel, ListRootItem } from './file-list';
|
||||||
|
import { selection } from '$lib/logic/selection';
|
||||||
|
import { getFileIds, moveItems } from '$lib/logic/file-actions';
|
||||||
|
import { get, writable, type Readable } from 'svelte/store';
|
||||||
|
import { settings } from '$lib/logic/settings';
|
||||||
|
import type { GPXFileWithStatistics } from '$lib/logic/statistics-tree';
|
||||||
|
import type { AnyGPXTreeElement, GPXTreeElement, Waypoint } from 'gpx';
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
|
||||||
|
const { fileOrder } = settings;
|
||||||
|
|
||||||
|
export const allowedMoves: Record<ListLevel, ListLevel[]> = {
|
||||||
|
[ListLevel.ROOT]: [],
|
||||||
|
[ListLevel.FILE]: [ListLevel.FILE],
|
||||||
|
[ListLevel.TRACK]: [ListLevel.FILE, ListLevel.TRACK],
|
||||||
|
[ListLevel.SEGMENT]: [ListLevel.FILE, ListLevel.TRACK, ListLevel.SEGMENT],
|
||||||
|
[ListLevel.WAYPOINTS]: [ListLevel.WAYPOINTS],
|
||||||
|
[ListLevel.WAYPOINT]: [ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const allowedPastes: Record<ListLevel, ListLevel[]> = {
|
||||||
|
[ListLevel.ROOT]: [],
|
||||||
|
[ListLevel.FILE]: [ListLevel.ROOT, ListLevel.FILE],
|
||||||
|
[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],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dragging = writable<ListLevel | null>(null);
|
||||||
|
|
||||||
|
export class SortableFileList {
|
||||||
|
private _node:
|
||||||
|
| Map<string, Readable<GPXFileWithStatistics | undefined>>
|
||||||
|
| GPXTreeElement<AnyGPXTreeElement>
|
||||||
|
| Waypoint[]
|
||||||
|
| Waypoint;
|
||||||
|
private _item: ListItem;
|
||||||
|
private _sortableLevel: ListLevel;
|
||||||
|
private _container: HTMLElement;
|
||||||
|
private _sortable: Sortable | null = null;
|
||||||
|
private _elements: { [id: string]: HTMLElement } = {};
|
||||||
|
private _updatingSelection: boolean = false;
|
||||||
|
private _unsubscribes: (() => void)[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
container: HTMLElement,
|
||||||
|
node:
|
||||||
|
| Map<string, Readable<GPXFileWithStatistics | undefined>>
|
||||||
|
| GPXTreeElement<AnyGPXTreeElement>
|
||||||
|
| Waypoint[]
|
||||||
|
| Waypoint,
|
||||||
|
item: ListItem,
|
||||||
|
waypointRoot: boolean,
|
||||||
|
sortableLevel: ListLevel,
|
||||||
|
orientation: Direction
|
||||||
|
) {
|
||||||
|
this._node = node;
|
||||||
|
this._item = item;
|
||||||
|
this._sortableLevel = sortableLevel;
|
||||||
|
this._container = container;
|
||||||
|
this._sortable = Sortable.create(container, {
|
||||||
|
group: {
|
||||||
|
name: sortableLevel,
|
||||||
|
pull: allowedMoves[sortableLevel],
|
||||||
|
put: true,
|
||||||
|
},
|
||||||
|
direction: orientation,
|
||||||
|
forceAutoScrollFallback: true,
|
||||||
|
multiDrag: true,
|
||||||
|
multiDragKey: isMac() ? 'Meta' : 'Ctrl',
|
||||||
|
avoidImplicitDeselect: true,
|
||||||
|
onSelect: (e: Sortable.SortableEvent) =>
|
||||||
|
setTimeout(() => this.updateToSelection(e), 50),
|
||||||
|
onDeselect: (e: Sortable.SortableEvent) =>
|
||||||
|
setTimeout(() => this.updateToSelection(e), 50),
|
||||||
|
onStart: () => dragging.set(sortableLevel),
|
||||||
|
onEnd: () => dragging.set(null),
|
||||||
|
onSort: (e: Sortable.SortableEvent) => this.onSort(e),
|
||||||
|
});
|
||||||
|
Object.defineProperty(this._sortable, '_item', {
|
||||||
|
value: item,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(this._sortable, '_waypointRoot', {
|
||||||
|
value: waypointRoot,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
this._unsubscribes.push(
|
||||||
|
selection.subscribe(() => tick().then(() => this.updateFromSelection()))
|
||||||
|
);
|
||||||
|
this._unsubscribes.push(fileOrder.subscribe(() => this.updateFromFileOrder()));
|
||||||
|
}
|
||||||
|
|
||||||
|
onSort(e: Sortable.SortableEvent) {
|
||||||
|
this.updateToFileOrder();
|
||||||
|
|
||||||
|
const from = Sortable.get(e.from);
|
||||||
|
const to = Sortable.get(e.to);
|
||||||
|
|
||||||
|
if (!from || !to) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let fromItem = from._item;
|
||||||
|
let toItem = to._item;
|
||||||
|
|
||||||
|
if (this._item === toItem && !(fromItem instanceof ListRootItem)) {
|
||||||
|
// Event is triggered on source and destination list, only handle it once
|
||||||
|
let fromItems = [];
|
||||||
|
let toItems = [];
|
||||||
|
|
||||||
|
if (from._waypointRoot) {
|
||||||
|
fromItems = [fromItem.extend('waypoints')];
|
||||||
|
} else {
|
||||||
|
let oldIndices: number[] =
|
||||||
|
e.oldIndicies.length > 0 ? e.oldIndicies.map((i) => i.index) : [e.oldIndex];
|
||||||
|
oldIndices = oldIndices.filter((i) => i >= 0);
|
||||||
|
oldIndices.sort((a, b) => a - b);
|
||||||
|
|
||||||
|
fromItems = oldIndices.map((i) => fromItem.extend(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (from._waypointRoot && to._waypointRoot) {
|
||||||
|
toItems = [toItem.extend('waypoints')];
|
||||||
|
} else {
|
||||||
|
if (to._waypointRoot) {
|
||||||
|
toItem = toItem.extend('waypoints');
|
||||||
|
}
|
||||||
|
|
||||||
|
let newIndices: number[] =
|
||||||
|
e.newIndicies.length > 0 ? e.newIndicies.map((i) => i.index) : [e.newIndex];
|
||||||
|
newIndices = newIndices.filter((i) => i >= 0);
|
||||||
|
newIndices.sort((a, b) => a - b);
|
||||||
|
|
||||||
|
if (toItem instanceof ListRootItem) {
|
||||||
|
let newFileIds = getFileIds(newIndices.length);
|
||||||
|
toItems = newIndices.map((i, index) => {
|
||||||
|
get(fileOrder).splice(i, 0, newFileIds[index]);
|
||||||
|
return this._item.extend(newFileIds[index]);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toItems = newIndices.map((i) => toItem.extend(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
moveItems(fromItem, toItem, fromItems, toItems);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFromSelection() {
|
||||||
|
const changed = this.getChangedIds();
|
||||||
|
if (changed.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const selection_ = get(selection);
|
||||||
|
for (let id of changed) {
|
||||||
|
let element = this._elements[id];
|
||||||
|
if (element) {
|
||||||
|
if (selection_.has(this._item.extend(id))) {
|
||||||
|
Sortable.utils.select(element);
|
||||||
|
element.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'nearest',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
Sortable.utils.deselect(element);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateToSelection(e: Sortable.SortableEvent) {
|
||||||
|
if (!this._sortable) return;
|
||||||
|
if (this._updatingSelection) return;
|
||||||
|
this._updatingSelection = true;
|
||||||
|
const changed = this.getChangedIds();
|
||||||
|
if (changed.length == 0) {
|
||||||
|
this._updatingSelection = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selection.update(($selection) => {
|
||||||
|
$selection.clear();
|
||||||
|
Object.entries(this._elements).forEach(([id, element]) => {
|
||||||
|
$selection.set(
|
||||||
|
this._item.extend(this.getRealId(id)),
|
||||||
|
element.classList.contains('sortable-selected')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
e.originalEvent &&
|
||||||
|
!(e.originalEvent.ctrlKey || e.originalEvent.metaKey || e.originalEvent.shiftKey) &&
|
||||||
|
($selection.size > 1 ||
|
||||||
|
!$selection.has(this._item.extend(this.getRealId(changed[0]))))
|
||||||
|
) {
|
||||||
|
// Fix bug that sometimes causes a single select to be treated as a multi-select
|
||||||
|
$selection.clear();
|
||||||
|
$selection.set(this._item.extend(this.getRealId(changed[0])), true);
|
||||||
|
}
|
||||||
|
return $selection;
|
||||||
|
});
|
||||||
|
this._updatingSelection = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFromFileOrder() {
|
||||||
|
if (!this._sortable || this._sortableLevel !== ListLevel.FILE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileOrder_ = get(fileOrder);
|
||||||
|
const sortableOrder = this._sortable.toArray();
|
||||||
|
|
||||||
|
if (
|
||||||
|
fileOrder_.length !== sortableOrder.length ||
|
||||||
|
fileOrder_.some((value, index) => value !== sortableOrder[index])
|
||||||
|
) {
|
||||||
|
this._sortable.sort(fileOrder_);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateToFileOrder() {
|
||||||
|
if (!this._sortable || this._sortableLevel !== ListLevel.FILE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileOrder_ = get(fileOrder);
|
||||||
|
const sortableOrder = this._sortable.toArray();
|
||||||
|
|
||||||
|
if (
|
||||||
|
fileOrder_.length !== sortableOrder.length ||
|
||||||
|
fileOrder_.some((value, index) => value !== sortableOrder[index])
|
||||||
|
) {
|
||||||
|
fileOrder.set(sortableOrder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateElements() {
|
||||||
|
this._elements = {};
|
||||||
|
this._container.childNodes.forEach((element) => {
|
||||||
|
if (element instanceof HTMLElement) {
|
||||||
|
let attr = element.getAttribute('data-id');
|
||||||
|
if (attr) {
|
||||||
|
if (this._node instanceof Map && !this._node.has(attr)) {
|
||||||
|
element.remove();
|
||||||
|
} else {
|
||||||
|
this._elements[attr] = element;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this._sortable = null;
|
||||||
|
this._unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||||
|
this._unsubscribes = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
getChangedIds() {
|
||||||
|
let changed: (string | number)[] = [];
|
||||||
|
const selection_ = get(selection);
|
||||||
|
Object.entries(this._elements).forEach(([id, element]) => {
|
||||||
|
let realId = this.getRealId(id);
|
||||||
|
let realItem = this._item.extend(realId);
|
||||||
|
let inSelection = selection_.has(realItem);
|
||||||
|
let isSelected = element.classList.contains('sortable-selected');
|
||||||
|
if (inSelection !== isSelected) {
|
||||||
|
changed.push(realId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
getRealId(id: string | number) {
|
||||||
|
return this._sortableLevel === ListLevel.FILE || this._sortableLevel === ListLevel.WAYPOINTS
|
||||||
|
? id
|
||||||
|
: parseInt(id as string);
|
||||||
|
}
|
||||||
|
}
|
||||||
171
website/src/lib/components/file-list/style/StyleDialog.svelte
Normal file
171
website/src/lib/components/file-list/style/StyleDialog.svelte
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import { Label } from '$lib/components/ui/label/index.js';
|
||||||
|
import { Slider } from '$lib/components/ui/slider';
|
||||||
|
import * as Popover from '$lib/components/ui/popover';
|
||||||
|
import { Save } from '@lucide/svelte';
|
||||||
|
import {
|
||||||
|
ListFileItem,
|
||||||
|
ListTrackItem,
|
||||||
|
type ListItem,
|
||||||
|
} from '$lib/components/file-list/file-list';
|
||||||
|
import { editStyle } from '$lib/components/file-list/style/utils.svelte';
|
||||||
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
|
import type { LineStyleExtension } from 'gpx';
|
||||||
|
import { settings } from '$lib/logic/settings';
|
||||||
|
import { selection } from '$lib/logic/selection';
|
||||||
|
import { fileStateCollection } from '$lib/logic/file-state';
|
||||||
|
import { gpxLayers } from '$lib/components/map/gpx-layer/gpx-layers';
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
import { fileActions } from '$lib/logic/file-actions';
|
||||||
|
|
||||||
|
let {
|
||||||
|
item,
|
||||||
|
open = $bindable(),
|
||||||
|
}: {
|
||||||
|
item: ListItem;
|
||||||
|
open: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const { defaultOpacity, defaultWidth } = settings;
|
||||||
|
|
||||||
|
let color: string = $state('');
|
||||||
|
let opacity: number = $state(0);
|
||||||
|
let width: number = $state(0);
|
||||||
|
let colorChanged = $state(false);
|
||||||
|
let opacityChanged = $state(false);
|
||||||
|
let widthChanged = $state(false);
|
||||||
|
|
||||||
|
function setStyleInputs() {
|
||||||
|
opacity = $defaultOpacity;
|
||||||
|
width = $defaultWidth;
|
||||||
|
|
||||||
|
$selection.forEach((item) => {
|
||||||
|
if (item instanceof ListFileItem) {
|
||||||
|
let file = fileStateCollection.getFile(item.getFileId());
|
||||||
|
let layer = gpxLayers.getLayer(item.getFileId());
|
||||||
|
if (file && layer) {
|
||||||
|
let style = file.getStyle();
|
||||||
|
color = layer.layerColor;
|
||||||
|
if (style.opacity.length > 0) {
|
||||||
|
opacity = style.opacity[0];
|
||||||
|
}
|
||||||
|
if (style.width.length > 0) {
|
||||||
|
width = style.width[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (item instanceof ListTrackItem) {
|
||||||
|
let file = fileStateCollection.getFile(item.getFileId());
|
||||||
|
let layer = gpxLayers.getLayer(item.getFileId());
|
||||||
|
if (file && layer) {
|
||||||
|
color = layer.layerColor;
|
||||||
|
let track = file.trk[item.getTrackIndex()];
|
||||||
|
let style = track.getStyle();
|
||||||
|
if (style) {
|
||||||
|
if (style['gpx_style:color']) {
|
||||||
|
color = style['gpx_style:color'];
|
||||||
|
}
|
||||||
|
if (style['gpx_style:opacity']) {
|
||||||
|
opacity = style['gpx_style:opacity'];
|
||||||
|
}
|
||||||
|
if (style['gpx_style:width']) {
|
||||||
|
width = style['gpx_style:width'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
colorChanged = false;
|
||||||
|
opacityChanged = false;
|
||||||
|
widthChanged = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if ($selection && open) {
|
||||||
|
untrack(() => setStyleInputs());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!open) {
|
||||||
|
editStyle.current = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function applyStyle() {
|
||||||
|
let style: LineStyleExtension = {};
|
||||||
|
if (colorChanged) {
|
||||||
|
style['gpx_style:color'] = color;
|
||||||
|
}
|
||||||
|
if (opacityChanged) {
|
||||||
|
style['gpx_style:opacity'] = opacity;
|
||||||
|
}
|
||||||
|
if (widthChanged) {
|
||||||
|
style['gpx_style:width'] = width;
|
||||||
|
}
|
||||||
|
fileActions.setStyleToSelection(style);
|
||||||
|
|
||||||
|
if (item instanceof ListFileItem && $selection.size === fileStateCollection.size) {
|
||||||
|
if (style['gpx_style:opacity']) {
|
||||||
|
$defaultOpacity = style['gpx_style:opacity'];
|
||||||
|
}
|
||||||
|
if (style['gpx_style:width']) {
|
||||||
|
$defaultWidth = style['gpx_style:width'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Popover.Root bind:open>
|
||||||
|
<Popover.Trigger class="-mx-1" />
|
||||||
|
<Popover.Content side="top" sideOffset={22} alignOffset={30} class="flex flex-col gap-3">
|
||||||
|
<Label class="flex flex-row gap-2 items-center justify-between">
|
||||||
|
{i18n._('menu.style.color')}
|
||||||
|
<Input
|
||||||
|
bind:value={color}
|
||||||
|
type="color"
|
||||||
|
class="p-0 h-6 w-40"
|
||||||
|
onchange={() => (colorChanged = true)}
|
||||||
|
/>
|
||||||
|
</Label>
|
||||||
|
<Label class="flex flex-row gap-2 items-center justify-between">
|
||||||
|
{i18n._('menu.style.opacity')}
|
||||||
|
<div class="w-40 p-2">
|
||||||
|
<Slider
|
||||||
|
bind:value={opacity}
|
||||||
|
min={0.3}
|
||||||
|
max={1}
|
||||||
|
step={0.1}
|
||||||
|
onValueChange={() => (opacityChanged = true)}
|
||||||
|
type="single"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Label>
|
||||||
|
<Label class="flex flex-row gap-2 items-center justify-between">
|
||||||
|
{i18n._('menu.style.width')}
|
||||||
|
<div class="w-40 p-2">
|
||||||
|
<Slider
|
||||||
|
bind:value={width}
|
||||||
|
id="width"
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
step={1}
|
||||||
|
onValueChange={() => (widthChanged = true)}
|
||||||
|
type="single"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Label>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={!colorChanged && !opacityChanged && !widthChanged}
|
||||||
|
onclick={applyStyle}
|
||||||
|
>
|
||||||
|
<Save size="16" />
|
||||||
|
{i18n._('menu.metadata.save')}
|
||||||
|
</Button>
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Root>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export const editStyle = $state({
|
||||||
|
current: false,
|
||||||
|
});
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
<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,128 +0,0 @@
|
|||||||
import { settings } from '$lib/db';
|
|
||||||
import { gpxStatistics } from '$lib/stores';
|
|
||||||
import { get } from 'svelte/store';
|
|
||||||
|
|
||||||
const { distanceMarkers, distanceUnits } = settings;
|
|
||||||
|
|
||||||
const stops = [
|
|
||||||
[100, 0],
|
|
||||||
[50, 7],
|
|
||||||
[25, 8, 10],
|
|
||||||
[10, 10],
|
|
||||||
[5, 11],
|
|
||||||
[1, 13],
|
|
||||||
];
|
|
||||||
|
|
||||||
export class DistanceMarkers {
|
|
||||||
map: mapboxgl.Map;
|
|
||||||
updateBinded: () => void = this.update.bind(this);
|
|
||||||
unsubscribes: (() => void)[] = [];
|
|
||||||
|
|
||||||
constructor(map: mapboxgl.Map) {
|
|
||||||
this.map = map;
|
|
||||||
|
|
||||||
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.import.load', this.updateBinded);
|
|
||||||
}
|
|
||||||
|
|
||||||
update() {
|
|
||||||
try {
|
|
||||||
if (get(distanceMarkers)) {
|
|
||||||
let distanceSource = this.map.getSource('distance-markers');
|
|
||||||
if (distanceSource) {
|
|
||||||
distanceSource.setData(this.getDistanceMarkersGeoJSON());
|
|
||||||
} else {
|
|
||||||
this.map.addSource('distance-markers', {
|
|
||||||
type: 'geojson',
|
|
||||||
data: this.getDistanceMarkersGeoJSON(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
stops.forEach(([d, minzoom, maxzoom]) => {
|
|
||||||
if (!this.map.getLayer(`distance-markers-${d}`)) {
|
|
||||||
this.map.addLayer({
|
|
||||||
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': ['Open Sans Bold'],
|
|
||||||
},
|
|
||||||
paint: {
|
|
||||||
'text-color': 'black',
|
|
||||||
'text-halo-width': 2,
|
|
||||||
'text-halo-color': 'white',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.map.moveLayer(`distance-markers-${d}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
stops.forEach(([d]) => {
|
|
||||||
if (this.map.getLayer(`distance-markers-${d}`)) {
|
|
||||||
this.map.removeLayer(`distance-markers-${d}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// No reliable way to check if the map is ready to add sources and layers
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
remove() {
|
|
||||||
this.unsubscribes.forEach((unsubscribe) => unsubscribe());
|
|
||||||
}
|
|
||||||
|
|
||||||
getDistanceMarkersGeoJSON(): GeoJSON.FeatureCollection {
|
|
||||||
let statistics = get(gpxStatistics);
|
|
||||||
|
|
||||||
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)
|
|
||||||
) {
|
|
||||||
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(),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
properties: {
|
|
||||||
distance,
|
|
||||||
level,
|
|
||||||
minzoom,
|
|
||||||
},
|
|
||||||
} as GeoJSON.Feature);
|
|
||||||
currentTargetDistance += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'FeatureCollection',
|
|
||||||
features,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,554 +0,0 @@
|
|||||||
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',
|
|
||||||
'#0000ff',
|
|
||||||
'#46e646',
|
|
||||||
'#00ccff',
|
|
||||||
'#ff9900',
|
|
||||||
'#ff00ff',
|
|
||||||
'#ffff32',
|
|
||||||
'#288228',
|
|
||||||
'#9933ff',
|
|
||||||
'#50f0be',
|
|
||||||
'#8c645a',
|
|
||||||
];
|
|
||||||
|
|
||||||
const colorCount: { [key: string]: number } = {};
|
|
||||||
for (let color of colors) {
|
|
||||||
colorCount[color] = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the color with the least amount of uses
|
|
||||||
function getColor() {
|
|
||||||
let color = colors.reduce((a, b) => (colorCount[a] <= colorCount[b] ? a : b));
|
|
||||||
colorCount[color]++;
|
|
||||||
return color;
|
|
||||||
}
|
|
||||||
|
|
||||||
function decrementColor(color: string) {
|
|
||||||
if (colorCount.hasOwnProperty(color)) {
|
|
||||||
colorCount[color]--;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMarkerForSymbol(symbol: string | undefined, layerColor: string) {
|
|
||||||
let symbolSvg = symbol ? symbols[symbol]?.iconSvg : undefined;
|
|
||||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
|
||||||
${Square.replace('width="24"', 'width="12"')
|
|
||||||
.replace('height="24"', 'height="12"')
|
|
||||||
.replace('stroke="currentColor"', 'stroke="SteelBlue"')
|
|
||||||
.replace('stroke-width="2"', 'stroke-width="1.5" x="9.6" y="0.4"')
|
|
||||||
.replace('fill="none"', `fill="${layerColor}"`)}
|
|
||||||
${MapPin.replace('width="24"', '')
|
|
||||||
.replace('height="24"', '')
|
|
||||||
.replace('stroke="currentColor"', '')
|
|
||||||
.replace('path', `path fill="#3fb1ce" stroke="SteelBlue" stroke-width="1"`)
|
|
||||||
.replace(
|
|
||||||
'circle',
|
|
||||||
`circle fill="${symbolSvg ? 'none' : 'white'}" stroke="${symbolSvg ? 'none' : 'white'}" stroke-width="2"`
|
|
||||||
)}
|
|
||||||
${
|
|
||||||
symbolSvg
|
|
||||||
?.replace('width="24"', 'width="10"')
|
|
||||||
.replace('height="24"', 'height="10"')
|
|
||||||
.replace('stroke="currentColor"', 'stroke="white"')
|
|
||||||
.replace('stroke-width="2"', 'stroke-width="2.5" x="7" y="5"') ?? ''
|
|
||||||
}
|
|
||||||
</svg>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { directionMarkers, treeFileView, defaultOpacity, defaultWidth } = settings;
|
|
||||||
|
|
||||||
export class GPXLayer {
|
|
||||||
map: mapboxgl.Map;
|
|
||||||
fileId: string;
|
|
||||||
file: Readable<GPXFileWithStatistics | undefined>;
|
|
||||||
layerColor: string;
|
|
||||||
markers: mapboxgl.Marker[] = [];
|
|
||||||
selected: boolean = false;
|
|
||||||
draggable: boolean;
|
|
||||||
unsubscribe: Function[] = [];
|
|
||||||
|
|
||||||
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);
|
|
||||||
layerOnContextMenuBinded: (e: any) => void = this.layerOnContextMenu.bind(this);
|
|
||||||
|
|
||||||
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) => {
|
|
||||||
let newSelected = $selection.hasAnyChildren(new ListFileItem(this.fileId));
|
|
||||||
if (this.selected || newSelected) {
|
|
||||||
this.selected = newSelected;
|
|
||||||
this.update();
|
|
||||||
}
|
|
||||||
if (newSelected) {
|
|
||||||
this.moveToFront();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
this.unsubscribe.push(directionMarkers.subscribe(this.updateBinded));
|
|
||||||
this.unsubscribe.push(
|
|
||||||
currentTool.subscribe((tool) => {
|
|
||||||
if (tool === Tool.WAYPOINT && !this.draggable) {
|
|
||||||
this.draggable = true;
|
|
||||||
this.markers.forEach((marker) => marker.setDraggable(true));
|
|
||||||
} else if (tool !== Tool.WAYPOINT && this.draggable) {
|
|
||||||
this.draggable = false;
|
|
||||||
this.markers.forEach((marker) => marker.setDraggable(false));
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
this.draggable = get(currentTool) === Tool.WAYPOINT;
|
|
||||||
|
|
||||||
this.map.on('style.import.load', this.updateBinded);
|
|
||||||
}
|
|
||||||
|
|
||||||
update() {
|
|
||||||
let file = get(this.file)?.file;
|
|
||||||
if (!file) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
file._data.style &&
|
|
||||||
file._data.style.color &&
|
|
||||||
this.layerColor !== `#${file._data.style.color}`
|
|
||||||
) {
|
|
||||||
decrementColor(this.layerColor);
|
|
||||||
this.layerColor = `#${file._data.style.color}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
let source = this.map.getSource(this.fileId);
|
|
||||||
if (source) {
|
|
||||||
source.setData(this.getGeoJSON());
|
|
||||||
} else {
|
|
||||||
this.map.addSource(this.fileId, {
|
|
||||||
type: 'geojson',
|
|
||||||
data: this.getGeoJSON(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.map.getLayer(this.fileId)) {
|
|
||||||
this.map.addLayer({
|
|
||||||
id: this.fileId,
|
|
||||||
type: 'line',
|
|
||||||
source: this.fileId,
|
|
||||||
layout: {
|
|
||||||
'line-join': 'round',
|
|
||||||
'line-cap': 'round',
|
|
||||||
},
|
|
||||||
paint: {
|
|
||||||
'line-color': ['get', 'color'],
|
|
||||||
'line-width': ['get', 'width'],
|
|
||||||
'line-opacity': ['get', 'opacity'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
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(
|
|
||||||
{
|
|
||||||
id: this.fileId + '-direction',
|
|
||||||
type: 'symbol',
|
|
||||||
source: this.fileId,
|
|
||||||
layout: {
|
|
||||||
'text-field': '»',
|
|
||||||
'text-offset': [0, -0.1],
|
|
||||||
'text-keep-upright': false,
|
|
||||||
'text-max-angle': 361,
|
|
||||||
'text-allow-overlap': true,
|
|
||||||
'text-font': ['Open Sans Bold'],
|
|
||||||
'symbol-placement': 'line',
|
|
||||||
'symbol-spacing': 20,
|
|
||||||
},
|
|
||||||
paint: {
|
|
||||||
'text-color': 'white',
|
|
||||||
'text-opacity': 0.7,
|
|
||||||
'text-halo-width': 0.2,
|
|
||||||
'text-halo-color': 'white',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
this.map.getLayer('distance-markers') ? 'distance-markers' : undefined
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (this.map.getLayer(this.fileId + '-direction')) {
|
|
||||||
this.map.removeLayer(this.fileId + '-direction');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let visibleItems: [number, number][] = [];
|
|
||||||
file.forEachSegment((segment, trackIndex, segmentIndex) => {
|
|
||||||
if (!segment._data.hidden) {
|
|
||||||
visibleItems.push([trackIndex, segmentIndex]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// No reliable way to check if the map is ready to add sources and layers
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let markerIndex = 0;
|
|
||||||
|
|
||||||
if (get(selection).hasAnyChildren(new ListFileItem(this.fileId))) {
|
|
||||||
file.wpt.forEach((waypoint) => {
|
|
||||||
// Update markers
|
|
||||||
let symbolKey = getSymbolKey(waypoint.sym);
|
|
||||||
if (markerIndex < this.markers.length) {
|
|
||||||
this.markers[markerIndex].getElement().innerHTML = getMarkerForSymbol(
|
|
||||||
symbolKey,
|
|
||||||
this.layerColor
|
|
||||||
);
|
|
||||||
this.markers[markerIndex].setLngLat(waypoint.getCoordinates());
|
|
||||||
Object.defineProperty(this.markers[markerIndex], '_waypoint', {
|
|
||||||
value: waypoint,
|
|
||||||
writable: true,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
let element = document.createElement('div');
|
|
||||||
element.classList.add('w-8', 'h-8', 'drop-shadow-xl');
|
|
||||||
element.innerHTML = getMarkerForSymbol(symbolKey, this.layerColor);
|
|
||||||
let marker = new mapboxgl.Marker({
|
|
||||||
draggable: this.draggable,
|
|
||||||
element,
|
|
||||||
anchor: 'bottom',
|
|
||||||
}).setLngLat(waypoint.getCoordinates());
|
|
||||||
Object.defineProperty(marker, '_waypoint', { value: waypoint, writable: true });
|
|
||||||
let dragEndTimestamp = 0;
|
|
||||||
marker.getElement().addEventListener('mousemove', (e) => {
|
|
||||||
if (marker._isDragging) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
waypointPopup?.setItem({ item: marker._waypoint, fileId: this.fileId });
|
|
||||||
e.stopPropagation();
|
|
||||||
});
|
|
||||||
marker.getElement().addEventListener('click', (e) => {
|
|
||||||
if (dragEndTimestamp && Date.now() - dragEndTimestamp < 1000) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (get(currentTool) === Tool.WAYPOINT && e.shiftKey) {
|
|
||||||
deleteWaypoint(this.fileId, marker._waypoint._data.index);
|
|
||||||
e.stopPropagation();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (get(treeFileView)) {
|
|
||||||
if (
|
|
||||||
(e.ctrlKey || e.metaKey) &&
|
|
||||||
get(selection).hasAnyChildren(
|
|
||||||
new ListWaypointsItem(this.fileId),
|
|
||||||
false
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
addSelectItem(
|
|
||||||
new ListWaypointItem(this.fileId, marker._waypoint._data.index)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
selectItem(
|
|
||||||
new ListWaypointItem(this.fileId, marker._waypoint._data.index)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (get(currentTool) === Tool.WAYPOINT) {
|
|
||||||
selectedWaypoint.set([marker._waypoint, this.fileId]);
|
|
||||||
} else {
|
|
||||||
waypointPopup?.setItem({ item: marker._waypoint, fileId: this.fileId });
|
|
||||||
}
|
|
||||||
e.stopPropagation();
|
|
||||||
});
|
|
||||||
marker.on('dragstart', () => {
|
|
||||||
setGrabbingCursor();
|
|
||||||
marker.getElement().style.cursor = 'grabbing';
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
wpt.ele = ele[0];
|
|
||||||
});
|
|
||||||
});
|
|
||||||
dragEndTimestamp = Date.now();
|
|
||||||
});
|
|
||||||
this.markers.push(marker);
|
|
||||||
}
|
|
||||||
markerIndex++;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
while (markerIndex < this.markers.length) {
|
|
||||||
// Remove extra markers
|
|
||||||
this.markers.pop()?.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.markers.forEach((marker) => {
|
|
||||||
if (!marker._waypoint._data.hidden) {
|
|
||||||
marker.addTo(this.map);
|
|
||||||
} else {
|
|
||||||
marker.remove();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
updateMap(map: mapboxgl.Map) {
|
|
||||||
this.map = map;
|
|
||||||
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('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');
|
|
||||||
}
|
|
||||||
if (this.map.getLayer(this.fileId)) {
|
|
||||||
this.map.removeLayer(this.fileId);
|
|
||||||
}
|
|
||||||
if (this.map.getSource(this.fileId)) {
|
|
||||||
this.map.removeSource(this.fileId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.markers.forEach((marker) => {
|
|
||||||
marker.remove();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.unsubscribe.forEach((unsubscribe) => unsubscribe());
|
|
||||||
|
|
||||||
decrementColor(this.layerColor);
|
|
||||||
}
|
|
||||||
|
|
||||||
moveToFront() {
|
|
||||||
if (this.map.getLayer(this.fileId)) {
|
|
||||||
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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
layerOnMouseEnter(e: any) {
|
|
||||||
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)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
setScissorsCursor();
|
|
||||||
} else {
|
|
||||||
setPointerCursor();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
layerOnMouseLeave() {
|
|
||||||
resetCursor();
|
|
||||||
}
|
|
||||||
|
|
||||||
layerOnMouseMove(e: any) {
|
|
||||||
if (e.originalEvent.shiftKey) {
|
|
||||||
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'])
|
|
||||||
) {
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let file = get(this.file)?.file;
|
|
||||||
if (!file) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let item = undefined;
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.originalEvent.ctrlKey || e.originalEvent.metaKey) {
|
|
||||||
addSelectItem(item);
|
|
||||||
} else {
|
|
||||||
selectItem(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
layerOnContextMenu(e: any) {
|
|
||||||
if (e.originalEvent.ctrlKey) {
|
|
||||||
this.layerOnClick(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getGeoJSON(): GeoJSON.FeatureCollection {
|
|
||||||
let file = get(this.file)?.file;
|
|
||||||
if (!file) {
|
|
||||||
return {
|
|
||||||
type: 'FeatureCollection',
|
|
||||||
features: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let data = file.toGeoJSON();
|
|
||||||
|
|
||||||
let trackIndex = 0,
|
|
||||||
segmentIndex = 0;
|
|
||||||
for (let feature of data.features) {
|
|
||||||
if (!feature.properties) {
|
|
||||||
feature.properties = {};
|
|
||||||
}
|
|
||||||
if (!feature.properties.color) {
|
|
||||||
feature.properties.color = this.layerColor;
|
|
||||||
}
|
|
||||||
if (!feature.properties.opacity) {
|
|
||||||
feature.properties.opacity = get(defaultOpacity);
|
|
||||||
}
|
|
||||||
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;
|
|
||||||
feature.properties.segmentIndex = segmentIndex;
|
|
||||||
|
|
||||||
segmentIndex++;
|
|
||||||
if (segmentIndex >= file.trk[trackIndex].trkseg.length) {
|
|
||||||
segmentIndex = 0;
|
|
||||||
trackIndex++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { map, gpxLayers } from '$lib/stores';
|
|
||||||
import { GPXLayer } from './GPXLayer';
|
|
||||||
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;
|
|
||||||
|
|
||||||
$: if ($map && $fileObservers) {
|
|
||||||
// remove layers for deleted files
|
|
||||||
gpxLayers.forEach((layer, fileId) => {
|
|
||||||
if (!$fileObservers.has(fileId)) {
|
|
||||||
layer.remove();
|
|
||||||
gpxLayers.delete(fileId);
|
|
||||||
} else if ($map !== layer.map) {
|
|
||||||
layer.updateMap($map);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// add layers for new files
|
|
||||||
$fileObservers.forEach((file, fileId) => {
|
|
||||||
if (!gpxLayers.has(fileId)) {
|
|
||||||
gpxLayers.set(fileId, new GPXLayer($map, fileId, file));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
$: if ($map) {
|
|
||||||
if (distanceMarkers) {
|
|
||||||
distanceMarkers.remove();
|
|
||||||
}
|
|
||||||
if (startEndMarkers) {
|
|
||||||
startEndMarkers.remove();
|
|
||||||
}
|
|
||||||
createPopups($map);
|
|
||||||
distanceMarkers = new DistanceMarkers($map);
|
|
||||||
startEndMarkers = new StartEndMarkers($map);
|
|
||||||
}
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
gpxLayers.forEach((layer) => layer.remove());
|
|
||||||
gpxLayers.clear();
|
|
||||||
removePopups();
|
|
||||||
if (distanceMarkers) {
|
|
||||||
distanceMarkers.remove();
|
|
||||||
distanceMarkers = undefined;
|
|
||||||
}
|
|
||||||
if (startEndMarkers) {
|
|
||||||
startEndMarkers.remove();
|
|
||||||
startEndMarkers = undefined;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import LayerTreeNode from './LayerTreeNode.svelte';
|
|
||||||
import { type LayerTreeType } from '$lib/assets/layers';
|
|
||||||
import CollapsibleTree from '$lib/components/collapsible-tree/CollapsibleTree.svelte';
|
|
||||||
|
|
||||||
export let layerTree: LayerTreeType;
|
|
||||||
export let name: string;
|
|
||||||
export let selected: string | undefined = undefined;
|
|
||||||
export let multiple: boolean = false;
|
|
||||||
|
|
||||||
export let checked: LayerTreeType = {};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<form>
|
|
||||||
<fieldset class="min-w-64 mb-1">
|
|
||||||
<CollapsibleTree nohover={true}>
|
|
||||||
<LayerTreeNode {name} node={layerTree} bind:selected {multiple} bind:checked />
|
|
||||||
</CollapsibleTree>
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { map } from '$lib/stores';
|
import { map } from '$lib/components/map/map';
|
||||||
import { trackpointPopup } from '$lib/components/gpx-layer/GPXLayerPopup';
|
import { trackpointPopup } from '$lib/components/map/gpx-layer/gpx-layer-popup';
|
||||||
import { TrackPoint } from 'gpx';
|
import { TrackPoint } from 'gpx';
|
||||||
|
|
||||||
$: if ($map) {
|
map.onLoad((map_) => {
|
||||||
$map.on('contextmenu', (e) => {
|
map_.on('contextmenu', (e) => {
|
||||||
trackpointPopup?.setItem({
|
trackpointPopup?.setItem({
|
||||||
item: new TrackPoint({
|
item: new TrackPoint({
|
||||||
attributes: {
|
attributes: {
|
||||||
@@ -14,5 +14,5 @@
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
</script>
|
</script>
|
||||||
239
website/src/lib/components/map/Map.svelte
Normal file
239
website/src/lib/components/map/Map.svelte
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
import mapboxgl from 'mapbox-gl';
|
||||||
|
import 'mapbox-gl/dist/mapbox-gl.css';
|
||||||
|
import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
|
import { PUBLIC_MAPBOX_TOKEN } from '$env/static/public';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { map } from '$lib/components/map/map';
|
||||||
|
|
||||||
|
let {
|
||||||
|
accessToken = PUBLIC_MAPBOX_TOKEN,
|
||||||
|
geolocate = true,
|
||||||
|
geocoder = true,
|
||||||
|
hash = true,
|
||||||
|
class: className = '',
|
||||||
|
}: {
|
||||||
|
accessToken?: string;
|
||||||
|
geolocate?: boolean;
|
||||||
|
geocoder?: boolean;
|
||||||
|
hash?: boolean;
|
||||||
|
class?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
mapboxgl.accessToken = accessToken;
|
||||||
|
|
||||||
|
let webgl2Supported = $state(true);
|
||||||
|
let embeddedApp = $state(false);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
let gl = document.createElement('canvas').getContext('webgl2');
|
||||||
|
if (!gl) {
|
||||||
|
webgl2Supported = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (window.top !== window.self && !page.route.id?.includes('embed')) {
|
||||||
|
embeddedApp = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let language = page.params.language;
|
||||||
|
if (language === 'zh') {
|
||||||
|
language = 'zh-Hans';
|
||||||
|
} else if (language?.includes('-')) {
|
||||||
|
language = language.split('-')[0];
|
||||||
|
} else if (language === '' || language === undefined) {
|
||||||
|
language = 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
map.init(PUBLIC_MAPBOX_TOKEN, language, hash, geocoder, geolocate);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
map.destroy();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={className}>
|
||||||
|
<div id="map" class="h-full {webgl2Supported && !embeddedApp ? '' : 'hidden'}"></div>
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center justify-center gap-3 h-full {webgl2Supported &&
|
||||||
|
!embeddedApp
|
||||||
|
? 'hidden'
|
||||||
|
: ''} {embeddedApp ? 'z-30' : ''}"
|
||||||
|
>
|
||||||
|
{#if !webgl2Supported}
|
||||||
|
<p>{i18n._('webgl2_required')}</p>
|
||||||
|
<Button href="https://get.webgl.org/webgl2/" target="_blank">
|
||||||
|
{i18n._('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>
|
||||||
|
|
||||||
|
<style lang="postcss">
|
||||||
|
@reference "../../../app.css";
|
||||||
|
|
||||||
|
div :global(.mapboxgl-map) {
|
||||||
|
@apply font-sans;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-top-right > .mapboxgl-ctrl) {
|
||||||
|
@apply shadow-md;
|
||||||
|
@apply bg-background;
|
||||||
|
@apply text-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-icon) {
|
||||||
|
@apply dark:brightness-[4.7];
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-geocoder) {
|
||||||
|
@apply flex;
|
||||||
|
@apply flex-row;
|
||||||
|
@apply w-fit;
|
||||||
|
@apply min-w-fit;
|
||||||
|
@apply items-center;
|
||||||
|
@apply shadow-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.suggestions) {
|
||||||
|
@apply shadow-md;
|
||||||
|
@apply bg-background;
|
||||||
|
@apply text-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-geocoder .suggestions > li > a) {
|
||||||
|
@apply text-foreground;
|
||||||
|
@apply hover:text-accent-foreground;
|
||||||
|
@apply hover:bg-accent;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-geocoder .suggestions > .active > a) {
|
||||||
|
@apply bg-background;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-geocoder--button) {
|
||||||
|
@apply bg-transparent;
|
||||||
|
@apply hover:bg-transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-geocoder--icon) {
|
||||||
|
@apply fill-foreground;
|
||||||
|
@apply hover:fill-accent-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-geocoder--icon-search) {
|
||||||
|
@apply relative;
|
||||||
|
@apply top-0;
|
||||||
|
@apply left-0;
|
||||||
|
@apply my-2;
|
||||||
|
@apply w-[29px];
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-geocoder--input) {
|
||||||
|
@apply relative;
|
||||||
|
@apply w-64;
|
||||||
|
@apply py-0;
|
||||||
|
@apply pl-2;
|
||||||
|
@apply focus:outline-none;
|
||||||
|
@apply transition-[width];
|
||||||
|
@apply duration-200;
|
||||||
|
@apply text-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-geocoder--collapsed .mapboxgl-ctrl-geocoder--input) {
|
||||||
|
@apply w-0;
|
||||||
|
@apply p-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-top-right) {
|
||||||
|
@apply z-40;
|
||||||
|
@apply flex;
|
||||||
|
@apply flex-col;
|
||||||
|
@apply items-end;
|
||||||
|
@apply h-full;
|
||||||
|
@apply overflow-hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.horizontal :global(.mapboxgl-ctrl-bottom-left) {
|
||||||
|
@apply bottom-[42px];
|
||||||
|
}
|
||||||
|
|
||||||
|
.horizontal :global(.mapboxgl-ctrl-bottom-right) {
|
||||||
|
@apply bottom-[42px];
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-attrib) {
|
||||||
|
@apply dark:bg-transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-compact-show.mapboxgl-ctrl-attrib) {
|
||||||
|
@apply dark:bg-background;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-attrib-button) {
|
||||||
|
@apply dark:bg-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-compact-show .mapboxgl-ctrl-attrib-button) {
|
||||||
|
@apply dark:bg-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-ctrl-attrib a) {
|
||||||
|
@apply text-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-popup) {
|
||||||
|
@apply w-fit;
|
||||||
|
@apply z-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-popup-content) {
|
||||||
|
@apply p-0;
|
||||||
|
@apply bg-transparent;
|
||||||
|
@apply shadow-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-popup-anchor-top .mapboxgl-popup-tip) {
|
||||||
|
@apply border-b-background;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-popup-anchor-top-left .mapboxgl-popup-tip) {
|
||||||
|
@apply border-b-background;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-popup-anchor-top-right .mapboxgl-popup-tip) {
|
||||||
|
@apply border-b-background;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip) {
|
||||||
|
@apply border-t-background;
|
||||||
|
@apply drop-shadow-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-popup-anchor-bottom-left .mapboxgl-popup-tip) {
|
||||||
|
@apply border-t-background;
|
||||||
|
@apply drop-shadow-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-popup-anchor-bottom-right .mapboxgl-popup-tip) {
|
||||||
|
@apply border-t-background;
|
||||||
|
@apply drop-shadow-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-popup-anchor-left .mapboxgl-popup-tip) {
|
||||||
|
@apply border-r-background;
|
||||||
|
}
|
||||||
|
|
||||||
|
div :global(.mapboxgl-popup-anchor-right .mapboxgl-popup-tip) {
|
||||||
|
@apply border-l-background;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
34
website/src/lib/components/map/MapPopup.svelte
Normal file
34
website/src/lib/components/map/MapPopup.svelte
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { TrackPoint, Waypoint } from 'gpx';
|
||||||
|
import WaypointPopup from '$lib/components/map/gpx-layer/WaypointPopup.svelte';
|
||||||
|
import TrackpointPopup from '$lib/components/map/gpx-layer/TrackpointPopup.svelte';
|
||||||
|
import OverpassPopup from '$lib/components/map/layer-control/OverpassPopup.svelte';
|
||||||
|
import type { PopupItem } from '$lib/components/map/map-popup';
|
||||||
|
import type { Writable } from 'svelte/store';
|
||||||
|
|
||||||
|
let {
|
||||||
|
item,
|
||||||
|
onContainerReady,
|
||||||
|
}: { item: Writable<PopupItem | null>; onContainerReady: (div: HTMLDivElement) => void } =
|
||||||
|
$props();
|
||||||
|
|
||||||
|
let container: HTMLDivElement | null = $state(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (container) {
|
||||||
|
onContainerReady(container);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</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>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import CustomControl from './custom-control';
|
||||||
|
import { map } from '$lib/components/map/map';
|
||||||
|
import { onMount, type Snippet } from 'svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
position = 'top-right',
|
||||||
|
class: className = '',
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
||||||
|
class: string;
|
||||||
|
children: Snippet;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let control: CustomControl | null = null;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
map.onLoad((map: mapboxgl.Map) => {
|
||||||
|
if (position.includes('right')) container.classList.add('float-right');
|
||||||
|
else container.classList.add('float-left');
|
||||||
|
container.classList.remove('hidden');
|
||||||
|
if (control === null) {
|
||||||
|
control = new CustomControl(container);
|
||||||
|
}
|
||||||
|
map.addControl(control, position);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={container}
|
||||||
|
class="{className ||
|
||||||
|
''} clear-both translate-0 m-[10px] mb-0 last:mb-[10px] pointer-events-auto bg-background rounded shadow-md hidden"
|
||||||
|
>
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { ClipboardCopy } from '@lucide/svelte';
|
||||||
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
|
import type { Coordinates } from 'gpx';
|
||||||
|
|
||||||
|
let {
|
||||||
|
coordinates,
|
||||||
|
onCopy = () => {},
|
||||||
|
class: className = '',
|
||||||
|
}: {
|
||||||
|
coordinates: Coordinates;
|
||||||
|
onCopy?: () => void;
|
||||||
|
class?: string;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
class="p-1 has-[>svg]:px-2 h-8 justify-start {className}"
|
||||||
|
variant="outline"
|
||||||
|
onclick={() => {
|
||||||
|
navigator.clipboard.writeText(
|
||||||
|
`${coordinates.lat.toFixed(6)}, ${coordinates.lon.toFixed(6)}`
|
||||||
|
);
|
||||||
|
onCopy();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ClipboardCopy size="16" />
|
||||||
|
{i18n._('menu.copy_coordinates')}
|
||||||
|
</Button>
|
||||||
31
website/src/lib/components/map/gpx-layer/GPXLayers.svelte
Normal file
31
website/src/lib/components/map/gpx-layer/GPXLayers.svelte
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
import { gpxLayers } from '$lib/components/map/gpx-layer/gpx-layers';
|
||||||
|
import { DistanceMarkers } from '$lib/components/map/gpx-layer/distance-markers';
|
||||||
|
import { StartEndMarkers } from '$lib/components/map/gpx-layer/start-end-markers';
|
||||||
|
import { createPopups, removePopups } from '$lib/components/map/gpx-layer/gpx-layer-popup';
|
||||||
|
import { map } from '$lib/components/map/map';
|
||||||
|
|
||||||
|
let distanceMarkers: DistanceMarkers;
|
||||||
|
let startEndMarkers: StartEndMarkers;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
gpxLayers.init();
|
||||||
|
startEndMarkers = new StartEndMarkers();
|
||||||
|
distanceMarkers = new DistanceMarkers();
|
||||||
|
});
|
||||||
|
|
||||||
|
map.onLoad((map_) => {
|
||||||
|
createPopups(map_);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (startEndMarkers) {
|
||||||
|
startEndMarkers.remove();
|
||||||
|
}
|
||||||
|
if (distanceMarkers) {
|
||||||
|
distanceMarkers.remove();
|
||||||
|
}
|
||||||
|
removePopups();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -1,20 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { TrackPoint } from 'gpx';
|
import type { TrackPoint } from 'gpx';
|
||||||
import type { PopupItem } from '$lib/components/MapPopup';
|
import CopyCoordinates from '$lib/components/map/gpx-layer/CopyCoordinates.svelte';
|
||||||
import CopyCoordinates from '$lib/components/gpx-layer/CopyCoordinates.svelte';
|
|
||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
import WithUnits from '$lib/components/WithUnits.svelte';
|
import WithUnits from '$lib/components/WithUnits.svelte';
|
||||||
import { Compass, Mountain, Timer } from 'lucide-svelte';
|
import { Compass, Mountain, Timer } from '@lucide/svelte';
|
||||||
import { df } from '$lib/utils';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import { _ } from 'svelte-i18n';
|
import type { PopupItem } from '$lib/components/map/map-popup';
|
||||||
|
|
||||||
export let trackpoint: PopupItem<TrackPoint>;
|
let { trackpoint }: { trackpoint: PopupItem<TrackPoint> } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card.Root class="border-none shadow-md text-base p-2">
|
<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">
|
<Card.Content class="flex flex-col p-0 text-xs gap-1">
|
||||||
<div class="flex flex-row items-center gap-1">
|
<div class="flex flex-row items-center gap-1">
|
||||||
<Compass size="14" />
|
<Compass size="14" />
|
||||||
@@ -31,7 +27,7 @@
|
|||||||
{#if trackpoint.item.time}
|
{#if trackpoint.item.time}
|
||||||
<div class="flex flex-row items-center gap-1">
|
<div class="flex flex-row items-center gap-1">
|
||||||
<Timer size="14" />
|
<Timer size="14" />
|
||||||
{df.format(trackpoint.item.time)}
|
{i18n.df.format(trackpoint.item.time)}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<CopyCoordinates
|
<CopyCoordinates
|
||||||
@@ -2,21 +2,30 @@
|
|||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import Shortcut from '$lib/components/Shortcut.svelte';
|
import Shortcut from '$lib/components/Shortcut.svelte';
|
||||||
import CopyCoordinates from '$lib/components/gpx-layer/CopyCoordinates.svelte';
|
import CopyCoordinates from '$lib/components/map/gpx-layer/CopyCoordinates.svelte';
|
||||||
import { deleteWaypoint } from './GPXLayerPopup';
|
|
||||||
import WithUnits from '$lib/components/WithUnits.svelte';
|
import WithUnits from '$lib/components/WithUnits.svelte';
|
||||||
import { Dot, ExternalLink, Trash2 } from 'lucide-svelte';
|
import { Dot, ExternalLink, Trash2 } from '@lucide/svelte';
|
||||||
import { Tool, currentTool } from '$lib/stores';
|
import { currentTool, Tool } from '$lib/components/toolbar/tools';
|
||||||
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
||||||
import { _ } from 'svelte-i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import sanitizeHtml from 'sanitize-html';
|
import sanitizeHtml from 'sanitize-html';
|
||||||
import type { Waypoint } from 'gpx';
|
import type { Waypoint } from 'gpx';
|
||||||
import type { PopupItem } from '$lib/components/MapPopup';
|
|
||||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||||
|
import { fileActions } from '$lib/logic/file-actions';
|
||||||
|
import type { PopupItem } from '$lib/components/map/map-popup';
|
||||||
|
import { selection } from '$lib/logic/selection';
|
||||||
|
import { ListFileItem } from '$lib/components/file-list/file-list';
|
||||||
|
|
||||||
export let waypoint: PopupItem<Waypoint>;
|
let {
|
||||||
|
waypoint,
|
||||||
|
}: {
|
||||||
|
waypoint: PopupItem<Waypoint>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
$: symbolKey = waypoint ? getSymbolKey(waypoint.item.sym) : undefined;
|
let selected = $derived(
|
||||||
|
waypoint.fileId ? $selection.hasAnyChildren(new ListFileItem(waypoint.fileId)) : false
|
||||||
|
);
|
||||||
|
let symbolKey = $derived(waypoint ? getSymbolKey(waypoint.item.sym) : undefined);
|
||||||
|
|
||||||
function sanitize(text: string | undefined): string {
|
function sanitize(text: string | undefined): string {
|
||||||
if (text === undefined) {
|
if (text === undefined) {
|
||||||
@@ -32,8 +41,8 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<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] gap-0">
|
||||||
<Card.Header class="p-0">
|
<Card.Header class="p-0 gap-0">
|
||||||
<Card.Title class="text-md">
|
<Card.Title class="text-md">
|
||||||
{#if waypoint.item.link && waypoint.item.link.attributes && waypoint.item.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">
|
<a href={waypoint.item.link.attributes.href} target="_blank">
|
||||||
@@ -41,7 +50,7 @@
|
|||||||
<ExternalLink size="12" class="inline-block mb-1.5" />
|
<ExternalLink size="12" class="inline-block mb-1.5" />
|
||||||
</a>
|
</a>
|
||||||
{:else}
|
{:else}
|
||||||
{waypoint.item.name ?? $_('gpx.waypoint')}
|
{waypoint.item.name ?? i18n._('gpx.waypoint')}
|
||||||
{/if}
|
{/if}
|
||||||
</Card.Title>
|
</Card.Title>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
@@ -50,15 +59,12 @@
|
|||||||
{#if symbolKey}
|
{#if symbolKey}
|
||||||
<span>
|
<span>
|
||||||
{#if symbols[symbolKey].icon}
|
{#if symbols[symbolKey].icon}
|
||||||
<svelte:component
|
{@const Icon = symbols[symbolKey].icon}
|
||||||
this={symbols[symbolKey].icon}
|
<Icon size="12" class="inline-block mb-1" />
|
||||||
size="12"
|
|
||||||
class="inline-block mb-0.5"
|
|
||||||
/>
|
|
||||||
{:else}
|
{:else}
|
||||||
<span class="w-4 inline-block" />
|
<span class="w-4 inline-block"></span>
|
||||||
{/if}
|
{/if}
|
||||||
{$_(`gpx.symbol.${symbolKey}`)}
|
{i18n._(`gpx.symbol.${symbolKey}`)}
|
||||||
</span>
|
</span>
|
||||||
<Dot size="16" />
|
<Dot size="16" />
|
||||||
{/if}
|
{/if}
|
||||||
@@ -70,7 +76,7 @@
|
|||||||
<WithUnits value={waypoint.item.ele} type="elevation" />
|
<WithUnits value={waypoint.item.ele} type="elevation" />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<ScrollArea class="flex flex-col" viewportClasses="max-h-[30dvh]">
|
<ScrollArea class="flex flex-col max-h-[30dvh]">
|
||||||
{#if waypoint.item.desc}
|
{#if waypoint.item.desc}
|
||||||
<span class="whitespace-pre-wrap">{@html sanitize(waypoint.item.desc)}</span>
|
<span class="whitespace-pre-wrap">{@html sanitize(waypoint.item.desc)}</span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -80,14 +86,19 @@
|
|||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
<div class="mt-2 flex flex-col gap-1">
|
<div class="mt-2 flex flex-col gap-1">
|
||||||
<CopyCoordinates coordinates={waypoint.item.attributes} />
|
<CopyCoordinates coordinates={waypoint.item.attributes} />
|
||||||
{#if $currentTool === Tool.WAYPOINT}
|
{#if $currentTool === Tool.WAYPOINT && selected}
|
||||||
<Button
|
<Button
|
||||||
class="w-full px-2 py-1 h-8 justify-start"
|
class="p-1 has-[>svg]:px-2 h-8"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
on:click={() => deleteWaypoint(waypoint.fileId, waypoint.item._data.index)}
|
onclick={() => {
|
||||||
|
if (waypoint.fileId) {
|
||||||
|
fileActions.deleteWaypoint(waypoint.fileId, waypoint.item._data.index);
|
||||||
|
waypoint.hide?.();
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 size="16" class="mr-1" />
|
<Trash2 size="16" />
|
||||||
{$_('menu.delete')}
|
{i18n._('menu.delete')}
|
||||||
<Shortcut shift={true} click={true} />
|
<Shortcut shift={true} click={true} />
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -96,6 +107,8 @@
|
|||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|
||||||
<style lang="postcss">
|
<style lang="postcss">
|
||||||
|
@reference "../../../../app.css";
|
||||||
|
|
||||||
div :global(a) {
|
div :global(a) {
|
||||||
@apply text-link;
|
@apply text-link;
|
||||||
@apply hover:underline;
|
@apply hover:underline;
|
||||||
136
website/src/lib/components/map/gpx-layer/distance-markers.ts
Normal file
136
website/src/lib/components/map/gpx-layer/distance-markers.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { settings } from '$lib/logic/settings';
|
||||||
|
import { gpxStatistics } from '$lib/logic/statistics';
|
||||||
|
import { getConvertedDistanceToKilometers } from '$lib/units';
|
||||||
|
import type { GeoJSONSource } from 'mapbox-gl';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
import { map } from '$lib/components/map/map';
|
||||||
|
import { allHidden } from '$lib/logic/hidden';
|
||||||
|
|
||||||
|
const { distanceMarkers, distanceUnits } = settings;
|
||||||
|
|
||||||
|
const levels = [100, 50, 25, 10, 5, 1];
|
||||||
|
|
||||||
|
export class DistanceMarkers {
|
||||||
|
updateBinded: () => void = this.update.bind(this);
|
||||||
|
unsubscribes: (() => void)[] = [];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.unsubscribes.push(gpxStatistics.subscribe(this.updateBinded));
|
||||||
|
this.unsubscribes.push(distanceMarkers.subscribe(this.updateBinded));
|
||||||
|
this.unsubscribes.push(distanceUnits.subscribe(this.updateBinded));
|
||||||
|
this.unsubscribes.push(allHidden.subscribe(this.updateBinded));
|
||||||
|
this.unsubscribes.push(
|
||||||
|
map.subscribe((map_) => {
|
||||||
|
if (map_) {
|
||||||
|
map_.on('style.import.load', this.updateBinded);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
const map_ = get(map);
|
||||||
|
if (!map_) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (get(distanceMarkers) && !get(allHidden)) {
|
||||||
|
let distanceSource: GeoJSONSource | undefined = map_.getSource('distance-markers');
|
||||||
|
if (distanceSource) {
|
||||||
|
distanceSource.setData(this.getDistanceMarkersGeoJSON());
|
||||||
|
} else {
|
||||||
|
map_.addSource('distance-markers', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: this.getDistanceMarkersGeoJSON(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!map_.getLayer('distance-markers')) {
|
||||||
|
map_.addLayer({
|
||||||
|
id: 'distance-markers',
|
||||||
|
type: 'symbol',
|
||||||
|
source: 'distance-markers',
|
||||||
|
filter: [
|
||||||
|
'match',
|
||||||
|
['get', 'level'],
|
||||||
|
100,
|
||||||
|
['>=', ['zoom'], 0],
|
||||||
|
50,
|
||||||
|
['>=', ['zoom'], 7],
|
||||||
|
25,
|
||||||
|
[
|
||||||
|
'any',
|
||||||
|
['all', ['>=', ['zoom'], 8], ['<=', ['zoom'], 9]],
|
||||||
|
['>=', ['zoom'], 11],
|
||||||
|
],
|
||||||
|
10,
|
||||||
|
['>=', ['zoom'], 10],
|
||||||
|
5,
|
||||||
|
['>=', ['zoom'], 11],
|
||||||
|
1,
|
||||||
|
['>=', ['zoom'], 13],
|
||||||
|
false,
|
||||||
|
],
|
||||||
|
layout: {
|
||||||
|
'text-field': ['get', 'distance'],
|
||||||
|
'text-size': 14,
|
||||||
|
'text-font': ['Open Sans Bold'],
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
'text-color': 'black',
|
||||||
|
'text-halo-width': 2,
|
||||||
|
'text-halo-color': 'white',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
map_.moveLayer('distance-markers');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (map_.getLayer('distance-markers')) {
|
||||||
|
map_.removeLayer('distance-markers');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// No reliable way to check if the map is ready to add sources and layers
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
remove() {
|
||||||
|
this.unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||||
|
}
|
||||||
|
|
||||||
|
getDistanceMarkersGeoJSON(): GeoJSON.FeatureCollection {
|
||||||
|
let statistics = get(gpxStatistics);
|
||||||
|
|
||||||
|
let features = [];
|
||||||
|
let currentTargetDistance = 1;
|
||||||
|
for (let i = 0; i < statistics.local.distance.total.length; i++) {
|
||||||
|
if (
|
||||||
|
statistics.local.distance.total[i] >=
|
||||||
|
getConvertedDistanceToKilometers(currentTargetDistance)
|
||||||
|
) {
|
||||||
|
let distance = currentTargetDistance.toFixed(0);
|
||||||
|
let level = levels.find((level) => currentTargetDistance % level === 0) || 1;
|
||||||
|
features.push({
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: {
|
||||||
|
type: 'Point',
|
||||||
|
coordinates: [
|
||||||
|
statistics.local.points[i].getLongitude(),
|
||||||
|
statistics.local.points[i].getLatitude(),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
distance,
|
||||||
|
level,
|
||||||
|
},
|
||||||
|
} as GeoJSON.Feature);
|
||||||
|
currentTargetDistance += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { dbUtils } from '$lib/db';
|
import { MapPopup } from '$lib/components/map/map-popup';
|
||||||
import { MapPopup } from '$lib/components/MapPopup';
|
|
||||||
|
|
||||||
export let waypointPopup: MapPopup | null = null;
|
export let waypointPopup: MapPopup | null = null;
|
||||||
export let trackpointPopup: MapPopup | null = null;
|
export let trackpointPopup: MapPopup | null = null;
|
||||||
@@ -38,7 +37,3 @@ export function removePopups() {
|
|||||||
trackpointPopup = null;
|
trackpointPopup = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteWaypoint(fileId: string, waypointIndex: number) {
|
|
||||||
dbUtils.applyToFile(fileId, (file) => file.replaceWaypoints(waypointIndex, waypointIndex, []));
|
|
||||||
}
|
|
||||||
759
website/src/lib/components/map/gpx-layer/gpx-layer.ts
Normal file
759
website/src/lib/components/map/gpx-layer/gpx-layer.ts
Normal file
@@ -0,0 +1,759 @@
|
|||||||
|
import { get, type Readable } from 'svelte/store';
|
||||||
|
import mapboxgl from 'mapbox-gl';
|
||||||
|
import { map } from '$lib/components/map/map';
|
||||||
|
import { waypointPopup, trackpointPopup } from './gpx-layer-popup';
|
||||||
|
import {
|
||||||
|
ListTrackSegmentItem,
|
||||||
|
ListWaypointItem,
|
||||||
|
ListWaypointsItem,
|
||||||
|
ListTrackItem,
|
||||||
|
ListFileItem,
|
||||||
|
ListRootItem,
|
||||||
|
} from '$lib/components/file-list/file-list';
|
||||||
|
import { getClosestLinePoint, getElevation } from '$lib/utils';
|
||||||
|
import { selectedWaypoint } from '$lib/components/toolbar/tools/waypoint/waypoint';
|
||||||
|
import { MapPin, Square } from 'lucide-static';
|
||||||
|
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
||||||
|
import type { GPXFileWithStatistics } from '$lib/logic/statistics-tree';
|
||||||
|
import { selection } from '$lib/logic/selection';
|
||||||
|
import { settings } from '$lib/logic/settings';
|
||||||
|
import { currentTool, Tool } from '$lib/components/toolbar/tools';
|
||||||
|
import { fileActionManager } from '$lib/logic/file-action-manager';
|
||||||
|
import { fileActions } from '$lib/logic/file-actions';
|
||||||
|
import { splitAs } from '$lib/components/toolbar/tools/scissors/scissors';
|
||||||
|
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
||||||
|
|
||||||
|
const colors = [
|
||||||
|
'#ff0000',
|
||||||
|
'#0000ff',
|
||||||
|
'#46e646',
|
||||||
|
'#00ccff',
|
||||||
|
'#ff9900',
|
||||||
|
'#ff00ff',
|
||||||
|
'#ffff32',
|
||||||
|
'#288228',
|
||||||
|
'#9933ff',
|
||||||
|
'#50f0be',
|
||||||
|
'#8c645a',
|
||||||
|
];
|
||||||
|
|
||||||
|
const colorCount: { [key: string]: number } = {};
|
||||||
|
for (let color of colors) {
|
||||||
|
colorCount[color] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the color with the least amount of uses
|
||||||
|
function getColor() {
|
||||||
|
let color = colors.reduce((a, b) => (colorCount[a] <= colorCount[b] ? a : b));
|
||||||
|
colorCount[color]++;
|
||||||
|
return color;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decrementColor(color: string) {
|
||||||
|
if (colorCount.hasOwnProperty(color)) {
|
||||||
|
colorCount[color]--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSvgForSymbol(symbol?: string | undefined, layerColor?: string | undefined) {
|
||||||
|
let symbolSvg = symbol ? symbols[symbol]?.iconSvg : undefined;
|
||||||
|
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
${
|
||||||
|
layerColor
|
||||||
|
? Square.replace('width="24"', 'width="12"')
|
||||||
|
.replace('height="24"', 'height="12"')
|
||||||
|
.replace('stroke="currentColor"', 'stroke="SteelBlue"')
|
||||||
|
.replace('stroke-width="2"', 'stroke-width="1.5" x="9.6" y="0.4"')
|
||||||
|
.replace('fill="none"', `fill="${layerColor}"`)
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
${MapPin.replace('width="24"', '')
|
||||||
|
.replace('height="24"', '')
|
||||||
|
.replace('stroke="currentColor"', '')
|
||||||
|
.replace('path', `path fill="#3fb1ce" stroke="SteelBlue" stroke-width="1"`)
|
||||||
|
.replace(
|
||||||
|
'circle',
|
||||||
|
`circle fill="${symbolSvg ? 'none' : 'white'}" stroke="${symbolSvg ? 'none' : 'white'}" stroke-width="2"`
|
||||||
|
)}
|
||||||
|
${
|
||||||
|
symbolSvg
|
||||||
|
?.replace('width="24"', 'width="10"')
|
||||||
|
.replace('height="24"', 'height="10"')
|
||||||
|
.replace('stroke="currentColor"', 'stroke="white"')
|
||||||
|
.replace('stroke-width="2"', 'stroke-width="2.5" x="7" y="5"') ?? ''
|
||||||
|
}
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { directionMarkers, treeFileView, defaultOpacity, defaultWidth } = settings;
|
||||||
|
|
||||||
|
export class GPXLayer {
|
||||||
|
fileId: string;
|
||||||
|
file: Readable<GPXFileWithStatistics | undefined>;
|
||||||
|
layerColor: string;
|
||||||
|
selected: boolean = false;
|
||||||
|
currentWaypointData: GeoJSON.FeatureCollection | null = null;
|
||||||
|
draggedWaypointIndex: number | null = null;
|
||||||
|
draggingStartingPosition: mapboxgl.Point = new mapboxgl.Point(0, 0);
|
||||||
|
unsubscribe: Function[] = [];
|
||||||
|
|
||||||
|
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);
|
||||||
|
layerOnContextMenuBinded: (e: any) => void = this.layerOnContextMenu.bind(this);
|
||||||
|
waypointLayerOnMouseEnterBinded: (e: mapboxgl.MapMouseEvent) => void =
|
||||||
|
this.waypointLayerOnMouseEnter.bind(this);
|
||||||
|
waypointLayerOnMouseLeaveBinded: (e: mapboxgl.MapMouseEvent) => void =
|
||||||
|
this.waypointLayerOnMouseLeave.bind(this);
|
||||||
|
waypointLayerOnClickBinded: (e: mapboxgl.MapMouseEvent) => void =
|
||||||
|
this.waypointLayerOnClick.bind(this);
|
||||||
|
waypointLayerOnMouseDownBinded: (e: mapboxgl.MapMouseEvent) => void =
|
||||||
|
this.waypointLayerOnMouseDown.bind(this);
|
||||||
|
waypointLayerOnTouchStartBinded: (e: mapboxgl.MapTouchEvent) => void =
|
||||||
|
this.waypointLayerOnTouchStart.bind(this);
|
||||||
|
waypointLayerOnMouseMoveBinded: (e: mapboxgl.MapMouseEvent | mapboxgl.MapTouchEvent) => void =
|
||||||
|
this.waypointLayerOnMouseMove.bind(this);
|
||||||
|
waypointLayerOnMouseUpBinded: (e: mapboxgl.MapMouseEvent | mapboxgl.MapTouchEvent) => void =
|
||||||
|
this.waypointLayerOnMouseUp.bind(this);
|
||||||
|
|
||||||
|
constructor(fileId: string, file: Readable<GPXFileWithStatistics | undefined>) {
|
||||||
|
this.fileId = fileId;
|
||||||
|
this.file = file;
|
||||||
|
this.layerColor = getColor();
|
||||||
|
this.unsubscribe.push(
|
||||||
|
map.subscribe(($map) => {
|
||||||
|
if ($map) {
|
||||||
|
$map.on('style.import.load', this.updateBinded);
|
||||||
|
this.update();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
this.unsubscribe.push(file.subscribe(this.updateBinded));
|
||||||
|
this.unsubscribe.push(
|
||||||
|
selection.subscribe(($selection) => {
|
||||||
|
let newSelected = $selection.hasAnyChildren(new ListFileItem(this.fileId));
|
||||||
|
if (this.selected || newSelected) {
|
||||||
|
this.selected = newSelected;
|
||||||
|
this.update();
|
||||||
|
}
|
||||||
|
if (newSelected) {
|
||||||
|
this.moveToFront();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
this.unsubscribe.push(directionMarkers.subscribe(this.updateBinded));
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
const _map = get(map);
|
||||||
|
let file = get(this.file)?.file;
|
||||||
|
if (!_map || !file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loadIcons();
|
||||||
|
|
||||||
|
if (
|
||||||
|
file._data.style &&
|
||||||
|
file._data.style.color &&
|
||||||
|
this.layerColor !== `#${file._data.style.color}`
|
||||||
|
) {
|
||||||
|
decrementColor(this.layerColor);
|
||||||
|
this.layerColor = `#${file._data.style.color}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let source = _map.getSource(this.fileId) as mapboxgl.GeoJSONSource | undefined;
|
||||||
|
if (source) {
|
||||||
|
source.setData(this.getGeoJSON());
|
||||||
|
} else {
|
||||||
|
_map.addSource(this.fileId, {
|
||||||
|
type: 'geojson',
|
||||||
|
data: this.getGeoJSON(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_map.getLayer(this.fileId)) {
|
||||||
|
_map.addLayer({
|
||||||
|
id: this.fileId,
|
||||||
|
type: 'line',
|
||||||
|
source: this.fileId,
|
||||||
|
layout: {
|
||||||
|
'line-join': 'round',
|
||||||
|
'line-cap': 'round',
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
'line-color': ['get', 'color'],
|
||||||
|
'line-width': ['get', 'width'],
|
||||||
|
'line-opacity': ['get', 'opacity'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
_map.on('click', this.fileId, this.layerOnClickBinded);
|
||||||
|
_map.on('contextmenu', this.fileId, this.layerOnContextMenuBinded);
|
||||||
|
_map.on('mouseenter', this.fileId, this.layerOnMouseEnterBinded);
|
||||||
|
_map.on('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
|
||||||
|
_map.on('mousemove', this.fileId, this.layerOnMouseMoveBinded);
|
||||||
|
}
|
||||||
|
|
||||||
|
let waypointSource = _map.getSource(this.fileId + '-waypoints') as
|
||||||
|
| mapboxgl.GeoJSONSource
|
||||||
|
| undefined;
|
||||||
|
this.currentWaypointData = this.getWaypointsGeoJSON();
|
||||||
|
if (waypointSource) {
|
||||||
|
waypointSource.setData(this.currentWaypointData);
|
||||||
|
} else {
|
||||||
|
_map.addSource(this.fileId + '-waypoints', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: this.currentWaypointData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_map.getLayer(this.fileId + '-waypoints')) {
|
||||||
|
_map.addLayer({
|
||||||
|
id: this.fileId + '-waypoints',
|
||||||
|
type: 'symbol',
|
||||||
|
source: this.fileId + '-waypoints',
|
||||||
|
layout: {
|
||||||
|
'icon-image': ['get', 'icon'],
|
||||||
|
'icon-size': 0.3,
|
||||||
|
'icon-anchor': 'bottom',
|
||||||
|
'icon-padding': 0,
|
||||||
|
'icon-allow-overlap': true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
_map.on(
|
||||||
|
'mouseenter',
|
||||||
|
this.fileId + '-waypoints',
|
||||||
|
this.waypointLayerOnMouseEnterBinded
|
||||||
|
);
|
||||||
|
_map.on(
|
||||||
|
'mouseleave',
|
||||||
|
this.fileId + '-waypoints',
|
||||||
|
this.waypointLayerOnMouseLeaveBinded
|
||||||
|
);
|
||||||
|
_map.on('click', this.fileId + '-waypoints', this.waypointLayerOnClickBinded);
|
||||||
|
_map.on(
|
||||||
|
'mousedown',
|
||||||
|
this.fileId + '-waypoints',
|
||||||
|
this.waypointLayerOnMouseDownBinded
|
||||||
|
);
|
||||||
|
_map.on(
|
||||||
|
'touchstart',
|
||||||
|
this.fileId + '-waypoints',
|
||||||
|
this.waypointLayerOnTouchStartBinded
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (get(directionMarkers)) {
|
||||||
|
if (!_map.getLayer(this.fileId + '-direction')) {
|
||||||
|
_map.addLayer(
|
||||||
|
{
|
||||||
|
id: this.fileId + '-direction',
|
||||||
|
type: 'symbol',
|
||||||
|
source: this.fileId,
|
||||||
|
layout: {
|
||||||
|
'text-field': '»',
|
||||||
|
'text-offset': [0, -0.1],
|
||||||
|
'text-keep-upright': false,
|
||||||
|
'text-max-angle': 361,
|
||||||
|
'text-allow-overlap': true,
|
||||||
|
'text-font': ['Open Sans Bold'],
|
||||||
|
'symbol-placement': 'line',
|
||||||
|
'symbol-spacing': 20,
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
'text-color': 'white',
|
||||||
|
'text-opacity': 0.7,
|
||||||
|
'text-halo-width': 0.2,
|
||||||
|
'text-halo-color': 'white',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_map.getLayer('distance-markers') ? 'distance-markers' : undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (_map.getLayer(this.fileId + '-direction')) {
|
||||||
|
_map.removeLayer(this.fileId + '-direction');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let visibleSegments: [number, number][] = [];
|
||||||
|
file.forEachSegment((segment, trackIndex, segmentIndex) => {
|
||||||
|
if (!segment._data.hidden) {
|
||||||
|
visibleSegments.push([trackIndex, segmentIndex]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_map.setFilter(
|
||||||
|
this.fileId,
|
||||||
|
[
|
||||||
|
'any',
|
||||||
|
...visibleSegments.map(([trackIndex, segmentIndex]) => [
|
||||||
|
'all',
|
||||||
|
['==', 'trackIndex', trackIndex],
|
||||||
|
['==', 'segmentIndex', segmentIndex],
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
{ validate: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
let visibleWaypoints: number[] = [];
|
||||||
|
file.wpt.forEach((waypoint, waypointIndex) => {
|
||||||
|
if (!waypoint._data.hidden) {
|
||||||
|
visibleWaypoints.push(waypointIndex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_map.setFilter(
|
||||||
|
this.fileId + '-waypoints',
|
||||||
|
['in', ['get', 'waypointIndex'], ['literal', visibleWaypoints]],
|
||||||
|
{ validate: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (_map.getLayer(this.fileId + '-direction')) {
|
||||||
|
_map.setFilter(
|
||||||
|
this.fileId + '-direction',
|
||||||
|
[
|
||||||
|
'any',
|
||||||
|
...visibleSegments.map(([trackIndex, segmentIndex]) => [
|
||||||
|
'all',
|
||||||
|
['==', 'trackIndex', trackIndex],
|
||||||
|
['==', 'segmentIndex', segmentIndex],
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
{ validate: false }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// No reliable way to check if the map is ready to add sources and layers
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
remove() {
|
||||||
|
const _map = get(map);
|
||||||
|
if (_map) {
|
||||||
|
_map.off('click', this.fileId, this.layerOnClickBinded);
|
||||||
|
_map.off('contextmenu', this.fileId, this.layerOnContextMenuBinded);
|
||||||
|
_map.off('mouseenter', this.fileId, this.layerOnMouseEnterBinded);
|
||||||
|
_map.off('mouseleave', this.fileId, this.layerOnMouseLeaveBinded);
|
||||||
|
_map.off('mousemove', this.fileId, this.layerOnMouseMoveBinded);
|
||||||
|
_map.off('style.import.load', this.updateBinded);
|
||||||
|
|
||||||
|
_map.off(
|
||||||
|
'mouseenter',
|
||||||
|
this.fileId + '-waypoints',
|
||||||
|
this.waypointLayerOnMouseEnterBinded
|
||||||
|
);
|
||||||
|
_map.off(
|
||||||
|
'mouseleave',
|
||||||
|
this.fileId + '-waypoints',
|
||||||
|
this.waypointLayerOnMouseLeaveBinded
|
||||||
|
);
|
||||||
|
_map.off('click', this.fileId + '-waypoints', this.waypointLayerOnClickBinded);
|
||||||
|
_map.off('mousedown', this.fileId + '-waypoints', this.waypointLayerOnMouseDownBinded);
|
||||||
|
_map.off(
|
||||||
|
'touchstart',
|
||||||
|
this.fileId + '-waypoints',
|
||||||
|
this.waypointLayerOnTouchStartBinded
|
||||||
|
);
|
||||||
|
|
||||||
|
if (_map.getLayer(this.fileId + '-direction')) {
|
||||||
|
_map.removeLayer(this.fileId + '-direction');
|
||||||
|
}
|
||||||
|
if (_map.getLayer(this.fileId)) {
|
||||||
|
_map.removeLayer(this.fileId);
|
||||||
|
}
|
||||||
|
if (_map.getSource(this.fileId)) {
|
||||||
|
_map.removeSource(this.fileId);
|
||||||
|
}
|
||||||
|
if (_map.getLayer(this.fileId + '-waypoints')) {
|
||||||
|
_map.removeLayer(this.fileId + '-waypoints');
|
||||||
|
}
|
||||||
|
if (_map.getSource(this.fileId + '-waypoints')) {
|
||||||
|
_map.removeSource(this.fileId + '-waypoints');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.unsubscribe.forEach((unsubscribe) => unsubscribe());
|
||||||
|
|
||||||
|
decrementColor(this.layerColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
moveToFront() {
|
||||||
|
const _map = get(map);
|
||||||
|
if (!_map) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_map.getLayer(this.fileId)) {
|
||||||
|
_map.moveLayer(this.fileId);
|
||||||
|
}
|
||||||
|
if (_map.getLayer(this.fileId + '-waypoints')) {
|
||||||
|
_map.moveLayer(this.fileId + '-waypoints');
|
||||||
|
}
|
||||||
|
if (_map.getLayer(this.fileId + '-direction')) {
|
||||||
|
_map.moveLayer(this.fileId + '-direction');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
layerOnMouseEnter(e: any) {
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
mapCursor.notify(MapCursorState.SCISSORS, true);
|
||||||
|
} else {
|
||||||
|
mapCursor.notify(MapCursorState.LAYER_HOVER, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
layerOnMouseLeave() {
|
||||||
|
mapCursor.notify(MapCursorState.SCISSORS, false);
|
||||||
|
mapCursor.notify(MapCursorState.LAYER_HOVER, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
layerOnMouseMove(e: any) {
|
||||||
|
if (e.originalEvent.shiftKey) {
|
||||||
|
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: mapboxgl.MapMouseEvent) {
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
if (get(map)?.queryRenderedFeatures(e.point, { layers: ['split-controls'] }).length) {
|
||||||
|
// Clicked on split control, ignoring
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fileActions.split(get(splitAs), this.fileId, trackIndex, segmentIndex, {
|
||||||
|
lat: e.lngLat.lat,
|
||||||
|
lon: e.lngLat.lng,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let file = get(this.file)?.file;
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let item = undefined;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.originalEvent.ctrlKey || e.originalEvent.metaKey) {
|
||||||
|
selection.addSelectItem(item);
|
||||||
|
} else {
|
||||||
|
selection.selectItem(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
layerOnContextMenu(e: any) {
|
||||||
|
if (e.originalEvent.ctrlKey) {
|
||||||
|
this.layerOnClick(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
waypointLayerOnMouseEnter(e: mapboxgl.MapMouseEvent) {
|
||||||
|
if (this.draggedWaypointIndex !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let file = get(this.file)?.file;
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let waypointIndex = e.features![0].properties!.waypointIndex;
|
||||||
|
let waypoint = file.wpt[waypointIndex];
|
||||||
|
waypointPopup?.setItem({ item: waypoint, fileId: this.fileId });
|
||||||
|
|
||||||
|
mapCursor.notify(MapCursorState.WAYPOINT_HOVER, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
waypointLayerOnMouseLeave() {
|
||||||
|
mapCursor.notify(MapCursorState.WAYPOINT_HOVER, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
waypointLayerOnClick(e: mapboxgl.MapMouseEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
let waypointIndex = e.features![0].properties!.waypointIndex;
|
||||||
|
let file = get(this.file)?.file;
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let waypoint = file.wpt[waypointIndex];
|
||||||
|
if (get(currentTool) === Tool.WAYPOINT) {
|
||||||
|
if (this.selected) {
|
||||||
|
if (e.originalEvent.shiftKey) {
|
||||||
|
fileActions.deleteWaypoint(this.fileId, waypointIndex);
|
||||||
|
} else {
|
||||||
|
selection.selectItem(new ListWaypointItem(this.fileId, waypointIndex));
|
||||||
|
selectedWaypoint.set([waypoint, this.fileId]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (get(treeFileView)) {
|
||||||
|
selection.selectItem(new ListWaypointItem(this.fileId, waypointIndex));
|
||||||
|
} else {
|
||||||
|
selection.selectItem(new ListFileItem(this.fileId));
|
||||||
|
}
|
||||||
|
selectedWaypoint.set([waypoint, this.fileId]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (get(treeFileView)) {
|
||||||
|
if ((e.originalEvent.ctrlKey || e.originalEvent.metaKey) && this.selected) {
|
||||||
|
selection.addSelectItem(new ListWaypointItem(this.fileId, waypointIndex));
|
||||||
|
} else {
|
||||||
|
selection.selectItem(new ListWaypointItem(this.fileId, waypointIndex));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!this.selected) {
|
||||||
|
selection.selectItem(new ListFileItem(this.fileId));
|
||||||
|
}
|
||||||
|
waypointPopup?.setItem({ item: waypoint, fileId: this.fileId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
waypointLayerOnMouseDown(e: mapboxgl.MapMouseEvent) {
|
||||||
|
if (get(currentTool) !== Tool.WAYPOINT || !this.selected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const _map = get(map);
|
||||||
|
if (!_map) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
this.draggedWaypointIndex = e.features![0].properties!.waypointIndex;
|
||||||
|
this.draggingStartingPosition = e.point;
|
||||||
|
waypointPopup?.hide();
|
||||||
|
|
||||||
|
_map.on('mousemove', this.waypointLayerOnMouseMoveBinded);
|
||||||
|
_map.once('mouseup', this.waypointLayerOnMouseUpBinded);
|
||||||
|
}
|
||||||
|
|
||||||
|
waypointLayerOnTouchStart(e: mapboxgl.MapTouchEvent) {
|
||||||
|
if (e.points.length !== 1 || get(currentTool) !== Tool.WAYPOINT || !this.selected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const _map = get(map);
|
||||||
|
if (!_map) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.draggedWaypointIndex = e.features![0].properties!.waypointIndex;
|
||||||
|
this.draggingStartingPosition = e.point;
|
||||||
|
waypointPopup?.hide();
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
_map.on('touchmove', this.waypointLayerOnMouseMoveBinded);
|
||||||
|
_map.once('touchend', this.waypointLayerOnMouseUpBinded);
|
||||||
|
}
|
||||||
|
|
||||||
|
waypointLayerOnMouseMove(e: mapboxgl.MapMouseEvent | mapboxgl.MapTouchEvent) {
|
||||||
|
if (this.draggedWaypointIndex === null || e.point.equals(this.draggingStartingPosition)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mapCursor.notify(MapCursorState.WAYPOINT_DRAGGING, true);
|
||||||
|
|
||||||
|
(
|
||||||
|
this.currentWaypointData!.features[this.draggedWaypointIndex].geometry as GeoJSON.Point
|
||||||
|
).coordinates = [e.lngLat.lng, e.lngLat.lat];
|
||||||
|
|
||||||
|
let waypointSource = get(map)?.getSource(this.fileId + '-waypoints') as
|
||||||
|
| mapboxgl.GeoJSONSource
|
||||||
|
| undefined;
|
||||||
|
if (waypointSource) {
|
||||||
|
waypointSource.setData(this.currentWaypointData!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
waypointLayerOnMouseUp(e: mapboxgl.MapMouseEvent | mapboxgl.MapTouchEvent) {
|
||||||
|
mapCursor.notify(MapCursorState.WAYPOINT_DRAGGING, false);
|
||||||
|
|
||||||
|
get(map)?.off('mousemove', this.waypointLayerOnMouseMoveBinded);
|
||||||
|
get(map)?.off('touchmove', this.waypointLayerOnMouseMoveBinded);
|
||||||
|
|
||||||
|
if (this.draggedWaypointIndex === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.point.equals(this.draggingStartingPosition)) {
|
||||||
|
this.draggedWaypointIndex = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
getElevation([
|
||||||
|
{
|
||||||
|
lat: e.lngLat.lat,
|
||||||
|
lon: e.lngLat.lng,
|
||||||
|
},
|
||||||
|
]).then((ele) => {
|
||||||
|
if (this.draggedWaypointIndex === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fileActionManager.applyToFile(this.fileId, (file) => {
|
||||||
|
let wpt = file.wpt[this.draggedWaypointIndex!];
|
||||||
|
wpt.setCoordinates({
|
||||||
|
lat: e.lngLat.lat,
|
||||||
|
lon: e.lngLat.lng,
|
||||||
|
});
|
||||||
|
wpt.ele = ele[0];
|
||||||
|
});
|
||||||
|
this.draggedWaypointIndex = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getGeoJSON(): GeoJSON.FeatureCollection {
|
||||||
|
let file = get(this.file)?.file;
|
||||||
|
if (!file) {
|
||||||
|
return {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = file.toGeoJSON();
|
||||||
|
|
||||||
|
let trackIndex = 0,
|
||||||
|
segmentIndex = 0;
|
||||||
|
for (let feature of data.features) {
|
||||||
|
if (!feature.properties) {
|
||||||
|
feature.properties = {};
|
||||||
|
}
|
||||||
|
if (!feature.properties.color) {
|
||||||
|
feature.properties.color = this.layerColor;
|
||||||
|
}
|
||||||
|
if (!feature.properties.opacity) {
|
||||||
|
feature.properties.opacity = get(defaultOpacity);
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
feature.properties.segmentIndex = segmentIndex;
|
||||||
|
|
||||||
|
segmentIndex++;
|
||||||
|
if (segmentIndex >= file.trk[trackIndex].trkseg.length) {
|
||||||
|
segmentIndex = 0;
|
||||||
|
trackIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
getWaypointsGeoJSON(): GeoJSON.FeatureCollection {
|
||||||
|
let file = get(this.file)?.file;
|
||||||
|
|
||||||
|
let data: GeoJSON.FeatureCollection = {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
file.wpt.forEach((waypoint, index) => {
|
||||||
|
data.features.push({
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: {
|
||||||
|
type: 'Point',
|
||||||
|
coordinates: [waypoint.getLongitude(), waypoint.getLatitude()],
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
fileId: this.fileId,
|
||||||
|
waypointIndex: index,
|
||||||
|
icon: `${this.fileId}-waypoint-${getSymbolKey(waypoint.sym) ?? 'default'}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadIcons() {
|
||||||
|
const _map = get(map);
|
||||||
|
let file = get(this.file)?.file;
|
||||||
|
if (!_map || !file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let symbols = new Set<string | undefined>();
|
||||||
|
file.wpt.forEach((waypoint) => {
|
||||||
|
symbols.add(getSymbolKey(waypoint.sym));
|
||||||
|
});
|
||||||
|
|
||||||
|
symbols.forEach((symbol) => {
|
||||||
|
const iconId = `${this.fileId}-waypoint-${symbol ?? 'default'}`;
|
||||||
|
if (!_map.hasImage(iconId)) {
|
||||||
|
let icon = new Image(100, 100);
|
||||||
|
icon.onload = () => {
|
||||||
|
if (!_map.hasImage(iconId)) {
|
||||||
|
_map.addImage(iconId, icon);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lucide icons are SVG files with a 24x24 viewBox
|
||||||
|
// Create a new SVG with a 32x32 viewBox and center the icon in a circle
|
||||||
|
icon.src =
|
||||||
|
'data:image/svg+xml,' +
|
||||||
|
encodeURIComponent(getSvgForSymbol(symbol, this.layerColor));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
44
website/src/lib/components/map/gpx-layer/gpx-layers.ts
Normal file
44
website/src/lib/components/map/gpx-layer/gpx-layers.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { GPXFileStateCollectionObserver } from '$lib/logic/file-state';
|
||||||
|
import { GPXLayer } from './gpx-layer';
|
||||||
|
|
||||||
|
export class GPXLayerCollection {
|
||||||
|
private _layers: Map<string, GPXLayer>;
|
||||||
|
private _fileStateCollectionObserver: GPXFileStateCollectionObserver | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this._layers = new Map<string, GPXLayer>();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
if (this._fileStateCollectionObserver) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._fileStateCollectionObserver = new GPXFileStateCollectionObserver(
|
||||||
|
(newFiles) => {
|
||||||
|
newFiles.forEach((fileState, fileId) => {
|
||||||
|
const layer = new GPXLayer(fileId, fileState);
|
||||||
|
this._layers.set(fileId, layer);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
(fileId) => {
|
||||||
|
const layer = this._layers.get(fileId);
|
||||||
|
if (layer) {
|
||||||
|
layer.remove();
|
||||||
|
this._layers.delete(fileId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
this._layers.forEach((layer) => {
|
||||||
|
layer.remove();
|
||||||
|
});
|
||||||
|
this._layers.clear();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getLayer(fileId: string): GPXLayer | undefined {
|
||||||
|
return this._layers.get(fileId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const gpxLayers = new GPXLayerCollection();
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
import { gpxStatistics, slicedGPXStatistics, currentTool, Tool } from '$lib/stores';
|
import { currentTool, Tool } from '$lib/components/toolbar/tools';
|
||||||
|
import { gpxStatistics, slicedGPXStatistics } from '$lib/logic/statistics';
|
||||||
import mapboxgl from 'mapbox-gl';
|
import mapboxgl from 'mapbox-gl';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
|
import { map } from '$lib/components/map/map';
|
||||||
|
import { allHidden } from '$lib/logic/hidden';
|
||||||
|
|
||||||
export class StartEndMarkers {
|
export class StartEndMarkers {
|
||||||
map: mapboxgl.Map;
|
|
||||||
start: mapboxgl.Marker;
|
start: mapboxgl.Marker;
|
||||||
end: mapboxgl.Marker;
|
end: mapboxgl.Marker;
|
||||||
updateBinded: () => void = this.update.bind(this);
|
updateBinded: () => void = this.update.bind(this);
|
||||||
unsubscribes: (() => void)[] = [];
|
unsubscribes: (() => void)[] = [];
|
||||||
|
|
||||||
constructor(map: mapboxgl.Map) {
|
constructor() {
|
||||||
this.map = map;
|
|
||||||
|
|
||||||
let startElement = document.createElement('div');
|
let startElement = document.createElement('div');
|
||||||
let endElement = document.createElement('div');
|
let endElement = document.createElement('div');
|
||||||
startElement.className = `h-4 w-4 rounded-full bg-green-500 border-2 border-white`;
|
startElement.className = `h-4 w-4 rounded-full bg-green-500 border-2 border-white`;
|
||||||
@@ -22,21 +22,27 @@ export class StartEndMarkers {
|
|||||||
this.start = new mapboxgl.Marker({ element: startElement });
|
this.start = new mapboxgl.Marker({ element: startElement });
|
||||||
this.end = new mapboxgl.Marker({ element: endElement });
|
this.end = new mapboxgl.Marker({ element: endElement });
|
||||||
|
|
||||||
|
map.onLoad(() => this.update());
|
||||||
this.unsubscribes.push(gpxStatistics.subscribe(this.updateBinded));
|
this.unsubscribes.push(gpxStatistics.subscribe(this.updateBinded));
|
||||||
this.unsubscribes.push(slicedGPXStatistics.subscribe(this.updateBinded));
|
this.unsubscribes.push(slicedGPXStatistics.subscribe(this.updateBinded));
|
||||||
this.unsubscribes.push(currentTool.subscribe(this.updateBinded));
|
this.unsubscribes.push(currentTool.subscribe(this.updateBinded));
|
||||||
|
this.unsubscribes.push(allHidden.subscribe(this.updateBinded));
|
||||||
}
|
}
|
||||||
|
|
||||||
update() {
|
update() {
|
||||||
let tool = get(currentTool);
|
const map_ = get(map);
|
||||||
let statistics = get(slicedGPXStatistics)?.[0] ?? get(gpxStatistics);
|
if (!map_) return;
|
||||||
if (statistics.local.points.length > 0 && tool !== Tool.ROUTING) {
|
|
||||||
this.start.setLngLat(statistics.local.points[0].getCoordinates()).addTo(this.map);
|
const tool = get(currentTool);
|
||||||
|
const statistics = get(slicedGPXStatistics)?.[0] ?? get(gpxStatistics);
|
||||||
|
const hidden = get(allHidden);
|
||||||
|
if (statistics.local.points.length > 0 && tool !== Tool.ROUTING && !hidden) {
|
||||||
|
this.start.setLngLat(statistics.local.points[0].getCoordinates()).addTo(map_);
|
||||||
this.end
|
this.end
|
||||||
.setLngLat(
|
.setLngLat(
|
||||||
statistics.local.points[statistics.local.points.length - 1].getCoordinates()
|
statistics.local.points[statistics.local.points.length - 1].getCoordinates()
|
||||||
)
|
)
|
||||||
.addTo(this.map);
|
.addTo(map_);
|
||||||
} else {
|
} else {
|
||||||
this.start.remove();
|
this.start.remove();
|
||||||
this.end.remove();
|
this.end.remove();
|
||||||
@@ -16,14 +16,14 @@
|
|||||||
Move,
|
Move,
|
||||||
Map,
|
Map,
|
||||||
Layers2,
|
Layers2,
|
||||||
} from 'lucide-svelte';
|
} from '@lucide/svelte';
|
||||||
import { _ } from 'svelte-i18n';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import { settings } from '$lib/db';
|
|
||||||
import { defaultBasemap, type CustomLayer } from '$lib/assets/layers';
|
import { defaultBasemap, type CustomLayer } from '$lib/assets/layers';
|
||||||
import { map } from '$lib/stores';
|
import { onMount } from 'svelte';
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { customBasemapUpdate, isSelected, remove } from './utils';
|
||||||
import Sortable from 'sortablejs/Sortable';
|
import { settings } from '$lib/logic/settings';
|
||||||
import { customBasemapUpdate } from './utils';
|
import { map } from '$lib/components/map/map';
|
||||||
|
import { dndzone } from 'svelte-dnd-action';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
customLayers,
|
customLayers,
|
||||||
@@ -37,17 +37,23 @@
|
|||||||
customOverlayOrder,
|
customOverlayOrder,
|
||||||
} = settings;
|
} = settings;
|
||||||
|
|
||||||
let name: string = '';
|
let name: string = $state('');
|
||||||
let tileUrls: string[] = [''];
|
let tileUrls: string[] = $state(['']);
|
||||||
let maxZoom: number = 20;
|
let maxZoom: number = $state(20);
|
||||||
let layerType: 'basemap' | 'overlay' = 'basemap';
|
let layerType: 'basemap' | 'overlay' = $state('basemap');
|
||||||
let resourceType: 'raster' | 'vector' = 'raster';
|
let resourceType: 'raster' | 'vector' = $derived.by(() => {
|
||||||
|
if (tileUrls[0].length > 0) {
|
||||||
|
if (
|
||||||
|
tileUrls[0].includes('.json') ||
|
||||||
|
(tileUrls[0].includes('api.mapbox.com/styles') && !tileUrls[0].includes('tiles'))
|
||||||
|
) {
|
||||||
|
return 'vector';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'raster';
|
||||||
|
});
|
||||||
|
|
||||||
let basemapContainer: HTMLElement;
|
let selectedLayerId: string | undefined = $state(undefined);
|
||||||
let overlayContainer: HTMLElement;
|
|
||||||
|
|
||||||
let basemapSortable: Sortable;
|
|
||||||
let overlaySortable: Sortable;
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if ($customBasemapOrder.length === 0) {
|
if ($customBasemapOrder.length === 0) {
|
||||||
@@ -60,45 +66,30 @@
|
|||||||
(id) => $customLayers[id].layerType === 'overlay'
|
(id) => $customLayers[id].layerType === 'overlay'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
basemapSortable = Sortable.create(basemapContainer, {
|
|
||||||
onSort: (e) => {
|
|
||||||
$customBasemapOrder = basemapSortable.toArray();
|
|
||||||
$selectedBasemapTree.basemaps['custom'] = $customBasemapOrder.reduce((acc, id) => {
|
|
||||||
acc[id] = true;
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
overlaySortable = Sortable.create(overlayContainer, {
|
|
||||||
onSort: (e) => {
|
|
||||||
$customOverlayOrder = overlaySortable.toArray();
|
|
||||||
$selectedOverlayTree.overlays['custom'] = $customOverlayOrder.reduce((acc, id) => {
|
|
||||||
acc[id] = true;
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
basemapSortable.sort($customBasemapOrder);
|
|
||||||
overlaySortable.sort($customOverlayOrder);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
let customBasemapItems: {
|
||||||
basemapSortable.destroy();
|
id: string;
|
||||||
overlaySortable.destroy();
|
name: string;
|
||||||
});
|
}[] = $derived(
|
||||||
|
$customBasemapOrder.map((id) => ({
|
||||||
|
id: id,
|
||||||
|
name: $customLayers[id].name,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
let customOverlayItems: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}[] = $derived(
|
||||||
|
$customOverlayOrder.map((id) => ({
|
||||||
|
id: id,
|
||||||
|
name: $customLayers[id].name,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
$: if (tileUrls[0].length > 0) {
|
$effect(() => {
|
||||||
if (
|
setDataFromSelectedLayer(selectedLayerId);
|
||||||
tileUrls[0].includes('.json') ||
|
});
|
||||||
(tileUrls[0].includes('api.mapbox.com/styles') && !tileUrls[0].includes('tiles'))
|
|
||||||
) {
|
|
||||||
resourceType = 'vector';
|
|
||||||
} else {
|
|
||||||
resourceType = 'raster';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createLayer() {
|
function createLayer() {
|
||||||
if (selectedLayerId && $customLayers[selectedLayerId].layerType !== layerType) {
|
if (selectedLayerId && $customLayers[selectedLayerId].layerType !== layerType) {
|
||||||
@@ -185,11 +176,7 @@
|
|||||||
return $tree;
|
return $tree;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if ($map && $currentOverlays && isSelected($currentOverlays, layerId)) {
|
||||||
$currentOverlays.overlays['custom'] &&
|
|
||||||
$currentOverlays.overlays['custom'][layerId] &&
|
|
||||||
$map
|
|
||||||
) {
|
|
||||||
try {
|
try {
|
||||||
$map.removeImport(layerId);
|
$map.removeImport(layerId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -197,10 +184,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$currentOverlays.overlays.hasOwnProperty('custom')) {
|
currentOverlays.update(($overlays) => {
|
||||||
$currentOverlays.overlays['custom'] = {};
|
if (!$overlays.overlays.hasOwnProperty('custom')) {
|
||||||
}
|
$overlays.overlays['custom'] = {};
|
||||||
$currentOverlays.overlays['custom'][layerId] = true;
|
}
|
||||||
|
$overlays.overlays['custom'][layerId] = true;
|
||||||
|
return $overlays;
|
||||||
|
});
|
||||||
|
|
||||||
if (!$customOverlayOrder.includes(layerId)) {
|
if (!$customOverlayOrder.includes(layerId)) {
|
||||||
$customOverlayOrder = [...$customOverlayOrder, layerId];
|
$customOverlayOrder = [...$customOverlayOrder, layerId];
|
||||||
@@ -225,58 +215,22 @@
|
|||||||
$previousBasemap = defaultBasemap;
|
$previousBasemap = defaultBasemap;
|
||||||
}
|
}
|
||||||
|
|
||||||
$selectedBasemapTree.basemaps['custom'] = tryDeleteLayer(
|
$selectedBasemapTree = remove($selectedBasemapTree, layerId);
|
||||||
$selectedBasemapTree.basemaps['custom'],
|
|
||||||
layerId
|
|
||||||
);
|
|
||||||
if (Object.keys($selectedBasemapTree.basemaps['custom']).length === 0) {
|
|
||||||
$selectedBasemapTree.basemaps = tryDeleteLayer(
|
|
||||||
$selectedBasemapTree.basemaps,
|
|
||||||
'custom'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
$customBasemapOrder = $customBasemapOrder.filter((id) => id !== layerId);
|
$customBasemapOrder = $customBasemapOrder.filter((id) => id !== layerId);
|
||||||
} else {
|
} else {
|
||||||
$currentOverlays.overlays['custom'][layerId] = false;
|
if ($currentOverlays) {
|
||||||
if ($previousOverlays.overlays['custom']) {
|
$currentOverlays = remove($currentOverlays, layerId);
|
||||||
$previousOverlays.overlays['custom'] = tryDeleteLayer(
|
|
||||||
$previousOverlays.overlays['custom'],
|
|
||||||
layerId
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$selectedOverlayTree.overlays['custom'] = tryDeleteLayer(
|
|
||||||
$selectedOverlayTree.overlays['custom'],
|
|
||||||
layerId
|
|
||||||
);
|
|
||||||
if (Object.keys($selectedOverlayTree.overlays['custom']).length === 0) {
|
|
||||||
$selectedOverlayTree.overlays = tryDeleteLayer(
|
|
||||||
$selectedOverlayTree.overlays,
|
|
||||||
'custom'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
$previousOverlays = remove($previousOverlays, layerId);
|
||||||
|
$selectedOverlayTree = remove($selectedOverlayTree, layerId);
|
||||||
$customOverlayOrder = $customOverlayOrder.filter((id) => id !== layerId);
|
$customOverlayOrder = $customOverlayOrder.filter((id) => id !== layerId);
|
||||||
|
|
||||||
if (
|
|
||||||
$currentOverlays.overlays['custom'] &&
|
|
||||||
$currentOverlays.overlays['custom'][layerId] &&
|
|
||||||
$map
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
$map.removeImport(layerId);
|
|
||||||
} catch (e) {
|
|
||||||
// No reliable way to check if the map is ready to remove sources and layers
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
$customLayers = tryDeleteLayer($customLayers, layerId);
|
$customLayers = tryDeleteLayer($customLayers, layerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
let selectedLayerId: string | undefined = undefined;
|
function setDataFromSelectedLayer(layerId?: string) {
|
||||||
|
if (layerId) {
|
||||||
function setDataFromSelectedLayer() {
|
const layer = $customLayers[layerId];
|
||||||
if (selectedLayerId) {
|
|
||||||
const layer = $customLayers[selectedLayerId];
|
|
||||||
name = layer.name;
|
name = layer.name;
|
||||||
tileUrls = layer.tileUrls;
|
tileUrls = layer.tileUrls;
|
||||||
maxZoom = layer.maxZoom;
|
maxZoom = layer.maxZoom;
|
||||||
@@ -290,32 +244,60 @@
|
|||||||
resourceType = 'raster';
|
resourceType = 'raster';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$: selectedLayerId, setDataFromSelectedLayer();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
{#if $customBasemapOrder.length > 0}
|
{#if $customBasemapOrder.length > 0}
|
||||||
<div class="flex flex-row items-center gap-1 font-semibold mb-2">
|
<div class="flex flex-row items-center gap-1 font-semibold mb-2">
|
||||||
<Map size="16" />
|
<Map size="16" />
|
||||||
{$_('layers.label.basemaps')}
|
{i18n._('layers.label.basemaps')}
|
||||||
<div class="grow">
|
<div class="grow">
|
||||||
<Separator />
|
<Separator />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div
|
<div
|
||||||
bind:this={basemapContainer}
|
|
||||||
class="ml-1.5 flex flex-col gap-1 {$customBasemapOrder.length > 0 ? 'mb-2' : ''}"
|
class="ml-1.5 flex flex-col gap-1 {$customBasemapOrder.length > 0 ? 'mb-2' : ''}"
|
||||||
|
use:dndzone={{
|
||||||
|
items: customBasemapItems,
|
||||||
|
type: 'basemap',
|
||||||
|
dropTargetStyle: {},
|
||||||
|
transformDraggedElement: (element) => {
|
||||||
|
if (element) {
|
||||||
|
element.style.opacity = '0.5';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onconsider={(e) => {
|
||||||
|
customBasemapItems = e.detail.items;
|
||||||
|
}}
|
||||||
|
onfinalize={(e) => {
|
||||||
|
customBasemapItems = e.detail.items;
|
||||||
|
$customBasemapOrder = customBasemapItems.map((item) => item.id);
|
||||||
|
$selectedBasemapTree.basemaps['custom'] = customBasemapItems.reduce((acc, item) => {
|
||||||
|
acc[item.id] = true;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{#each $customBasemapOrder as id (id)}
|
{#each customBasemapItems as item (item.id)}
|
||||||
<div class="flex flex-row items-center gap-2" data-id={id}>
|
<div class="flex flex-row items-center gap-2">
|
||||||
<Move size="12" />
|
<Move size="12" />
|
||||||
<span class="grow">{$customLayers[id].name}</span>
|
<span class="grow">{item.name}</span>
|
||||||
<Button variant="outline" on:click={() => (selectedLayerId = id)} class="p-1 h-7">
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
onclick={() => (selectedLayerId = item.id)}
|
||||||
|
class="p-1 h-7"
|
||||||
|
>
|
||||||
<Pencil size="16" />
|
<Pencil size="16" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" on:click={() => deleteLayer(id)} class="p-1 h-7">
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
onclick={() => deleteLayer(item.id)}
|
||||||
|
class="p-1 h-7"
|
||||||
|
>
|
||||||
<Trash2 size="16" />
|
<Trash2 size="16" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -324,56 +306,85 @@
|
|||||||
{#if $customOverlayOrder.length > 0}
|
{#if $customOverlayOrder.length > 0}
|
||||||
<div class="flex flex-row items-center gap-1 font-semibold mb-2">
|
<div class="flex flex-row items-center gap-1 font-semibold mb-2">
|
||||||
<Layers2 size="16" />
|
<Layers2 size="16" />
|
||||||
{$_('layers.label.overlays')}
|
{i18n._('layers.label.overlays')}
|
||||||
<div class="grow">
|
<div class="grow">
|
||||||
<Separator />
|
<Separator />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div
|
<div
|
||||||
bind:this={overlayContainer}
|
|
||||||
class="ml-1.5 flex flex-col gap-1 {$customOverlayOrder.length > 0 ? 'mb-2' : ''}"
|
class="ml-1.5 flex flex-col gap-1 {$customOverlayOrder.length > 0 ? 'mb-2' : ''}"
|
||||||
|
use:dndzone={{
|
||||||
|
items: customOverlayItems,
|
||||||
|
type: 'overlay',
|
||||||
|
dropTargetStyle: {},
|
||||||
|
transformDraggedElement: (element) => {
|
||||||
|
if (element) {
|
||||||
|
element.style.opacity = '0.5';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onconsider={(e) => {
|
||||||
|
customOverlayItems = e.detail.items;
|
||||||
|
}}
|
||||||
|
onfinalize={(e) => {
|
||||||
|
customOverlayItems = e.detail.items;
|
||||||
|
$customOverlayOrder = customOverlayItems.map((item) => item.id);
|
||||||
|
$selectedOverlayTree.overlays['custom'] = customOverlayItems.reduce((acc, item) => {
|
||||||
|
acc[item.id] = true;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{#each $customOverlayOrder as id (id)}
|
{#each customOverlayItems as item (item.id)}
|
||||||
<div class="flex flex-row items-center gap-2" data-id={id}>
|
<div class="flex flex-row items-center gap-2">
|
||||||
<Move size="12" />
|
<Move size="12" />
|
||||||
<span class="grow">{$customLayers[id].name}</span>
|
<span class="grow">{item.name}</span>
|
||||||
<Button variant="outline" on:click={() => (selectedLayerId = id)} class="p-1 h-7">
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
onclick={() => (selectedLayerId = item.id)}
|
||||||
|
class="p-1 h-7"
|
||||||
|
>
|
||||||
<Pencil size="16" />
|
<Pencil size="16" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" on:click={() => deleteLayer(id)} class="p-1 h-7">
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
onclick={() => deleteLayer(item.id)}
|
||||||
|
class="p-1 h-7"
|
||||||
|
>
|
||||||
<Trash2 size="16" />
|
<Trash2 size="16" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
<Card.Root class="py-0 gap-0 shadow-none">
|
||||||
<Card.Root>
|
|
||||||
<Card.Header class="p-3">
|
<Card.Header class="p-3">
|
||||||
<Card.Title class="text-base">
|
<Card.Title class="text-base">
|
||||||
{#if selectedLayerId}
|
{#if selectedLayerId}
|
||||||
{$_('layers.custom_layers.edit')}
|
{i18n._('layers.custom_layers.edit')}
|
||||||
{:else}
|
{:else}
|
||||||
{$_('layers.custom_layers.new')}
|
{i18n._('layers.custom_layers.new')}
|
||||||
{/if}
|
{/if}
|
||||||
</Card.Title>
|
</Card.Title>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content class="p-3 pt-0">
|
<Card.Content class="p-3 pt-0">
|
||||||
<fieldset class="flex flex-col gap-2">
|
<fieldset class="flex flex-col gap-2">
|
||||||
<Label for="name">{$_('menu.metadata.name')}</Label>
|
<Label for="name">{i18n._('menu.metadata.name')}</Label>
|
||||||
<Input bind:value={name} id="name" class="h-8" />
|
<Input bind:value={name} id="name" class="h-8" />
|
||||||
<Label for="url">{$_('layers.custom_layers.urls')}</Label>
|
<Label for="url">{i18n._('layers.custom_layers.urls')}</Label>
|
||||||
{#each tileUrls as url, i}
|
{#each tileUrls as url, i}
|
||||||
<div class="flex flex-row gap-2">
|
<div class="flex flex-row gap-2">
|
||||||
<Input
|
<Input
|
||||||
bind:value={tileUrls[i]}
|
bind:value={tileUrls[i]}
|
||||||
id="url"
|
id="url"
|
||||||
class="h-8"
|
class="h-8"
|
||||||
placeholder={$_('layers.custom_layers.url_placeholder')}
|
placeholder={i18n._('layers.custom_layers.url_placeholder')}
|
||||||
/>
|
/>
|
||||||
{#if tileUrls.length > 1}
|
{#if tileUrls.length > 1}
|
||||||
<Button
|
<Button
|
||||||
on:click={() =>
|
onclick={() =>
|
||||||
(tileUrls = tileUrls.filter((_, index) => index !== i))}
|
(tileUrls = tileUrls.filter((_, index) => index !== i))}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="p-1 h-8"
|
class="p-1 h-8"
|
||||||
@@ -383,7 +394,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#if i === tileUrls.length - 1}
|
{#if i === tileUrls.length - 1}
|
||||||
<Button
|
<Button
|
||||||
on:click={() => (tileUrls = [...tileUrls, ''])}
|
onclick={() => (tileUrls = [...tileUrls, ''])}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="p-1 h-8"
|
class="p-1 h-8"
|
||||||
>
|
>
|
||||||
@@ -393,7 +404,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{#if resourceType === 'raster'}
|
{#if resourceType === 'raster'}
|
||||||
<Label for="maxZoom">{$_('layers.custom_layers.max_zoom')}</Label>
|
<Label for="maxZoom">{i18n._('layers.custom_layers.max_zoom')}</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
bind:value={maxZoom}
|
bind:value={maxZoom}
|
||||||
@@ -403,31 +414,31 @@
|
|||||||
class="h-8"
|
class="h-8"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<Label>{$_('layers.custom_layers.layer_type')}</Label>
|
<Label>{i18n._('layers.custom_layers.layer_type')}</Label>
|
||||||
<RadioGroup.Root bind:value={layerType} class="flex flex-row">
|
<RadioGroup.Root bind:value={layerType} class="flex flex-row">
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<RadioGroup.Item value="basemap" id="basemap" />
|
<RadioGroup.Item value="basemap" id="basemap" />
|
||||||
<Label for="basemap">{$_('layers.custom_layers.basemap')}</Label>
|
<Label for="basemap">{i18n._('layers.custom_layers.basemap')}</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<RadioGroup.Item value="overlay" id="overlay" />
|
<RadioGroup.Item value="overlay" id="overlay" />
|
||||||
<Label for="overlay">{$_('layers.custom_layers.overlay')}</Label>
|
<Label for="overlay">{i18n._('layers.custom_layers.overlay')}</Label>
|
||||||
</div>
|
</div>
|
||||||
</RadioGroup.Root>
|
</RadioGroup.Root>
|
||||||
{#if selectedLayerId}
|
{#if selectedLayerId}
|
||||||
<div class="mt-2 flex flex-row gap-2">
|
<div class="mt-2 flex flex-row gap-2">
|
||||||
<Button variant="outline" on:click={createLayer} class="grow">
|
<Button variant="outline" onclick={createLayer} class="grow">
|
||||||
<Save size="16" class="mr-1" />
|
<Save size="16" />
|
||||||
{$_('layers.custom_layers.update')}
|
{i18n._('layers.custom_layers.update')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" on:click={() => (selectedLayerId = undefined)}>
|
<Button variant="outline" onclick={() => (selectedLayerId = undefined)}>
|
||||||
<CircleX size="16" />
|
<CircleX size="16" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<Button variant="outline" class="mt-2" on:click={createLayer}>
|
<Button variant="outline" class="mt-2" onclick={createLayer}>
|
||||||
<CirclePlus size="16" class="mr-1" />
|
<CirclePlus size="16" />
|
||||||
{$_('layers.custom_layers.create')}
|
{i18n._('layers.custom_layers.create')}
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@@ -1,18 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import CustomControl from '$lib/components/custom-control/CustomControl.svelte';
|
import CustomControl from '$lib/components/map/custom-control/CustomControl.svelte';
|
||||||
import LayerTree from './LayerTree.svelte';
|
import LayerTree from './LayerTree.svelte';
|
||||||
|
import { OverpassLayer } from './overpass-layer';
|
||||||
import { Separator } from '$lib/components/ui/separator';
|
import { Separator } from '$lib/components/ui/separator';
|
||||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||||
|
import { Layers } from '@lucide/svelte';
|
||||||
import { Layers } from 'lucide-svelte';
|
|
||||||
|
|
||||||
import { basemaps, defaultBasemap, overlays } from '$lib/assets/layers';
|
import { basemaps, defaultBasemap, overlays } from '$lib/assets/layers';
|
||||||
import { settings } from '$lib/db';
|
import { settings } from '$lib/logic/settings';
|
||||||
import { map } from '$lib/stores';
|
import { map } from '$lib/components/map/map';
|
||||||
import { get, writable } from 'svelte/store';
|
|
||||||
import { customBasemapUpdate, getLayers } from './utils';
|
import { customBasemapUpdate, getLayers } from './utils';
|
||||||
import { OverpassLayer } from './OverpassLayer';
|
import type { ImportSpecification, StyleSpecification } from 'mapbox-gl';
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
let overpassLayer: OverpassLayer;
|
let overpassLayer: OverpassLayer;
|
||||||
@@ -30,30 +28,37 @@
|
|||||||
} = settings;
|
} = settings;
|
||||||
|
|
||||||
function setStyle() {
|
function setStyle() {
|
||||||
if ($map) {
|
if (!$map) {
|
||||||
let basemap = basemaps.hasOwnProperty($currentBasemap)
|
return;
|
||||||
? basemaps[$currentBasemap]
|
}
|
||||||
: ($customLayers[$currentBasemap]?.value ?? basemaps[defaultBasemap]);
|
let basemap = basemaps.hasOwnProperty($currentBasemap)
|
||||||
$map.removeImport('basemap');
|
? basemaps[$currentBasemap]
|
||||||
if (typeof basemap === 'string') {
|
: ($customLayers[$currentBasemap]?.value ?? basemaps[defaultBasemap]);
|
||||||
$map.addImport({ id: 'basemap', url: basemap }, 'overlays');
|
$map.removeImport('basemap');
|
||||||
} else {
|
if (typeof basemap === 'string') {
|
||||||
$map.addImport(
|
$map.addImport({ id: 'basemap', url: basemap }, 'overlays');
|
||||||
{
|
} else {
|
||||||
id: 'basemap',
|
$map.addImport(
|
||||||
data: basemap,
|
{
|
||||||
},
|
id: 'basemap',
|
||||||
'overlays'
|
url: '',
|
||||||
);
|
data: basemap as StyleSpecification,
|
||||||
}
|
},
|
||||||
|
'overlays'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$: if ($map && ($currentBasemap || $customBasemapUpdate)) {
|
$effect(() => {
|
||||||
setStyle();
|
if ($map && ($currentBasemap || $customBasemapUpdate)) {
|
||||||
}
|
untrack(() => setStyle());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function addOverlay(id: string) {
|
function addOverlay(id: string) {
|
||||||
|
if (!$map) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
let overlay = $customLayers.hasOwnProperty(id) ? $customLayers[id].value : overlays[id];
|
let overlay = $customLayers.hasOwnProperty(id) ? $customLayers[id].value : overlays[id];
|
||||||
if (typeof overlay === 'string') {
|
if (typeof overlay === 'string') {
|
||||||
@@ -62,7 +67,7 @@
|
|||||||
if ($opacities.hasOwnProperty(id)) {
|
if ($opacities.hasOwnProperty(id)) {
|
||||||
overlay = {
|
overlay = {
|
||||||
...overlay,
|
...overlay,
|
||||||
layers: overlay.layers.map((layer) => {
|
layers: (overlay as StyleSpecification).layers.map((layer) => {
|
||||||
if (layer.type === 'raster') {
|
if (layer.type === 'raster') {
|
||||||
if (!layer.paint) {
|
if (!layer.paint) {
|
||||||
layer.paint = {};
|
layer.paint = {};
|
||||||
@@ -75,7 +80,8 @@
|
|||||||
}
|
}
|
||||||
$map.addImport({
|
$map.addImport({
|
||||||
id,
|
id,
|
||||||
data: overlay,
|
url: '',
|
||||||
|
data: overlay as StyleSpecification,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -87,15 +93,26 @@
|
|||||||
if ($map && $currentOverlays && $opacities) {
|
if ($map && $currentOverlays && $opacities) {
|
||||||
let overlayLayers = getLayers($currentOverlays);
|
let overlayLayers = getLayers($currentOverlays);
|
||||||
try {
|
try {
|
||||||
let activeOverlays = $map.getStyle().imports.reduce((acc, i) => {
|
let activeOverlays =
|
||||||
if (!['basemap', 'overlays', 'glyphs-and-sprite'].includes(i.id)) {
|
$map
|
||||||
acc[i.id] = i;
|
.getStyle()
|
||||||
}
|
.imports?.reduce(
|
||||||
return acc;
|
(
|
||||||
}, {});
|
acc: Record<string, ImportSpecification>,
|
||||||
|
imprt: ImportSpecification
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
!['basemap', 'overlays', 'glyphs-and-sprite'].includes(imprt.id)
|
||||||
|
) {
|
||||||
|
acc[imprt.id] = imprt;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
) || {};
|
||||||
let toRemove = Object.keys(activeOverlays).filter((id) => !overlayLayers[id]);
|
let toRemove = Object.keys(activeOverlays).filter((id) => !overlayLayers[id]);
|
||||||
toRemove.forEach((id) => {
|
toRemove.forEach((id) => {
|
||||||
$map.removeImport(id);
|
$map?.removeImport(id);
|
||||||
});
|
});
|
||||||
let toAdd = Object.entries(overlayLayers)
|
let toAdd = Object.entries(overlayLayers)
|
||||||
.filter(([id, selected]) => selected && !activeOverlays.hasOwnProperty(id))
|
.filter(([id, selected]) => selected && !activeOverlays.hasOwnProperty(id))
|
||||||
@@ -109,52 +126,44 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$: if ($map && $currentOverlays && $opacities) {
|
$effect(() => {
|
||||||
updateOverlays();
|
if ($map && $currentOverlays && $opacities) {
|
||||||
}
|
untrack(() => updateOverlays());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
$: if ($map) {
|
map.onLoad((_map: mapboxgl.Map) => {
|
||||||
if (overpassLayer) {
|
if (overpassLayer) {
|
||||||
overpassLayer.remove();
|
overpassLayer.remove();
|
||||||
}
|
}
|
||||||
overpassLayer = new OverpassLayer($map);
|
overpassLayer = new OverpassLayer(_map);
|
||||||
overpassLayer.add();
|
overpassLayer.add();
|
||||||
$map.on('style.import.load', updateOverlays);
|
let first = true;
|
||||||
}
|
_map.on('style.import.load', () => {
|
||||||
|
if (!first) return;
|
||||||
let selectedBasemap = writable(get(currentBasemap));
|
first = false;
|
||||||
selectedBasemap.subscribe((value) => {
|
updateOverlays();
|
||||||
// Updates coming from radio buttons
|
});
|
||||||
if (value !== get(currentBasemap)) {
|
|
||||||
previousBasemap.set(get(currentBasemap));
|
|
||||||
currentBasemap.set(value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
currentBasemap.subscribe((value) => {
|
|
||||||
// Updates coming from the database, or from the user swapping basemaps
|
|
||||||
if (value !== get(selectedBasemap)) {
|
|
||||||
selectedBasemap.set(value);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let open = false;
|
let open = $state(false);
|
||||||
function openLayerControl() {
|
function openLayerControl() {
|
||||||
open = true;
|
open = true;
|
||||||
}
|
}
|
||||||
function closeLayerControl() {
|
function closeLayerControl() {
|
||||||
open = false;
|
open = false;
|
||||||
}
|
}
|
||||||
let cancelEvents = false;
|
let cancelEvents = $state(false);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<CustomControl class="group min-w-[29px] min-h-[29px] overflow-hidden">
|
<CustomControl class="group min-w-[29px] min-h-[29px] overflow-hidden">
|
||||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
bind:this={container}
|
bind:this={container}
|
||||||
class="h-full w-full"
|
class="size-full"
|
||||||
on:mouseenter={openLayerControl}
|
onmouseenter={openLayerControl}
|
||||||
on:mouseleave={closeLayerControl}
|
onmouseleave={closeLayerControl}
|
||||||
on:pointerenter={() => {
|
onpointerenter={() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
cancelEvents = true;
|
cancelEvents = true;
|
||||||
openLayerControl();
|
openLayerControl();
|
||||||
@@ -166,7 +175,7 @@
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex flex-row justify-center items-center delay-100 transition-[opacity] duration-0 {open
|
class="flex flex-row justify-center items-center delay-100 transition-[opacity] duration-0 {open
|
||||||
? 'opacity-0 w-0 h-0 delay-0'
|
? 'opacity-0 size-0 delay-0'
|
||||||
: 'w-[29px] h-[29px]'}"
|
: 'w-[29px] h-[29px]'}"
|
||||||
>
|
>
|
||||||
<Layers size="20" />
|
<Layers size="20" />
|
||||||
@@ -176,17 +185,21 @@
|
|||||||
? 'grid-rows-[1fr] grid-cols-[1fr]'
|
? 'grid-rows-[1fr] grid-cols-[1fr]'
|
||||||
: ''} {cancelEvents ? 'pointer-events-none' : ''}"
|
: ''} {cancelEvents ? 'pointer-events-none' : ''}"
|
||||||
>
|
>
|
||||||
<ScrollArea>
|
<ScrollArea class="overflow-hidden">
|
||||||
<div class="h-fit">
|
<div class="h-fit">
|
||||||
<div class="p-2">
|
<div class="p-2 ml-1">
|
||||||
<LayerTree
|
<LayerTree
|
||||||
layerTree={$selectedBasemapTree}
|
layerTree={$selectedBasemapTree}
|
||||||
name="basemaps"
|
name="basemaps"
|
||||||
bind:selected={$selectedBasemap}
|
selected={$currentBasemap}
|
||||||
|
onselect={(value) => {
|
||||||
|
$previousBasemap = $currentBasemap;
|
||||||
|
$currentBasemap = value;
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Separator class="w-full" />
|
<Separator class="w-full" />
|
||||||
<div class="p-2">
|
<div class="p-2 ml-1">
|
||||||
{#if $currentOverlays}
|
{#if $currentOverlays}
|
||||||
<LayerTree
|
<LayerTree
|
||||||
layerTree={$selectedOverlayTree}
|
layerTree={$selectedOverlayTree}
|
||||||
@@ -197,7 +210,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<Separator class="w-full" />
|
<Separator class="w-full" />
|
||||||
<div class="p-2">
|
<div class="p-2 ml-1">
|
||||||
{#if $currentOverpassQueries}
|
{#if $currentOverpassQueries}
|
||||||
<LayerTree
|
<LayerTree
|
||||||
layerTree={$selectedOverpassTree}
|
layerTree={$selectedOverpassTree}
|
||||||
@@ -214,8 +227,9 @@
|
|||||||
</CustomControl>
|
</CustomControl>
|
||||||
|
|
||||||
<svelte:window
|
<svelte:window
|
||||||
on:click={(e) => {
|
on:click={(e: MouseEvent) => {
|
||||||
if (open && !cancelEvents && !container.contains(e.target)) {
|
const target = e.target as Node | null;
|
||||||
|
if (open && !cancelEvents && target && container && !container.contains(target)) {
|
||||||
closeLayerControl();
|
closeLayerControl();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import LayerTree from './LayerTree.svelte';
|
import LayerTree from './LayerTree.svelte';
|
||||||
|
|
||||||
import { Separator } from '$lib/components/ui/separator';
|
import { Separator } from '$lib/components/ui/separator';
|
||||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||||
import * as Sheet from '$lib/components/ui/sheet';
|
import * as Sheet from '$lib/components/ui/sheet';
|
||||||
@@ -8,7 +7,6 @@
|
|||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import * as Select from '$lib/components/ui/select';
|
import * as Select from '$lib/components/ui/select';
|
||||||
import { Slider } from '$lib/components/ui/slider';
|
import { Slider } from '$lib/components/ui/slider';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
basemapTree,
|
basemapTree,
|
||||||
defaultBasemap,
|
defaultBasemap,
|
||||||
@@ -16,13 +14,13 @@
|
|||||||
overlayTree,
|
overlayTree,
|
||||||
overpassTree,
|
overpassTree,
|
||||||
} from '$lib/assets/layers';
|
} from '$lib/assets/layers';
|
||||||
import { getLayers, isSelected, toggle } from '$lib/components/layer-control/utils';
|
import { getLayers, isSelected, toggle } from '$lib/components/map/layer-control/utils';
|
||||||
import { settings } from '$lib/db';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
|
import { map } from '$lib/components/map/map';
|
||||||
import { _ } from 'svelte-i18n';
|
|
||||||
import { writable } from 'svelte/store';
|
|
||||||
import { map } from '$lib/stores';
|
|
||||||
import CustomLayers from './CustomLayers.svelte';
|
import CustomLayers from './CustomLayers.svelte';
|
||||||
|
import { settings } from '$lib/logic/settings';
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
import { extensionAPI } from '$lib/components/map/layer-control/extension-api';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
selectedBasemapTree,
|
selectedBasemapTree,
|
||||||
@@ -30,72 +28,99 @@
|
|||||||
selectedOverpassTree,
|
selectedOverpassTree,
|
||||||
currentBasemap,
|
currentBasemap,
|
||||||
currentOverlays,
|
currentOverlays,
|
||||||
|
currentOverpassQueries,
|
||||||
customLayers,
|
customLayers,
|
||||||
opacities,
|
opacities,
|
||||||
} = settings;
|
} = settings;
|
||||||
|
|
||||||
export let open: boolean;
|
const { isLayerFromExtension, getLayerName } = extensionAPI;
|
||||||
let accordionValue: string | string[] | undefined = undefined;
|
|
||||||
|
|
||||||
let selectedOverlay = writable(undefined);
|
let { open = $bindable() }: { open: boolean } = $props();
|
||||||
let overlayOpacity = writable([1]);
|
|
||||||
|
let accordionValue: string | undefined = $state(undefined);
|
||||||
|
let selectedOverlay = $state(undefined);
|
||||||
|
let overlayOpacity = $state(1);
|
||||||
|
|
||||||
function setOpacityFromSelection() {
|
function setOpacityFromSelection() {
|
||||||
if ($selectedOverlay) {
|
if (selectedOverlay) {
|
||||||
let overlayId = $selectedOverlay.value;
|
if ($opacities.hasOwnProperty(selectedOverlay)) {
|
||||||
if ($opacities.hasOwnProperty(overlayId)) {
|
overlayOpacity = $opacities[selectedOverlay];
|
||||||
$overlayOpacity = [$opacities[overlayId]];
|
|
||||||
} else {
|
} else {
|
||||||
$overlayOpacity = [1];
|
overlayOpacity = 1;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$overlayOpacity = [1];
|
overlayOpacity = 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$: if ($selectedBasemapTree && $currentBasemap) {
|
$effect(() => {
|
||||||
if (!isSelected($selectedBasemapTree, $currentBasemap)) {
|
if ($selectedBasemapTree && $currentBasemap) {
|
||||||
if (!isSelected($selectedBasemapTree, defaultBasemap)) {
|
if (!isSelected($selectedBasemapTree, $currentBasemap)) {
|
||||||
$selectedBasemapTree = toggle($selectedBasemapTree, defaultBasemap);
|
if (!isSelected($selectedBasemapTree, defaultBasemap)) {
|
||||||
|
$selectedBasemapTree = toggle($selectedBasemapTree, defaultBasemap);
|
||||||
|
}
|
||||||
|
$currentBasemap = defaultBasemap;
|
||||||
}
|
}
|
||||||
$currentBasemap = defaultBasemap;
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
$: if ($selectedOverlayTree && $currentOverlays) {
|
$effect(() => {
|
||||||
let overlayLayers = getLayers($currentOverlays);
|
if ($selectedOverlayTree) {
|
||||||
let toRemove = Object.entries(overlayLayers).filter(
|
untrack(() => {
|
||||||
([id, checked]) => checked && !isSelected($selectedOverlayTree, id)
|
if ($currentOverlays) {
|
||||||
);
|
let overlayLayers = getLayers($currentOverlays);
|
||||||
if (toRemove.length > 0) {
|
let toRemove = Object.entries(overlayLayers).filter(
|
||||||
currentOverlays.update((tree) => {
|
([id, checked]) => checked && !isSelected($selectedOverlayTree, id)
|
||||||
toRemove.forEach(([id]) => {
|
);
|
||||||
toggle(tree, id);
|
if (toRemove.length > 0) {
|
||||||
});
|
currentOverlays.update((tree) => {
|
||||||
return tree;
|
toRemove.forEach(([id]) => {
|
||||||
|
toggle(tree, id);
|
||||||
|
});
|
||||||
|
return tree;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
$: if ($selectedOverlay) {
|
$effect(() => {
|
||||||
setOpacityFromSelection();
|
if ($selectedOverpassTree) {
|
||||||
}
|
untrack(() => {
|
||||||
|
if ($currentOverpassQueries) {
|
||||||
|
let overlayLayers = getLayers($currentOverpassQueries);
|
||||||
|
let toRemove = Object.entries(overlayLayers).filter(
|
||||||
|
([id, checked]) => checked && !isSelected($selectedOverpassTree, id)
|
||||||
|
);
|
||||||
|
if (toRemove.length > 0) {
|
||||||
|
currentOverpassQueries.update((tree) => {
|
||||||
|
toRemove.forEach(([id]) => {
|
||||||
|
toggle(tree, id);
|
||||||
|
});
|
||||||
|
return tree;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Sheet.Root bind:open>
|
<Sheet.Root bind:open>
|
||||||
<Sheet.Trigger class="hidden" />
|
<Sheet.Trigger class="hidden" />
|
||||||
<Sheet.Content>
|
<Sheet.Content>
|
||||||
<Sheet.Header class="h-full">
|
<Sheet.Header class="h-full">
|
||||||
<Sheet.Title>{$_('layers.settings')}</Sheet.Title>
|
<Sheet.Title>{i18n._('layers.settings')}</Sheet.Title>
|
||||||
<ScrollArea class="w-[105%] min-h-full pr-4">
|
<ScrollArea class="w-[105%] min-h-full pr-4">
|
||||||
<Sheet.Description>
|
<Sheet.Description>
|
||||||
{$_('layers.settings_help')}
|
{i18n._('layers.settings_help')}
|
||||||
</Sheet.Description>
|
</Sheet.Description>
|
||||||
<Accordion.Root class="flex flex-col" bind:value={accordionValue}>
|
<Accordion.Root class="flex flex-col" bind:value={accordionValue} type="single">
|
||||||
<Accordion.Item value="layer-selection" class="flex flex-col">
|
<Accordion.Item value="layer-selection" class="flex flex-col">
|
||||||
<Accordion.Trigger>{$_('layers.selection')}</Accordion.Trigger>
|
<Accordion.Trigger>{i18n._('layers.selection')}</Accordion.Trigger>
|
||||||
<Accordion.Content class="grow flex flex-col border rounded">
|
<Accordion.Content class="grow flex flex-col border rounded">
|
||||||
<div class="py-2 pl-1 pr-2">
|
<div class="py-2 pl-3 pr-2">
|
||||||
<LayerTree
|
<LayerTree
|
||||||
layerTree={basemapTree}
|
layerTree={basemapTree}
|
||||||
name="basemapSettings"
|
name="basemapSettings"
|
||||||
@@ -104,7 +129,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div class="py-2 pl-1 pr-2">
|
<div class="py-2 pl-3 pr-2">
|
||||||
<LayerTree
|
<LayerTree
|
||||||
layerTree={overlayTree}
|
layerTree={overlayTree}
|
||||||
name="overlaySettings"
|
name="overlaySettings"
|
||||||
@@ -113,7 +138,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div class="py-2 pl-1 pr-2">
|
<div class="py-2 pl-3 pr-2">
|
||||||
<LayerTree
|
<LayerTree
|
||||||
layerTree={overpassTree}
|
layerTree={overpassTree}
|
||||||
name="overpassSettings"
|
name="overpassSettings"
|
||||||
@@ -124,22 +149,40 @@
|
|||||||
</Accordion.Content>
|
</Accordion.Content>
|
||||||
</Accordion.Item>
|
</Accordion.Item>
|
||||||
<Accordion.Item value="overlay-opacity">
|
<Accordion.Item value="overlay-opacity">
|
||||||
<Accordion.Trigger>{$_('layers.opacity')}</Accordion.Trigger>
|
<Accordion.Trigger>{i18n._('layers.opacity')}</Accordion.Trigger>
|
||||||
<Accordion.Content class="flex flex-col gap-3 overflow-visible">
|
<Accordion.Content class="flex flex-col gap-3 overflow-visible">
|
||||||
<div class="flex flex-row gap-6 items-center">
|
<div class="flex flex-row gap-6 items-center">
|
||||||
<Label>
|
<Label>
|
||||||
{$_('layers.custom_layers.overlay')}
|
{i18n._('layers.custom_layers.overlay')}
|
||||||
</Label>
|
</Label>
|
||||||
<Select.Root bind:selected={$selectedOverlay}>
|
<Select.Root
|
||||||
<Select.Trigger class="h-8 mr-1">
|
bind:value={selectedOverlay}
|
||||||
<Select.Value />
|
type="single"
|
||||||
|
onValueChange={setOpacityFromSelection}
|
||||||
|
>
|
||||||
|
<Select.Trigger class="h-8 mr-1 w-full">
|
||||||
|
{#if selectedOverlay}
|
||||||
|
{#if isSelected($selectedOverlayTree, selectedOverlay)}
|
||||||
|
{#if $isLayerFromExtension(selectedOverlay)}
|
||||||
|
{$getLayerName(selectedOverlay)}
|
||||||
|
{:else}
|
||||||
|
{i18n._(`layers.label.${selectedOverlay}`)}
|
||||||
|
{/if}
|
||||||
|
{:else if $customLayers.hasOwnProperty(selectedOverlay)}
|
||||||
|
{$customLayers[selectedOverlay].name}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
</Select.Trigger>
|
</Select.Trigger>
|
||||||
<Select.Content class="h-fit max-h-[40dvh] overflow-y-auto">
|
<Select.Content class="h-fit max-h-[40dvh] overflow-y-auto">
|
||||||
{#each Object.keys(overlays) as id}
|
{#each Object.keys(overlays) as id}
|
||||||
{#if isSelected($selectedOverlayTree, id)}
|
{#if isSelected($selectedOverlayTree, id)}
|
||||||
<Select.Item value={id}
|
<Select.Item value={id}>
|
||||||
>{$_(`layers.label.${id}`)}</Select.Item
|
{#if $isLayerFromExtension(id)}
|
||||||
>
|
{$getLayerName(id)}
|
||||||
|
{:else}
|
||||||
|
{i18n._(`layers.label.${id}`)}
|
||||||
|
{/if}
|
||||||
|
</Select.Item>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
{#each Object.entries($customLayers) as [id, layer]}
|
{#each Object.entries($customLayers) as [id, layer]}
|
||||||
@@ -151,30 +194,29 @@
|
|||||||
</Select.Root>
|
</Select.Root>
|
||||||
</div>
|
</div>
|
||||||
<Label class="flex flex-row gap-6 items-center">
|
<Label class="flex flex-row gap-6 items-center">
|
||||||
{$_('menu.style.opacity')}
|
{i18n._('menu.style.opacity')}
|
||||||
<div class="p-2 pr-3 grow">
|
<div class="p-2 pr-3 grow">
|
||||||
<Slider
|
<Slider
|
||||||
bind:value={$overlayOpacity}
|
bind:value={overlayOpacity}
|
||||||
|
type="single"
|
||||||
min={0.1}
|
min={0.1}
|
||||||
max={1}
|
max={1}
|
||||||
step={0.1}
|
step={0.1}
|
||||||
disabled={$selectedOverlay === undefined}
|
disabled={selectedOverlay === undefined}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
if ($selectedOverlay) {
|
if (selectedOverlay) {
|
||||||
if (
|
if (
|
||||||
$map &&
|
$map &&
|
||||||
isSelected(
|
$currentOverlays &&
|
||||||
$currentOverlays,
|
isSelected($currentOverlays, selectedOverlay)
|
||||||
$selectedOverlay.value
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
$map.removeImport($selectedOverlay.value);
|
$map.removeImport(selectedOverlay);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// No reliable way to check if the map is ready to remove sources and layers
|
// No reliable way to check if the map is ready to remove sources and layers
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$opacities[$selectedOverlay.value] = value[0];
|
$opacities[selectedOverlay] = value;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -183,7 +225,8 @@
|
|||||||
</Accordion.Content>
|
</Accordion.Content>
|
||||||
</Accordion.Item>
|
</Accordion.Item>
|
||||||
<Accordion.Item value="custom-layers">
|
<Accordion.Item value="custom-layers">
|
||||||
<Accordion.Trigger>{$_('layers.custom_layers.title')}</Accordion.Trigger>
|
<Accordion.Trigger>{i18n._('layers.custom_layers.title')}</Accordion.Trigger
|
||||||
|
>
|
||||||
<Accordion.Content>
|
<Accordion.Content>
|
||||||
<ScrollArea>
|
<ScrollArea>
|
||||||
<CustomLayers />
|
<CustomLayers />
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import LayerTreeNode from './LayerTreeNode.svelte';
|
||||||
|
import { type LayerTreeType } from '$lib/assets/layers';
|
||||||
|
import CollapsibleTree from '$lib/components/collapsible-tree/CollapsibleTree.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
layerTree,
|
||||||
|
name,
|
||||||
|
selected = '',
|
||||||
|
onselect = () => {},
|
||||||
|
multiple = false,
|
||||||
|
checked = $bindable({}),
|
||||||
|
}: {
|
||||||
|
layerTree: LayerTreeType;
|
||||||
|
name: string;
|
||||||
|
selected?: string;
|
||||||
|
onselect?: (value: string) => void;
|
||||||
|
multiple?: boolean;
|
||||||
|
checked?: LayerTreeType;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form>
|
||||||
|
<fieldset class="min-w-64 mb-1">
|
||||||
|
<CollapsibleTree nohover={true}>
|
||||||
|
<LayerTreeNode {name} node={layerTree} {selected} {onselect} {multiple} bind:checked />
|
||||||
|
</CollapsibleTree>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
@@ -1,25 +1,34 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import Self from '$lib/components/map/layer-control/LayerTreeNode.svelte';
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||||
import CollapsibleTreeNode from '../collapsible-tree/CollapsibleTreeNode.svelte';
|
import CollapsibleTreeNode from '$lib/components/collapsible-tree/CollapsibleTreeNode.svelte';
|
||||||
|
|
||||||
import { type LayerTreeType } from '$lib/assets/layers';
|
import { type LayerTreeType } from '$lib/assets/layers';
|
||||||
import { anySelectedLayer } from './utils';
|
import { anySelectedLayer } from './utils';
|
||||||
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
|
import { settings } from '$lib/logic/settings';
|
||||||
|
import { extensionAPI } from '$lib/components/map/layer-control/extension-api';
|
||||||
|
|
||||||
import { _ } from 'svelte-i18n';
|
let {
|
||||||
import { settings } from '$lib/db';
|
name,
|
||||||
import { beforeUpdate } from 'svelte';
|
node,
|
||||||
|
selected = '',
|
||||||
export let name: string;
|
onselect = () => {},
|
||||||
export let node: LayerTreeType;
|
multiple = false,
|
||||||
export let selected: string | undefined = undefined;
|
checked = $bindable({}),
|
||||||
export let multiple: boolean = false;
|
}: {
|
||||||
|
name: string;
|
||||||
export let checked: LayerTreeType;
|
node: LayerTreeType;
|
||||||
|
selected?: string;
|
||||||
|
onselect?: (value: string) => void;
|
||||||
|
multiple: boolean;
|
||||||
|
checked: LayerTreeType;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
const { customLayers } = settings;
|
const { customLayers } = settings;
|
||||||
|
const { isLayerFromExtension, getLayerName } = extensionAPI;
|
||||||
|
|
||||||
beforeUpdate(() => {
|
$effect.pre(() => {
|
||||||
if (checked !== undefined) {
|
if (checked !== undefined) {
|
||||||
Object.keys(node).forEach((id) => {
|
Object.keys(node).forEach((id) => {
|
||||||
if (!checked.hasOwnProperty(id)) {
|
if (!checked.hasOwnProperty(id)) {
|
||||||
@@ -46,7 +55,7 @@
|
|||||||
value={id}
|
value={id}
|
||||||
bind:checked={checked[id]}
|
bind:checked={checked[id]}
|
||||||
class="scale-90"
|
class="scale-90"
|
||||||
aria-label={$_(`layers.label.${id}`)}
|
aria-label={i18n._(`layers.label.${id}`)}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<input
|
<input
|
||||||
@@ -54,36 +63,50 @@
|
|||||||
type="radio"
|
type="radio"
|
||||||
{name}
|
{name}
|
||||||
value={id}
|
value={id}
|
||||||
bind:group={selected}
|
checked={selected === id}
|
||||||
|
oninput={(e) => {
|
||||||
|
if ((e.target as HTMLInputElement)?.checked) {
|
||||||
|
onselect(id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<Label for="{name}-{id}" class="flex flex-row items-center gap-1">
|
<Label for="{name}-{id}" class="flex flex-row items-center gap-1">
|
||||||
{#if $customLayers.hasOwnProperty(id)}
|
{#if $customLayers.hasOwnProperty(id)}
|
||||||
{$customLayers[id].name}
|
{$customLayers[id].name}
|
||||||
|
{:else if $isLayerFromExtension(id)}
|
||||||
|
{$getLayerName(id)}
|
||||||
{:else}
|
{:else}
|
||||||
{$_(`layers.label.${id}`)}
|
{i18n._(`layers.label.${id}`)}
|
||||||
{/if}
|
{/if}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else if anySelectedLayer(node[id])}
|
{:else if anySelectedLayer(node[id])}
|
||||||
<CollapsibleTreeNode {id}>
|
<CollapsibleTreeNode {id}>
|
||||||
<span slot="trigger">{$_(`layers.label.${id}`)}</span>
|
{#snippet trigger()}
|
||||||
<div slot="content">
|
<span>{i18n._(`layers.label.${id}`, id)}</span>
|
||||||
<svelte:self
|
{/snippet}
|
||||||
node={node[id]}
|
{#snippet content()}
|
||||||
{name}
|
<div class="ml-2">
|
||||||
bind:selected
|
<Self
|
||||||
{multiple}
|
node={node[id]}
|
||||||
bind:checked={checked[id]}
|
{name}
|
||||||
/>
|
{selected}
|
||||||
</div>
|
{onselect}
|
||||||
|
{multiple}
|
||||||
|
bind:checked={checked[id]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
</CollapsibleTreeNode>
|
</CollapsibleTreeNode>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="postcss">
|
<style lang="postcss">
|
||||||
|
@reference "../../../../app.css";
|
||||||
|
|
||||||
div :global(input[type='radio']) {
|
div :global(input[type='radio']) {
|
||||||
@apply appearance-none;
|
@apply appearance-none;
|
||||||
@apply w-4 h-4;
|
@apply w-4 h-4;
|
||||||
@@ -1,26 +1,31 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { selection } from '$lib/components/file-list/Selection';
|
import { PencilLine, MapPin } from '@lucide/svelte';
|
||||||
import { PencilLine, MapPin } from 'lucide-svelte';
|
import { i18n } from '$lib/i18n.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';
|
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||||
import type { WaypointType } from 'gpx';
|
import type { WaypointType } from 'gpx';
|
||||||
|
import type { PopupItem } from '$lib/components/map/map-popup';
|
||||||
|
import { fileActions } from '$lib/logic/file-actions';
|
||||||
|
import { selection } from '$lib/logic/selection';
|
||||||
|
|
||||||
export let poi: PopupItem<any>;
|
let {
|
||||||
|
poi,
|
||||||
|
}: {
|
||||||
|
poi: PopupItem<any>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
let tags: { [key: string]: string } = {};
|
let tags: Record<string, string> = $derived(poi ? JSON.parse(poi.item.tags) : {});
|
||||||
let name = '';
|
let name = $derived.by(() => {
|
||||||
$: if (poi) {
|
if (poi) {
|
||||||
tags = JSON.parse(poi.item.tags);
|
if (tags.name !== undefined && tags.name !== '') {
|
||||||
if (tags.name !== undefined && tags.name !== '') {
|
return tags.name;
|
||||||
name = tags.name;
|
} else {
|
||||||
} else {
|
return i18n._(`layers.label.${poi.item.query}`);
|
||||||
name = $_(`layers.label.${poi.item.query}`);
|
}
|
||||||
}
|
}
|
||||||
}
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
function addToFile() {
|
function addToFile() {
|
||||||
const desc = Object.entries(tags)
|
const desc = Object.entries(tags)
|
||||||
@@ -43,23 +48,24 @@
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
dbUtils.addOrUpdateWaypoint(wpt);
|
fileActions.addOrUpdateWaypoint(wpt);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<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] gap-0">
|
||||||
<Card.Header class="p-0">
|
<Card.Header class="p-0 gap-0">
|
||||||
<Card.Title class="text-md">
|
<Card.Title class="text-md">
|
||||||
<div class="flex flex-row gap-3">
|
<div class="flex flex-row gap-3">
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
{name}
|
{name}
|
||||||
<div class="text-muted-foreground text-sm font-normal">
|
<div class="text-muted-foreground text-xs font-normal">
|
||||||
{poi.item.lat.toFixed(6)}° {poi.item.lon.toFixed(6)}°
|
{poi.item.lat.toFixed(6)}° {poi.item.lon.toFixed(6)}°
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
class="ml-auto p-1.5 h-8"
|
class="ml-auto"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
href="https://www.openstreetmap.org/edit?editor=id&{poi.item.type ??
|
href="https://www.openstreetmap.org/edit?editor=id&{poi.item.type ??
|
||||||
'node'}={poi.item.id}"
|
'node'}={poi.item.id}"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -70,10 +76,10 @@
|
|||||||
</Card.Title>
|
</Card.Title>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content class="flex flex-col p-0 text-sm mt-1 whitespace-normal break-all">
|
<Card.Content class="flex flex-col p-0 text-sm mt-1 whitespace-normal break-all">
|
||||||
<ScrollArea class="flex flex-col" viewportClasses="max-h-[30dvh]">
|
<ScrollArea class="flex flex-col max-h-[30dvh]">
|
||||||
{#if tags.image || tags['image:0']}
|
{#if tags.image || tags['image:0']}
|
||||||
<div class="w-full rounded-md overflow-clip my-2 max-w-96 mx-auto">
|
<div class="w-full rounded-md overflow-clip my-2 max-w-96 mx-auto">
|
||||||
<!-- svelte-ignore a11y-missing-attribute -->
|
<!-- svelte-ignore a11y_missing_attribute -->
|
||||||
<img src={tags.image ?? tags['image:0']} />
|
<img src={tags.image ?? tags['image:0']} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -81,7 +87,7 @@
|
|||||||
{#each Object.entries(tags) as [key, value]}
|
{#each Object.entries(tags) as [key, value]}
|
||||||
{#if key !== 'name' && !key.includes('image')}
|
{#if key !== 'name' && !key.includes('image')}
|
||||||
<span class="font-mono">{key}</span>
|
<span class="font-mono">{key}</span>
|
||||||
{#if key === 'website' || key.startsWith('website:') || key === 'contact:website' || key === 'contact:facebook' || key === 'contact:instagram' || key === 'contact:twitter'}
|
{#if key === 'website' || key.startsWith('website:') || key.endsWith(':website') || key === 'contact:facebook' || key === 'contact:instagram' || key === 'contact:twitter'}
|
||||||
<a href={value} target="_blank" class="text-link underline">{value}</a>
|
<a href={value} target="_blank" class="text-link underline">{value}</a>
|
||||||
{:else if key === 'phone' || key === 'contact:phone'}
|
{:else if key === 'phone' || key === 'contact:phone'}
|
||||||
<a href={'tel:' + value} class="text-link underline">{value}</a>
|
<a href={'tel:' + value} class="text-link underline">{value}</a>
|
||||||
@@ -94,14 +100,9 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
<Button
|
<Button class="mt-2" variant="outline" disabled={$selection.size === 0} onclick={addToFile}>
|
||||||
class="mt-2"
|
<MapPin size="16" />
|
||||||
variant="outline"
|
{i18n._('toolbar.waypoint.add')}
|
||||||
disabled={$selection.size === 0}
|
|
||||||
on:click={addToFile}
|
|
||||||
>
|
|
||||||
<MapPin size="16" class="mr-1" />
|
|
||||||
{$_('toolbar.waypoint.add')}
|
|
||||||
</Button>
|
</Button>
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
213
website/src/lib/components/map/layer-control/extension-api.ts
Normal file
213
website/src/lib/components/map/layer-control/extension-api.ts
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import { settings } from '$lib/logic/settings';
|
||||||
|
import { derived, get, writable, type Writable } from 'svelte/store';
|
||||||
|
import { isSelected, remove, removeAll } from './utils';
|
||||||
|
import { overlays, overlayTree } from '$lib/assets/layers';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { map } from '$lib/components/map/map';
|
||||||
|
|
||||||
|
const { currentOverlays, previousOverlays, selectedOverlayTree } = settings;
|
||||||
|
|
||||||
|
export type CustomOverlay = {
|
||||||
|
extensionName: string;
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
tileUrls: string[];
|
||||||
|
maxZoom?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ExtensionAPI {
|
||||||
|
private _overlays: Writable<Map<string, CustomOverlay>> = writable(new Map());
|
||||||
|
|
||||||
|
init() {
|
||||||
|
if (browser && !window.hasOwnProperty('gpxstudio')) {
|
||||||
|
Object.defineProperty(window, 'gpxstudio', {
|
||||||
|
value: this,
|
||||||
|
});
|
||||||
|
addEventListener('beforeunload', () => {
|
||||||
|
this.destroy();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureLoaded(): Promise<void> {
|
||||||
|
let unsubscribe: () => void;
|
||||||
|
const promise = new Promise<void>((resolve) => {
|
||||||
|
map.onLoad(() => {
|
||||||
|
unsubscribe = currentOverlays.subscribe((current) => {
|
||||||
|
if (current) {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
promise.finally(() => {
|
||||||
|
unsubscribe?.();
|
||||||
|
});
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
addOrUpdateOverlay(overlay: CustomOverlay) {
|
||||||
|
if (
|
||||||
|
!overlay.extensionName ||
|
||||||
|
!overlay.id ||
|
||||||
|
!overlay.name ||
|
||||||
|
!overlay.tileUrls ||
|
||||||
|
overlay.tileUrls.length === 0
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'Overlay must have an extensionName, id, name, and at least one tile URL.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
overlay.id = this.getOverlayId(overlay.id);
|
||||||
|
|
||||||
|
this._overlays.update(($overlays) => {
|
||||||
|
$overlays.set(overlay.id, overlay);
|
||||||
|
return $overlays;
|
||||||
|
});
|
||||||
|
|
||||||
|
overlays[overlay.id] = {
|
||||||
|
version: 8,
|
||||||
|
sources: {
|
||||||
|
[overlay.id]: {
|
||||||
|
type: 'raster',
|
||||||
|
tiles: overlay.tileUrls,
|
||||||
|
tileSize: overlay.tileUrls.some((url) => url.includes('512')) ? 512 : 256,
|
||||||
|
maxzoom: overlay.maxZoom ?? 22,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
id: overlay.id,
|
||||||
|
type: 'raster',
|
||||||
|
source: overlay.id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!overlayTree.overlays.hasOwnProperty(overlay.extensionName)) {
|
||||||
|
overlayTree.overlays[overlay.extensionName] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
overlayTree.overlays[overlay.extensionName][overlay.id] = true;
|
||||||
|
|
||||||
|
selectedOverlayTree.update((selected) => {
|
||||||
|
if (!selected.overlays.hasOwnProperty(overlay.extensionName)) {
|
||||||
|
selected.overlays[overlay.extensionName] = {};
|
||||||
|
}
|
||||||
|
selected.overlays[overlay.extensionName][overlay.id] = true;
|
||||||
|
return selected;
|
||||||
|
});
|
||||||
|
|
||||||
|
const current = get(currentOverlays);
|
||||||
|
let show = false;
|
||||||
|
if (current && isSelected(current, overlay.id)) {
|
||||||
|
show = true;
|
||||||
|
try {
|
||||||
|
get(map)?.removeImport(overlay.id);
|
||||||
|
} catch (e) {
|
||||||
|
// No reliable way to check if the map is ready to remove sources and layers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentOverlays.update((current) => {
|
||||||
|
if (!current.overlays.hasOwnProperty(overlay.extensionName)) {
|
||||||
|
current.overlays[overlay.extensionName] = {};
|
||||||
|
}
|
||||||
|
current.overlays[overlay.extensionName][overlay.id] = show;
|
||||||
|
return current;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
filterOverlays(ids: string[]) {
|
||||||
|
ids = ids.map((id) => this.getOverlayId(id));
|
||||||
|
const idsToRemove = Array.from(get(this._overlays).keys()).filter(
|
||||||
|
(id) => !ids.includes(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
currentOverlays.update((current) => {
|
||||||
|
removeAll(current, idsToRemove);
|
||||||
|
return current;
|
||||||
|
});
|
||||||
|
previousOverlays.update((previous) => {
|
||||||
|
removeAll(previous, idsToRemove);
|
||||||
|
return previous;
|
||||||
|
});
|
||||||
|
selectedOverlayTree.update((selected) => {
|
||||||
|
removeAll(selected, idsToRemove);
|
||||||
|
return selected;
|
||||||
|
});
|
||||||
|
Object.keys(overlays).forEach((id) => {
|
||||||
|
if (idsToRemove.includes(id)) {
|
||||||
|
delete overlays[id];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
removeAll(overlayTree, idsToRemove);
|
||||||
|
this._overlays.update(($overlays) => {
|
||||||
|
$overlays.forEach((_, id) => {
|
||||||
|
if (idsToRemove.includes(id)) {
|
||||||
|
$overlays.delete(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return $overlays;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateOverlaysOrder(ids: string[]) {
|
||||||
|
ids = ids.map((id) => this.getOverlayId(id));
|
||||||
|
selectedOverlayTree.update((selected) => {
|
||||||
|
let isSelected: Record<string, boolean> = {};
|
||||||
|
ids.forEach((id) => {
|
||||||
|
const overlay = get(this._overlays).get(id);
|
||||||
|
if (
|
||||||
|
overlay &&
|
||||||
|
selected.overlays.hasOwnProperty(overlay.extensionName) &&
|
||||||
|
selected.overlays[overlay.extensionName].hasOwnProperty(id)
|
||||||
|
) {
|
||||||
|
isSelected[id] = selected.overlays[overlay.extensionName][id];
|
||||||
|
delete selected.overlays[overlay.extensionName][id];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Object.entries(isSelected).forEach(([id, value]) => {
|
||||||
|
const overlay = get(this._overlays).get(id)!;
|
||||||
|
selected.overlays[overlay.extensionName][id] = value;
|
||||||
|
});
|
||||||
|
return selected;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
isLayerFromExtension = derived(this._overlays, ($overlays) => {
|
||||||
|
return (id: string) => $overlays.has(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
getLayerName = derived(this._overlays, ($overlays) => {
|
||||||
|
return (id: string) => $overlays.get(id)?.name || '';
|
||||||
|
});
|
||||||
|
|
||||||
|
private getOverlayId(id: string): string {
|
||||||
|
return `extension-${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private destroy() {
|
||||||
|
const ids = Array.from(get(this._overlays).keys());
|
||||||
|
currentOverlays.update((current) => {
|
||||||
|
ids.forEach((id) => {
|
||||||
|
remove(current, id);
|
||||||
|
});
|
||||||
|
return current;
|
||||||
|
});
|
||||||
|
previousOverlays.update((previous) => {
|
||||||
|
ids.forEach((id) => {
|
||||||
|
remove(previous, id);
|
||||||
|
});
|
||||||
|
return previous;
|
||||||
|
});
|
||||||
|
selectedOverlayTree.update((selected) => {
|
||||||
|
ids.forEach((id) => {
|
||||||
|
remove(selected, id);
|
||||||
|
});
|
||||||
|
return selected;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const extensionAPI = new ExtensionAPI();
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import SphericalMercator from '@mapbox/sphericalmercator';
|
import { SphericalMercator } from '@mapbox/sphericalmercator';
|
||||||
import { getLayers } from './utils';
|
import { getLayers } from './utils';
|
||||||
import { get, writable } from 'svelte/store';
|
import { get, writable } from 'svelte/store';
|
||||||
import { liveQuery } from 'dexie';
|
import { liveQuery } from 'dexie';
|
||||||
import { db, settings } from '$lib/db';
|
|
||||||
import { overpassQueryData } from '$lib/assets/layers';
|
import { overpassQueryData } from '$lib/assets/layers';
|
||||||
import { MapPopup } from '$lib/components/MapPopup';
|
import { MapPopup } from '$lib/components/map/map-popup';
|
||||||
|
import { settings } from '$lib/logic/settings';
|
||||||
|
import { db } from '$lib/db';
|
||||||
|
|
||||||
const { currentOverpassQueries } = settings;
|
const { currentOverpassQueries } = settings;
|
||||||
|
|
||||||
@@ -60,8 +61,10 @@ export class OverpassLayer {
|
|||||||
|
|
||||||
queryIfNeeded() {
|
queryIfNeeded() {
|
||||||
if (this.map.getZoom() >= this.minZoom) {
|
if (this.map.getZoom() >= this.minZoom) {
|
||||||
const bounds = this.map.getBounds().toArray();
|
const bounds = this.map.getBounds()?.toArray();
|
||||||
this.query([bounds[0][0], bounds[0][1], bounds[1][0], bounds[1][1]]);
|
if (bounds) {
|
||||||
|
this.query([bounds[0][0], bounds[0][1], bounds[1][0], bounds[1][1]]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +74,7 @@ export class OverpassLayer {
|
|||||||
let d = get(data);
|
let d = get(data);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let source = this.map.getSource('overpass');
|
let source = this.map.getSource('overpass') as mapboxgl.GeoJSONSource | undefined;
|
||||||
if (source) {
|
if (source) {
|
||||||
source.setData(d);
|
source.setData(d);
|
||||||
} else {
|
} else {
|
||||||
@@ -98,7 +101,9 @@ export class OverpassLayer {
|
|||||||
this.map.on('click', 'overpass', this.onHoverBinded);
|
this.map.on('click', 'overpass', this.onHoverBinded);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.map.setFilter('overpass', ['in', 'query', ...getCurrentQueries()]);
|
this.map.setFilter('overpass', ['in', 'query', ...getCurrentQueries()], {
|
||||||
|
validate: false,
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// No reliable way to check if the map is ready to add sources and layers
|
// No reliable way to check if the map is ready to add sources and layers
|
||||||
}
|
}
|
||||||
@@ -281,9 +286,9 @@ function getQuery(query: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getQueryItem(tags: Record<string, string | boolean | string[]>) {
|
function getQueryItem(tags: Record<string, string | boolean | string[]>) {
|
||||||
let arrayEntry = Object.entries(tags).find(([_, value]) => Array.isArray(value));
|
let arrayEntry = Object.values(tags).find((value) => Array.isArray(value));
|
||||||
if (arrayEntry !== undefined) {
|
if (arrayEntry !== undefined) {
|
||||||
return arrayEntry[1]
|
return arrayEntry
|
||||||
.map(
|
.map(
|
||||||
(val) =>
|
(val) =>
|
||||||
`nwr${Object.entries(tags)
|
`nwr${Object.entries(tags)
|
||||||
@@ -55,4 +55,26 @@ export function toggle(node: LayerTreeType, id: string) {
|
|||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function remove(node: LayerTreeType, id: string) {
|
||||||
|
Object.keys(node).forEach((key) => {
|
||||||
|
if (key === id) {
|
||||||
|
delete node[key];
|
||||||
|
} else if (typeof node[key] !== 'boolean') {
|
||||||
|
remove(node[key], id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeAll(node: LayerTreeType, ids: string[]) {
|
||||||
|
Object.keys(node).forEach((key) => {
|
||||||
|
if (ids.includes(key)) {
|
||||||
|
delete node[key];
|
||||||
|
} else if (typeof node[key] !== 'boolean') {
|
||||||
|
removeAll(node[key], ids);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
export const customBasemapUpdate = writable(0);
|
export const customBasemapUpdate = writable(0);
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { TrackPoint, Waypoint } from 'gpx';
|
import { TrackPoint, Waypoint } from 'gpx';
|
||||||
import mapboxgl from 'mapbox-gl';
|
import mapboxgl from 'mapbox-gl';
|
||||||
import { tick } from 'svelte';
|
import { mount, tick, unmount } from 'svelte';
|
||||||
import { get, writable, type Writable } from 'svelte/store';
|
import { get, writable, type Writable } from 'svelte/store';
|
||||||
import MapPopupComponent from './MapPopup.svelte';
|
import MapPopupComponent from '$lib/components/map/MapPopup.svelte';
|
||||||
|
|
||||||
export type PopupItem<T = Waypoint | TrackPoint | any> = {
|
export type PopupItem<T = Waypoint | TrackPoint | any> = {
|
||||||
item: T;
|
item: T;
|
||||||
@@ -14,20 +14,21 @@ export class MapPopup {
|
|||||||
map: mapboxgl.Map;
|
map: mapboxgl.Map;
|
||||||
popup: mapboxgl.Popup;
|
popup: mapboxgl.Popup;
|
||||||
item: Writable<PopupItem | null> = writable(null);
|
item: Writable<PopupItem | null> = writable(null);
|
||||||
|
component: ReturnType<typeof mount>;
|
||||||
maybeHideBinded = this.maybeHide.bind(this);
|
maybeHideBinded = this.maybeHide.bind(this);
|
||||||
|
|
||||||
constructor(map: mapboxgl.Map, options?: mapboxgl.PopupOptions) {
|
constructor(map: mapboxgl.Map, options?: mapboxgl.PopupOptions) {
|
||||||
this.map = map;
|
this.map = map;
|
||||||
this.popup = new mapboxgl.Popup(options);
|
this.popup = new mapboxgl.Popup(options);
|
||||||
|
this.component = mount(MapPopupComponent, {
|
||||||
let component = new MapPopupComponent({
|
|
||||||
target: document.body,
|
target: document.body,
|
||||||
props: {
|
props: {
|
||||||
item: this.item,
|
item: this.item,
|
||||||
|
onContainerReady: (container: HTMLDivElement) => {
|
||||||
|
this.popup.setDOMContent(container);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
tick().then(() => this.popup.setDOMContent(component.container));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setItem(item: PopupItem | null) {
|
setItem(item: PopupItem | null) {
|
||||||
@@ -41,8 +42,8 @@ export class MapPopup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
show() {
|
show() {
|
||||||
const i = get(this.item);
|
const item = get(this.item);
|
||||||
if (i === null) {
|
if (item === null) {
|
||||||
this.hide();
|
this.hide();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -51,8 +52,8 @@ export class MapPopup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
maybeHide(e: mapboxgl.MapMouseEvent) {
|
maybeHide(e: mapboxgl.MapMouseEvent) {
|
||||||
const i = get(this.item);
|
const item = get(this.item);
|
||||||
if (i === null) {
|
if (item === null) {
|
||||||
this.hide();
|
this.hide();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -68,15 +69,16 @@ export class MapPopup {
|
|||||||
|
|
||||||
remove() {
|
remove() {
|
||||||
this.popup.remove();
|
this.popup.remove();
|
||||||
|
unmount(this.component);
|
||||||
}
|
}
|
||||||
|
|
||||||
getCoordinates() {
|
getCoordinates() {
|
||||||
const i = get(this.item);
|
const item = get(this.item);
|
||||||
if (i === null) {
|
if (item === null) {
|
||||||
return new mapboxgl.LngLat(0, 0);
|
return new mapboxgl.LngLat(0, 0);
|
||||||
}
|
}
|
||||||
return i.item instanceof Waypoint || i.item instanceof TrackPoint
|
return item.item instanceof Waypoint || item.item instanceof TrackPoint
|
||||||
? i.item.getCoordinates()
|
? item.item.getCoordinates()
|
||||||
: new mapboxgl.LngLat(i.item.lon, i.item.lat);
|
: new mapboxgl.LngLat(item.item.lon, item.item.lat);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
227
website/src/lib/components/map/map.ts
Normal file
227
website/src/lib/components/map/map.ts
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import mapboxgl from 'mapbox-gl';
|
||||||
|
import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder';
|
||||||
|
import { get, writable, type Writable } from 'svelte/store';
|
||||||
|
import { settings } from '$lib/logic/settings';
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
|
||||||
|
const { treeFileView, elevationProfile, bottomPanelSize, rightPanelSize, distanceUnits } = settings;
|
||||||
|
|
||||||
|
let fitBoundsOptions: mapboxgl.MapOptions['fitBoundsOptions'] = {
|
||||||
|
maxZoom: 15,
|
||||||
|
linear: true,
|
||||||
|
easing: () => 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
export class MapboxGLMap {
|
||||||
|
private _map: Writable<mapboxgl.Map | null> = writable(null);
|
||||||
|
private _onLoadCallbacks: ((map: mapboxgl.Map) => void)[] = [];
|
||||||
|
private _unsubscribes: (() => void)[] = [];
|
||||||
|
|
||||||
|
subscribe(run: (value: mapboxgl.Map | null) => void, invalidate?: () => void) {
|
||||||
|
return this._map.subscribe(run, invalidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
accessToken: string,
|
||||||
|
language: string,
|
||||||
|
hash: boolean,
|
||||||
|
geocoder: boolean,
|
||||||
|
geolocate: boolean
|
||||||
|
) {
|
||||||
|
const map = new mapboxgl.Map({
|
||||||
|
container: 'map',
|
||||||
|
style: {
|
||||||
|
version: 8,
|
||||||
|
sources: {},
|
||||||
|
layers: [],
|
||||||
|
imports: [
|
||||||
|
{
|
||||||
|
id: 'glyphs-and-sprite', // make Mapbox glyphs and sprite available to other styles
|
||||||
|
url: '',
|
||||||
|
data: {
|
||||||
|
version: 8,
|
||||||
|
sources: {},
|
||||||
|
layers: [],
|
||||||
|
glyphs: 'mapbox://fonts/mapbox/{fontstack}/{range}.pbf',
|
||||||
|
sprite: 'mapbox://sprites/mapbox/outdoors-v12',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
map.addControl(
|
||||||
|
new mapboxgl.AttributionControl({
|
||||||
|
compact: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
map.addControl(
|
||||||
|
new mapboxgl.NavigationControl({
|
||||||
|
visualizePitch: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
if (geocoder) {
|
||||||
|
let geocoder = new MapboxGeocoder({
|
||||||
|
mapboxgl: mapboxgl,
|
||||||
|
enableEventLogging: false,
|
||||||
|
collapsed: true,
|
||||||
|
flyTo: fitBoundsOptions,
|
||||||
|
language,
|
||||||
|
localGeocoder: () => [],
|
||||||
|
localGeocoderOnly: true,
|
||||||
|
externalGeocoder: (query: string) =>
|
||||||
|
fetch(
|
||||||
|
`https://nominatim.openstreetmap.org/search?format=json&q=${query}&limit=5&accept-language=${language}`
|
||||||
|
)
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((data) => {
|
||||||
|
return data.map((result: any) => {
|
||||||
|
return {
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: {
|
||||||
|
type: 'Point',
|
||||||
|
coordinates: [result.lon, result.lat],
|
||||||
|
},
|
||||||
|
place_name: result.display_name,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
let onKeyDown = geocoder._onKeyDown;
|
||||||
|
geocoder._onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
// Trigger search on Enter key only
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
onKeyDown.apply(geocoder, [{ target: geocoder._inputEl }]);
|
||||||
|
} else if (geocoder._typeahead.data.length > 0) {
|
||||||
|
geocoder._typeahead.clear();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
map.addControl(geocoder);
|
||||||
|
}
|
||||||
|
if (geolocate) {
|
||||||
|
map.addControl(
|
||||||
|
new mapboxgl.GeolocateControl({
|
||||||
|
positionOptions: {
|
||||||
|
enableHighAccuracy: true,
|
||||||
|
},
|
||||||
|
fitBoundsOptions,
|
||||||
|
trackUserLocation: true,
|
||||||
|
showUserHeading: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const scaleControl = new mapboxgl.ScaleControl({
|
||||||
|
unit: get(distanceUnits),
|
||||||
|
});
|
||||||
|
map.addControl(scaleControl);
|
||||||
|
map.on('style.load', () => {
|
||||||
|
map.addSource('mapbox-dem', {
|
||||||
|
type: 'raster-dem',
|
||||||
|
url: 'mapbox://mapbox.mapbox-terrain-dem-v1',
|
||||||
|
tileSize: 512,
|
||||||
|
maxzoom: 14,
|
||||||
|
});
|
||||||
|
if (map.getPitch() > 0) {
|
||||||
|
map.setTerrain({
|
||||||
|
source: 'mapbox-dem',
|
||||||
|
exaggeration: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
map.setFog({
|
||||||
|
color: 'rgb(186, 210, 235)',
|
||||||
|
'high-color': 'rgb(36, 92, 223)',
|
||||||
|
'horizon-blend': 0.1,
|
||||||
|
'space-color': 'rgb(156, 240, 255)',
|
||||||
|
});
|
||||||
|
map.on('pitch', () => {
|
||||||
|
if (map.getPitch() > 0) {
|
||||||
|
map.setTerrain({
|
||||||
|
source: 'mapbox-dem',
|
||||||
|
exaggeration: 1,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
map.setTerrain(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
map.on('load', () => {
|
||||||
|
this._map.set(map); // only set the store after the map has loaded
|
||||||
|
window._map = map; // entry point for extensions
|
||||||
|
this.resize();
|
||||||
|
scaleControl.setUnit(get(distanceUnits));
|
||||||
|
|
||||||
|
this._onLoadCallbacks.forEach((callback) => callback(map));
|
||||||
|
this._onLoadCallbacks = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
this._unsubscribes.push(treeFileView.subscribe(() => this.resize()));
|
||||||
|
this._unsubscribes.push(elevationProfile.subscribe(() => this.resize()));
|
||||||
|
this._unsubscribes.push(bottomPanelSize.subscribe(() => this.resize()));
|
||||||
|
this._unsubscribes.push(rightPanelSize.subscribe(() => this.resize()));
|
||||||
|
this._unsubscribes.push(
|
||||||
|
distanceUnits.subscribe((units) => {
|
||||||
|
scaleControl.setUnit(units);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoad(callback: (map: mapboxgl.Map) => void) {
|
||||||
|
const map = get(this._map);
|
||||||
|
if (map) {
|
||||||
|
callback(map);
|
||||||
|
} else {
|
||||||
|
this._onLoadCallbacks.push(callback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
const map = get(this._map);
|
||||||
|
if (map) {
|
||||||
|
map.remove();
|
||||||
|
this._map.set(null);
|
||||||
|
}
|
||||||
|
this._unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||||
|
this._unsubscribes = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
resize() {
|
||||||
|
const map = get(this._map);
|
||||||
|
if (map) {
|
||||||
|
tick().then(() => {
|
||||||
|
map.resize();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle3D() {
|
||||||
|
const map = get(this._map);
|
||||||
|
if (map) {
|
||||||
|
if (map.getPitch() === 0) {
|
||||||
|
map.easeTo({ pitch: 70 });
|
||||||
|
} else {
|
||||||
|
map.easeTo({ pitch: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const map = new MapboxGLMap();
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { streetViewEnabled } from '$lib/components/map/street-view-control/utils';
|
||||||
|
import { map } from '$lib/components/map/map';
|
||||||
|
import CustomControl from '$lib/components/map/custom-control/CustomControl.svelte';
|
||||||
|
import { PersonStanding, X } from '@lucide/svelte';
|
||||||
|
import { MapillaryLayer } from './mapillary';
|
||||||
|
import { GoogleRedirect } from './google';
|
||||||
|
import { settings } from '$lib/logic/settings';
|
||||||
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import ButtonWithTooltip from '$lib/components/ButtonWithTooltip.svelte';
|
||||||
|
|
||||||
|
const { streetViewSource } = settings;
|
||||||
|
|
||||||
|
let googleRedirect: GoogleRedirect | null = $state(null);
|
||||||
|
let mapillaryLayer: MapillaryLayer | null = $state(null);
|
||||||
|
let mapillaryOpen = $state({
|
||||||
|
value: false,
|
||||||
|
});
|
||||||
|
let container: HTMLElement;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
map.onLoad((map: mapboxgl.Map) => {
|
||||||
|
googleRedirect = new GoogleRedirect(map);
|
||||||
|
mapillaryLayer = new MapillaryLayer(map, container, mapillaryOpen);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if ($streetViewSource === 'mapillary') {
|
||||||
|
googleRedirect?.remove();
|
||||||
|
if ($streetViewEnabled) {
|
||||||
|
mapillaryLayer?.add();
|
||||||
|
} else {
|
||||||
|
mapillaryLayer?.remove();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mapillaryLayer?.remove();
|
||||||
|
if ($streetViewEnabled) {
|
||||||
|
googleRedirect?.add();
|
||||||
|
} else {
|
||||||
|
googleRedirect?.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CustomControl class="w-[29px] h-[29px] shrink-0">
|
||||||
|
<ButtonWithTooltip
|
||||||
|
variant="ghost"
|
||||||
|
class="w-full h-full"
|
||||||
|
side="left"
|
||||||
|
label={i18n._('menu.toggle_street_view')}
|
||||||
|
onclick={() => {
|
||||||
|
$streetViewEnabled = !$streetViewEnabled;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PersonStanding
|
||||||
|
size="22"
|
||||||
|
class="size-5.5"
|
||||||
|
color={$streetViewEnabled ? '#33b5e5' : 'currentColor'}
|
||||||
|
/>
|
||||||
|
</ButtonWithTooltip>
|
||||||
|
</CustomControl>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={container}
|
||||||
|
class="{mapillaryOpen.value
|
||||||
|
? ''
|
||||||
|
: '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 -->
|
||||||
|
<div
|
||||||
|
class="absolute top-0 right-0 z-10 bg-background p-1 rounded-bl-md cursor-pointer"
|
||||||
|
onclick={() => {
|
||||||
|
if (mapillaryLayer) {
|
||||||
|
mapillaryLayer.closePopup();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size="16" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { resetCursor, setCrosshairCursor } from '$lib/utils';
|
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
||||||
import type mapboxgl from 'mapbox-gl';
|
import type mapboxgl from 'mapbox-gl';
|
||||||
|
|
||||||
export class GoogleRedirect {
|
export class GoogleRedirect {
|
||||||
@@ -13,7 +13,7 @@ export class GoogleRedirect {
|
|||||||
if (this.enabled) return;
|
if (this.enabled) return;
|
||||||
|
|
||||||
this.enabled = true;
|
this.enabled = true;
|
||||||
setCrosshairCursor();
|
mapCursor.notify(MapCursorState.STREET_VIEW_CROSSHAIR, true);
|
||||||
this.map.on('click', this.openStreetView);
|
this.map.on('click', this.openStreetView);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,11 +21,11 @@ export class GoogleRedirect {
|
|||||||
if (!this.enabled) return;
|
if (!this.enabled) return;
|
||||||
|
|
||||||
this.enabled = false;
|
this.enabled = false;
|
||||||
resetCursor();
|
mapCursor.notify(MapCursorState.STREET_VIEW_CROSSHAIR, false);
|
||||||
this.map.off('click', this.openStreetView);
|
this.map.off('click', this.openStreetView);
|
||||||
}
|
}
|
||||||
|
|
||||||
openStreetView(e) {
|
openStreetView(e: mapboxgl.MapMouseEvent) {
|
||||||
window.open(
|
window.open(
|
||||||
`https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=${e.lngLat.lat},${e.lngLat.lng}`
|
`https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=${e.lngLat.lat},${e.lngLat.lng}`
|
||||||
);
|
);
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import mapboxgl, { type LayerSpecification, type VectorSourceSpecification } from 'mapbox-gl';
|
import mapboxgl, { type LayerSpecification, type VectorSourceSpecification } from 'mapbox-gl';
|
||||||
import { Viewer, type ViewerBearingEvent } from 'mapillary-js/dist/mapillary.module';
|
import { Viewer, type ViewerBearingEvent } from 'mapillary-js/dist/mapillary.module';
|
||||||
import 'mapillary-js/dist/mapillary.css';
|
import 'mapillary-js/dist/mapillary.css';
|
||||||
import { resetCursor, setPointerCursor } from '$lib/utils';
|
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
||||||
import type { Writable } from 'svelte/store';
|
|
||||||
|
|
||||||
const mapillarySource: VectorSourceSpecification = {
|
const mapillarySource: VectorSourceSpecification = {
|
||||||
type: 'vector',
|
type: 'vector',
|
||||||
@@ -47,13 +46,13 @@ export class MapillaryLayer {
|
|||||||
viewer: Viewer;
|
viewer: Viewer;
|
||||||
|
|
||||||
active = false;
|
active = false;
|
||||||
popupOpen: Writable<boolean>;
|
popupOpen: { value: boolean };
|
||||||
|
|
||||||
addBinded = this.add.bind(this);
|
addBinded = this.add.bind(this);
|
||||||
onMouseEnterBinded = this.onMouseEnter.bind(this);
|
onMouseEnterBinded = this.onMouseEnter.bind(this);
|
||||||
onMouseLeaveBinded = this.onMouseLeave.bind(this);
|
onMouseLeaveBinded = this.onMouseLeave.bind(this);
|
||||||
|
|
||||||
constructor(map: mapboxgl.Map, container: HTMLElement, popupOpen: Writable<boolean>) {
|
constructor(map: mapboxgl.Map, container: HTMLElement, popupOpen: { value: boolean }) {
|
||||||
this.map = map;
|
this.map = map;
|
||||||
|
|
||||||
this.viewer = new Viewer({
|
this.viewer = new Viewer({
|
||||||
@@ -77,7 +76,7 @@ export class MapillaryLayer {
|
|||||||
|
|
||||||
this.viewer.on('position', async () => {
|
this.viewer.on('position', async () => {
|
||||||
if (this.active) {
|
if (this.active) {
|
||||||
popupOpen.set(true);
|
popupOpen.value = true;
|
||||||
let latLng = await this.viewer.getPosition();
|
let latLng = await this.viewer.getPosition();
|
||||||
this.marker.setLngLat(latLng).addTo(this.map);
|
this.marker.setLngLat(latLng).addTo(this.map);
|
||||||
if (!this.map.getBounds()?.contains(latLng)) {
|
if (!this.map.getBounds()?.contains(latLng)) {
|
||||||
@@ -126,25 +125,32 @@ export class MapillaryLayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.marker.remove();
|
this.marker.remove();
|
||||||
this.popupOpen.set(false);
|
this.popupOpen.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
closePopup() {
|
closePopup() {
|
||||||
this.active = false;
|
this.active = false;
|
||||||
this.marker.remove();
|
this.marker.remove();
|
||||||
this.popupOpen.set(false);
|
this.popupOpen.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
onMouseEnter(e: mapboxgl.MapMouseEvent) {
|
onMouseEnter(e: mapboxgl.MapMouseEvent) {
|
||||||
this.active = true;
|
if (
|
||||||
|
e.features &&
|
||||||
|
e.features.length > 0 &&
|
||||||
|
e.features[0].properties &&
|
||||||
|
e.features[0].properties.id
|
||||||
|
) {
|
||||||
|
this.active = true;
|
||||||
|
|
||||||
this.viewer.resize();
|
this.viewer.resize();
|
||||||
this.viewer.moveTo(e.features[0].properties.id);
|
this.viewer.moveTo(e.features[0].properties.id);
|
||||||
|
|
||||||
setPointerCursor();
|
mapCursor.notify(MapCursorState.MAPILLARY_HOVER, true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMouseLeave() {
|
onMouseLeave() {
|
||||||
resetCursor();
|
mapCursor.notify(MapCursorState.MAPILLARY_HOVER, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
|
export const streetViewEnabled = writable(false);
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
<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, mapillaryOpen);
|
|
||||||
}
|
|
||||||
|
|
||||||
$: if (mapillaryLayer) {
|
|
||||||
if ($streetViewSource === 'mapillary') {
|
|
||||||
googleRedirect.remove();
|
|
||||||
if ($streetViewEnabled) {
|
|
||||||
mapillaryLayer.add();
|
|
||||||
} else {
|
|
||||||
mapillaryLayer.remove();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
mapillaryLayer.remove();
|
|
||||||
if ($streetViewEnabled) {
|
|
||||||
googleRedirect.add();
|
|
||||||
} else {
|
|
||||||
googleRedirect.remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<CustomControl class="w-[29px] h-[29px] shrink-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="{$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 -->
|
|
||||||
<div
|
|
||||||
class="absolute top-0 right-0 z-10 bg-background p-1 rounded-bl-md cursor-pointer"
|
|
||||||
on:click={() => {
|
|
||||||
if (mapillaryLayer) {
|
|
||||||
mapillaryLayer.closePopup();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<X size="16" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user