17 Commits
Author SHA1 Message Date
vcoppe b719fe908f fix slow elevation profile tooltip 2026-11-11 11:06:39 +01:00
vcoppe 8ef3eca819 replace deprecated method 2026-11-11 09:32:28 +01:00
vcoppe 0e2a5a39df New Crowdin updates (#405)
* New translations en.json (Spanish)

[ci skip]

* New translations en.json (Spanish)

[ci skip]

* New translations edit.mdx (Spanish)

[ci skip]

* New translations en.json (Romanian)

[ci skip]

* New translations en.json (French)

[ci skip]

* New translations en.json (Spanish)

[ci skip]

* New translations en.json (Belarusian)

[ci skip]

* New translations en.json (Catalan)

[ci skip]

* New translations en.json (Czech)

[ci skip]

* New translations en.json (Danish)

[ci skip]

* New translations en.json (German)

[ci skip]

* New translations en.json (Greek)

[ci skip]

* New translations en.json (Basque)

[ci skip]

* New translations en.json (Finnish)

[ci skip]

* New translations en.json (Hebrew)

[ci skip]

* New translations en.json (Hungarian)

[ci skip]

* New translations en.json (Italian)

[ci skip]

* New translations en.json (Korean)

[ci skip]

* New translations en.json (Lithuanian)

[ci skip]

* New translations en.json (Dutch)

[ci skip]

* New translations en.json (Norwegian)

[ci skip]

* New translations en.json (Polish)

[ci skip]

* New translations en.json (Portuguese)

[ci skip]

* New translations en.json (Russian)

[ci skip]

* New translations en.json (Swedish)

[ci skip]

* New translations en.json (Turkish)

[ci skip]

* New translations en.json (Ukrainian)

[ci skip]

* New translations en.json (Chinese Simplified)

[ci skip]

* New translations en.json (Vietnamese)

[ci skip]

* New translations en.json (Portuguese, Brazilian)

[ci skip]

* New translations en.json (Indonesian)

[ci skip]

* New translations en.json (Thai)

[ci skip]

* New translations en.json (Latvian)

[ci skip]

* New translations en.json (Chinese Traditional, Hong Kong)

[ci skip]

* New translations en.json (Serbian (Latin))

[ci skip]

* New translations en.json (Basque)

[ci skip]

* New translations en.json (Basque)

[ci skip]

* New translations en.json (Dutch)

[ci skip]

* New translations en.json (French)

[ci skip]

* New translations en.json (Lithuanian)

[ci skip]

* New translations en.json (Lithuanian)

[ci skip]

* New translations en.json (Spanish)

[ci skip]
2026-11-11 09:23:41 +01:00
vcoppe 59c9427778 rename extension api event 2026-11-08 17:42:13 +01:00
vcoppe 57c34b388b filter out extension layers on load 2026-11-08 17:34:35 +01:00
vcoppe 4050420b3c extension api v2, ref #395 2026-11-08 17:23:22 +01:00
vcoppe 7474f892ce cleaning 2026-11-08 15:32:12 +01:00
vcoppe 002fd259aa hide point info in routing and scissors tools 2026-11-08 15:15:16 +01:00
vcoppe 21e27a4416 show hovered point on elevation profile 2026-11-08 15:06:03 +01:00
b80fa7d942 fix a click on the temporary anchor (#397)
Hovering a line in the routing tool shows a temporary anchor, which is
drawn in the anchors' layer with the index one past the last anchor.
Clicking it reached onClick, which looked that index up in the anchors,
got undefined and threw reading its properties. The click now adds the
anchor, as a click on the line does.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: vcoppe <vianney.coppe@gmail.com>
2026-11-08 14:26:58 +01:00
Nathan Cahillandvcoppe 2608870505 fix a latitude typed past ±90 in the waypoint tool (#398)
The pin followed the latitude and longitude fields on every keystroke,
and min/max on a number field don't stop typing, so a value on the way
to a coordinate (389) reached Marker.setLngLat and MapLibre threw
"Invalid LngLat latitude value". An empty field moved the pin to the
equator, saving with one threw on null.toFixed, and an out-of-range
value saved as typed.

The pin now stays where it was and Save is disabled until both fields
hold a coordinate in range, and the field that doesn't is marked
invalid.

Co-authored-by: vcoppe <vianney.coppe@gmail.com>
2026-11-08 14:17:45 +01:00
vcoppe 9e289c33be rework time component 2026-11-08 14:03:48 +01:00
vcoppe 419489bcfe #206 continued 2026-11-07 14:31:24 +01:00
vcoppe 7a7749a9d3 closes #206 2026-11-05 20:29:23 +01:00
vcoppe eee76f05d4 batch of fixes 2026-11-04 21:27:14 +01:00
vcoppe a67b982b1c clean builds and add auto-updater 2026-11-04 20:02:18 +01:00
vcoppe c0afd1fea9 cloudflare build only 2026-11-04 18:46:23 +01:00
101 changed files with 3168 additions and 1011 deletions

No files matched your search

-67
View File
@@ -1,67 +0,0 @@
name: Deploy to Cloudflare Pages
on:
push:
branches: 'dev'
workflow_dispatch:
concurrency:
group: cloudflare-${{ github.ref }}
cancel-in-progress: true
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: website/package-lock.json
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- name: Install wasm-pack
uses: taiki-e/install-action@v2
with:
tool: wasm-pack
- name: Test gpx-rs
run: cargo test
- name: Build wasm
run: wasm-pack build --target bundler
working-directory: wasm
- name: Install dependencies for website
run: npm install --prefix website
- name: Test website
run: npm test --prefix website
- name: Create env file
run: echo PUBLIC_MAPTILER_KEY=${{ secrets.PUBLIC_MAPTILER_KEY }} >> website/.env
- name: Build website
env:
BASE_PATH: ''
run: npm run build --prefix website
- name: Deploy to Cloudflare Pages
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy website/build --project-name=${{ vars.CLOUDFLARE_PROJECT_NAME || 'gpxstudio' }} --branch=${{ github.ref_name }} --commit-dirty=true
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
+21 -29
View File
@@ -1,11 +1,21 @@
name: Deploy to GitHub Pages name: Deploy to Cloudflare Pages
on: on:
push: push:
branches: 'main' branches: ['main', 'dev']
workflow_dispatch:
concurrency:
group: cloudflare-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
build_site: deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v5 uses: actions/checkout@v5
@@ -41,35 +51,17 @@ jobs:
run: npm test --prefix website run: npm test --prefix website
- name: Create env file - name: Create env file
run: | run: echo PUBLIC_MAPTILER_KEY=${{ secrets.PUBLIC_MAPTILER_KEY }} >> website/.env
touch website/.env
echo PUBLIC_MAPTILER_KEY=${{ secrets.PUBLIC_MAPTILER_KEY }} >> website/.env
cat website/.env
- name: Build website - name: Build website
env: env:
BASE_PATH: '' BASE_PATH: ''
run: | run: npm run build --prefix website
npm run build --prefix website
- name: Upload Artifacts - name: Deploy to Cloudflare Pages
uses: actions/upload-pages-artifact@v4 uses: cloudflare/wrangler-action@v3
with: with:
path: 'website/build/' apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
deploy: command: pages deploy website/build --project-name=${{ vars.CLOUDFLARE_PROJECT_NAME || 'gpxstudio' }} --branch=${{ github.ref_name }} --commit-dirty=true
needs: build_site gitHubToken: ${{ secrets.GITHUB_TOKEN }}
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy
id: deployment
uses: actions/deploy-pages@v4
-124
View File
@@ -1,124 +0,0 @@
name: Desktop beta
on:
push:
branches: 'dev'
workflow_dispatch:
concurrency:
group: desktop-beta-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- platform: macos-latest
rust_targets: aarch64-apple-darwin,x86_64-apple-darwin,wasm32-unknown-unknown
args: --target universal-apple-darwin
bundles: target/universal-apple-darwin/release/bundle/dmg/*.dmg
# the oldest supported Ubuntu, so that the AppImage runs on older systems too
- platform: ubuntu-22.04
rust_targets: wasm32-unknown-unknown
args: ''
bundles: |
target/release/bundle/deb/*.deb
target/release/bundle/rpm/*.rpm
target/release/bundle/appimage/*.AppImage
- platform: windows-latest
rust_targets: wasm32-unknown-unknown
args: ''
bundles: |
target/release/bundle/msi/*.msi
target/release/bundle/nsis/*.exe
runs-on: ${{ matrix.platform }}
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Install Linux dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: website/package-lock.json
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.rust_targets }}
- name: Cache Rust build
uses: Swatinem/rust-cache@v2
- name: Install wasm-pack
uses: taiki-e/install-action@v2
with:
tool: wasm-pack
# the desktop app does not use it, but the website depends on its package
- name: Build wasm
run: wasm-pack build --target bundler
working-directory: wasm
- name: Install dependencies for website
run: npm install --prefix website
# the key would be readable in the app: it is left empty, so only the MapTiler layer fails
- name: Create env file
run: echo "PUBLIC_MAPTILER_KEY=" >> website/.env
- name: Build app
run: npx --yes @tauri-apps/cli@2 build ${{ matrix.args }}
working-directory: tauri
# after the build, which gives the app the website it embeds
- name: Test engine and app
run: cargo test -p gpx-engine -p gpx-studio-desktop
- name: Upload bundles
uses: actions/upload-artifact@v4
with:
name: bundles-${{ matrix.platform }}
path: ${{ matrix.bundles }}
if-no-files-found: error
# replaces the beta release only once every platform is built
release:
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
steps:
- name: Download bundles
uses: actions/download-artifact@v5
with:
pattern: bundles-*
path: bundles
merge-multiple: true
- name: Publish beta release
# the bundles of a platform are in a folder per format (deb, msi…)
run: |
mapfile -t files < <(find bundles -type f)
gh release delete desktop-beta --cleanup-tag --yes || true
gh release create desktop-beta "${files[@]}" \
--prerelease \
--target ${{ github.sha }} \
--title "Desktop beta" \
--notes "Beta of the desktop app, built from ${{ github.sha }} on the dev branch. The builds are not signed yet: macOS and Windows will warn before opening them."
+214
View File
@@ -0,0 +1,214 @@
name: Desktop release
# run by hand: from `main` for a release, from `dev` for a pre-release
on:
workflow_dispatch:
concurrency:
group: desktop-${{ github.ref }}
jobs:
# the release stays a draft until every platform is built and tested
prepare:
runs-on: ubuntu-latest
permissions:
contents: write
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
outputs:
version: ${{ steps.version.outputs.version }}
draft_tag: ${{ steps.version.outputs.draft_tag }}
release_id: ${{ steps.draft.outputs.release_id }}
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Choose version
id: version
run: |
base=$(jq -r .version tauri/tauri.conf.json)
if [[ ! "$base" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::The version in tauri.conf.json should be x.y.z, not $base"
exit 1
fi
case "$GITHUB_REF_NAME" in
main)
version=$base
draft_tag=desktop-v$base
if git ls-remote --exit-code --tags origin "refs/tags/$draft_tag"; then
echo "::error::$draft_tag is already released: bump the version in tauri.conf.json"
exit 1
fi
if [ -z "$(jq -r '.plugins.updater.pubkey' tauri/tauri.conf.json)" ]; then
echo "::error::The public key of the updater is missing from tauri.conf.json"
exit 1
fi
;;
dev)
version=$base-beta.$GITHUB_RUN_NUMBER
draft_tag=desktop-beta-$GITHUB_RUN_NUMBER
;;
*)
echo "::error::Desktop builds are made from main or dev, not $GITHUB_REF_NAME"
exit 1
;;
esac
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "draft_tag=$draft_tag" >> "$GITHUB_OUTPUT"
- name: Create draft release
id: draft
env:
VERSION: ${{ steps.version.outputs.version }}
DRAFT_TAG: ${{ steps.version.outputs.draft_tag }}
run: |
# what a previous attempt left: this tag is not released, so it can only be a draft
gh release delete "$DRAFT_TAG" --yes || true
if [ "$GITHUB_REF_NAME" = main ]; then
gh release create "$DRAFT_TAG" --draft --target "$GITHUB_SHA" \
--title "Desktop $VERSION" --generate-notes
else
gh release create "$DRAFT_TAG" --draft --prerelease --target "$GITHUB_SHA" \
--title "Desktop beta $VERSION" \
--notes "Beta of the desktop app, built from $GITHUB_SHA on the dev branch. The builds are not signed yet: macOS and Windows will warn before opening them."
fi
echo "release_id=$(gh release view "$DRAFT_TAG" --json databaseId --jq .databaseId)" >> "$GITHUB_OUTPUT"
build:
needs: prepare
strategy:
fail-fast: false
matrix:
include:
- platform: macos-latest
rust_targets: aarch64-apple-darwin,x86_64-apple-darwin,wasm32-unknown-unknown
args: --target universal-apple-darwin
# the oldest supported Ubuntu, so that the AppImage runs on older systems too
- platform: ubuntu-22.04
rust_targets: wasm32-unknown-unknown
args: ''
- platform: windows-latest
rust_targets: wasm32-unknown-unknown
args: ''
runs-on: ${{ matrix.platform }}
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Install Linux dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: website/package-lock.json
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.rust_targets }}
- name: Cache Rust build
uses: Swatinem/rust-cache@v2
- name: Install wasm-pack
uses: taiki-e/install-action@v2
with:
tool: wasm-pack
# the desktop app does not use it, but the website depends on its package
- name: Build wasm
run: wasm-pack build --target bundler
working-directory: wasm
- name: Install dependencies for website
run: npm install --prefix website
# the key would be readable in the app: it is left empty, so only the MapTiler layer fails
- name: Create env file
run: echo "PUBLIC_MAPTILER_KEY=" >> website/.env
# only the releases update themselves: they need the updater files, and the signing key
- name: Set version and updater files
shell: bash
env:
VERSION: ${{ needs.prepare.outputs.version }}
UPDATER: ${{ github.ref_name == 'main' }}
run: |
node -e "
const fs = require('fs');
const path = 'tauri/tauri.conf.json';
const config = JSON.parse(fs.readFileSync(path));
config.version = process.env.VERSION;
config.bundle.createUpdaterArtifacts = process.env.UPDATER === 'true';
fs.writeFileSync(path, JSON.stringify(config, null, 4));
"
- name: Build app and upload it to the draft
uses: tauri-apps/tauri-action@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ github.ref_name == 'main' && secrets.TAURI_SIGNING_PRIVATE_KEY || '' }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ github.ref_name == 'main' && secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD || '' }}
with:
projectPath: tauri
tauriScript: npx --yes @tauri-apps/cli@2
args: ${{ matrix.args }}
releaseId: ${{ needs.prepare.outputs.release_id }}
# given with the id for the addresses in `latest.json`
tagName: ${{ needs.prepare.outputs.draft_tag }}
uploadUpdaterJson: ${{ github.ref_name == 'main' }}
# after the build, which gives the app the website it embeds
- name: Test engine and app
run: cargo test -p gpx-engine -p gpx-studio-desktop
# `releases/latest`, which the updater reads, only changes here
publish:
needs: [prepare, build]
runs-on: ubuntu-latest
permissions:
contents: write
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
DRAFT_TAG: ${{ needs.prepare.outputs.draft_tag }}
steps:
# the builds of the platforms add themselves to it in parallel, and could overwrite each other
- name: Check updater file
if: github.ref_name == 'main'
run: |
gh release download "$DRAFT_TAG" --pattern latest.json
for platform in darwin-aarch64 darwin-x86_64 linux-x86_64 windows-x86_64; do
if ! jq -e --arg platform "$platform" '.platforms[$platform].signature | length > 0' latest.json; then
echo "::error::latest.json has no signed update for $platform"
exit 1
fi
done
- name: Publish release
if: github.ref_name == 'main'
run: gh release edit "$DRAFT_TAG" --draft=false --latest
- name: Replace beta release
if: github.ref_name == 'dev'
run: |
gh release delete desktop-beta --cleanup-tag --yes || true
gh release edit "$DRAFT_TAG" --tag desktop-beta --draft=false --prerelease --latest=false
Generated
+516 -9
View File
@@ -475,6 +475,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.45" version = "0.4.45"
@@ -537,6 +543,16 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.10.1" version = "0.10.1"
@@ -560,7 +576,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"core-foundation", "core-foundation 0.10.1",
"core-graphics-types", "core-graphics-types",
"foreign-types", "foreign-types",
"libc", "libc",
@@ -573,7 +589,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"core-foundation", "core-foundation 0.10.1",
"libc", "libc",
] ]
@@ -1020,6 +1036,16 @@ dependencies = [
"rustc_version", "rustc_version",
] ]
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.12" version = "0.1.12"
@@ -1287,6 +1313,17 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.3.4" version = "0.3.4"
@@ -1306,8 +1343,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"r-efi 6.0.0", "r-efi 6.0.0",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -1431,7 +1470,9 @@ dependencies = [
"tauri-build", "tauri-build",
"tauri-plugin-dialog", "tauri-plugin-dialog",
"tauri-plugin-opener", "tauri-plugin-opener",
"tauri-plugin-process",
"tauri-plugin-single-instance", "tauri-plugin-single-instance",
"tauri-plugin-updater",
"trash", "trash",
"uuid", "uuid",
] ]
@@ -1631,6 +1672,21 @@ dependencies = [
"want", "want",
] ]
[[package]]
name = "hyper-rustls"
version = "0.27.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
dependencies = [
"http",
"hyper",
"hyper-util",
"rustls",
"tokio",
"tokio-rustls",
"tower-service",
]
[[package]] [[package]]
name = "hyper-util" name = "hyper-util"
version = "0.1.21" version = "0.1.21"
@@ -1650,9 +1706,11 @@ dependencies = [
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"socket2", "socket2",
"system-configuration",
"tokio", "tokio",
"tower-service", "tower-service",
"tracing", "tracing",
"windows-registry",
] ]
[[package]] [[package]]
@@ -1990,6 +2048,36 @@ dependencies = [
"windows-sys 0.45.0", "windows-sys 0.45.0",
] ]
[[package]]
name = "jni"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
dependencies = [
"cfg-if",
"combine",
"jni-macros",
"jni-sys 0.4.1",
"log",
"simd_cesu8",
"thiserror 2.0.21",
"walkdir",
"windows-link",
]
[[package]]
name = "jni-macros"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"simd_cesu8",
"syn 2.0.118",
]
[[package]] [[package]]
name = "jni-sys" name = "jni-sys"
version = "0.3.1" version = "0.3.1"
@@ -2214,6 +2302,12 @@ dependencies = [
"walkdir", "walkdir",
] ]
[[package]]
name = "minisign-verify"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce"
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.9" version = "0.8.9"
@@ -2303,6 +2397,18 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nix"
version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags 2.13.2",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]] [[package]]
name = "notify" name = "notify"
version = "8.2.0" version = "8.2.0"
@@ -2529,6 +2635,18 @@ dependencies = [
"objc2-core-foundation", "objc2-core-foundation",
] ]
[[package]]
name = "objc2-osa-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
dependencies = [
"bitflags 2.13.2",
"objc2",
"objc2-app-kit",
"objc2-foundation",
]
[[package]] [[package]]
name = "objc2-quartz-core" name = "objc2-quartz-core"
version = "0.3.2" version = "0.3.2"
@@ -2609,6 +2727,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "openssl-probe"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1"
[[package]] [[package]]
name = "option-ext" name = "option-ext"
version = "0.2.0" version = "0.2.0"
@@ -2625,6 +2749,20 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "osakit"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
dependencies = [
"objc2",
"objc2-foundation",
"objc2-osa-kit",
"serde",
"serde_json",
"thiserror 2.0.21",
]
[[package]] [[package]]
name = "pango" name = "pango"
version = "0.18.3" version = "0.18.3"
@@ -3049,15 +3187,20 @@ dependencies = [
"http-body", "http-body",
"http-body-util", "http-body-util",
"hyper", "hyper",
"hyper-rustls",
"hyper-util", "hyper-util",
"js-sys", "js-sys",
"log", "log",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"serde", "serde",
"serde_json", "serde_json",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-rustls",
"tokio-util", "tokio-util",
"tower", "tower",
"tower-http", "tower-http",
@@ -3093,6 +3236,20 @@ dependencies = [
"windows-sys 0.60.2", "windows-sys 0.60.2",
] ]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "rustc-hash" name = "rustc-hash"
version = "2.1.3" version = "2.1.3"
@@ -3121,6 +3278,79 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "rustls"
version = "0.23.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4"
dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni 0.22.4",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-platform-verifier-android"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
[[package]]
name = "rustls-webpki"
version = "0.103.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.23" version = "1.0.23"
@@ -3136,6 +3366,15 @@ dependencies = [
"winapi-util", "winapi-util",
] ]
[[package]]
name = "schannel"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5"
dependencies = [
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "schemars" name = "schemars"
version = "0.8.22" version = "0.8.22"
@@ -3193,6 +3432,29 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.13.2",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "selectors" name = "selectors"
version = "0.38.0" version = "0.38.0"
@@ -3425,6 +3687,22 @@ version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "simd_cesu8"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
dependencies = [
"rustc_version",
"simdutf8",
]
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]] [[package]]
name = "siphasher" name = "siphasher"
version = "1.0.4" version = "1.0.4"
@@ -3546,6 +3824,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "swift-rs" name = "swift-rs"
version = "1.0.9" version = "1.0.9"
@@ -3609,6 +3893,27 @@ dependencies = [
"syn 3.0.6", "syn 3.0.6",
] ]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.13.2",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]] [[package]]
name = "system-deps" name = "system-deps"
version = "6.2.2" version = "6.2.2"
@@ -3630,7 +3935,7 @@ checksum = "f37f381f4e048e6cdf038b5705f8cf14ad108279d46eb968140a7b291aba9400"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"block2", "block2",
"core-foundation", "core-foundation 0.10.1",
"core-graphics", "core-graphics",
"crossbeam-channel", "crossbeam-channel",
"dbus", "dbus",
@@ -3640,7 +3945,7 @@ dependencies = [
"gdkwayland-sys", "gdkwayland-sys",
"gdkx11-sys", "gdkx11-sys",
"gtk", "gtk",
"jni", "jni 0.21.1",
"libc", "libc",
"log", "log",
"ndk", "ndk",
@@ -3673,6 +3978,17 @@ dependencies = [
"syn 2.0.118", "syn 2.0.118",
] ]
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]] [[package]]
name = "target-lexicon" name = "target-lexicon"
version = "0.12.16" version = "0.12.16"
@@ -3696,7 +4012,7 @@ dependencies = [
"gtk", "gtk",
"heck 0.5.0", "heck 0.5.0",
"http", "http",
"jni", "jni 0.21.1",
"libc", "libc",
"log", "log",
"mime", "mime",
@@ -3872,6 +4188,16 @@ dependencies = [
"zbus", "zbus",
] ]
[[package]]
name = "tauri-plugin-process"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74ae3d8549ae564c0397377373e262f340908eb23a5b9fbe03f2a00007dc0038"
dependencies = [
"tauri",
"tauri-plugin",
]
[[package]] [[package]]
name = "tauri-plugin-single-instance" name = "tauri-plugin-single-instance"
version = "2.5.3" version = "2.5.3"
@@ -3888,6 +4214,40 @@ dependencies = [
"zbus", "zbus",
] ]
[[package]]
name = "tauri-plugin-updater"
version = "2.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d957abbcb3f9f6c76d22a3a0f012f30880640d0bccb5634d2b12a9937e4c872b"
dependencies = [
"base64 0.23.1",
"dirs",
"flate2",
"futures-util",
"http",
"infer",
"libc",
"log",
"minisign-verify",
"osakit",
"percent-encoding",
"reqwest",
"rustls",
"semver",
"serde",
"serde_json",
"tar",
"tauri",
"tauri-plugin",
"tempfile",
"thiserror 2.0.21",
"time",
"tokio",
"url",
"windows-sys 0.61.2",
"zip",
]
[[package]] [[package]]
name = "tauri-runtime" name = "tauri-runtime"
version = "2.12.1" version = "2.12.1"
@@ -3898,7 +4258,7 @@ dependencies = [
"dpi", "dpi",
"gtk", "gtk",
"http", "http",
"jni", "jni 0.21.1",
"objc2", "objc2",
"objc2-ui-kit", "objc2-ui-kit",
"objc2-web-kit", "objc2-web-kit",
@@ -3921,7 +4281,7 @@ checksum = "7f9e7e0c9f80a5130773cfabb6df4dc924518fb1b2c923726966804634b57d22"
dependencies = [ dependencies = [
"gtk", "gtk",
"http", "http",
"jni", "jni 0.21.1",
"log", "log",
"objc2", "objc2",
"objc2-app-kit", "objc2-app-kit",
@@ -4110,6 +4470,16 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "tokio-rustls"
version = "0.26.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
dependencies = [
"rustls",
"tokio",
]
[[package]] [[package]]
name = "tokio-util" name = "tokio-util"
version = "0.7.20" version = "0.7.20"
@@ -4341,6 +4711,12 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "typed-path"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e"
[[package]] [[package]]
name = "typeid" name = "typeid"
version = "1.0.3" version = "1.0.3"
@@ -4376,6 +4752,12 @@ version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]] [[package]]
name = "url" name = "url"
version = "2.5.8" version = "2.5.8"
@@ -4675,6 +5057,15 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "webpki-root-certs"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "webview2-com" name = "webview2-com"
version = "0.39.1" version = "0.39.1"
@@ -4839,6 +5230,17 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link",
"windows-result",
"windows-strings",
]
[[package]] [[package]]
name = "windows-result" name = "windows-result"
version = "0.4.1" version = "0.4.1"
@@ -4866,6 +5268,15 @@ dependencies = [
"windows-targets 0.42.2", "windows-targets 0.42.2",
] ]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.60.2" version = "0.60.2"
@@ -4899,6 +5310,22 @@ dependencies = [
"windows_x86_64_msvc 0.42.2", "windows_x86_64_msvc 0.42.2",
] ]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]] [[package]]
name = "windows-targets" name = "windows-targets"
version = "0.53.5" version = "0.53.5"
@@ -4909,7 +5336,7 @@ dependencies = [
"windows_aarch64_gnullvm 0.53.1", "windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1", "windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1", "windows_i686_gnu 0.53.1",
"windows_i686_gnullvm", "windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1", "windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1", "windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1", "windows_x86_64_gnullvm 0.53.1",
@@ -4940,6 +5367,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]] [[package]]
name = "windows_aarch64_gnullvm" name = "windows_aarch64_gnullvm"
version = "0.53.1" version = "0.53.1"
@@ -4952,6 +5385,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.53.1" version = "0.53.1"
@@ -4964,12 +5403,24 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.53.1" version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]] [[package]]
name = "windows_i686_gnullvm" name = "windows_i686_gnullvm"
version = "0.53.1" version = "0.53.1"
@@ -4982,6 +5433,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.53.1" version = "0.53.1"
@@ -4994,6 +5451,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.53.1" version = "0.53.1"
@@ -5006,6 +5469,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.53.1" version = "0.53.1"
@@ -5018,6 +5487,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.53.1" version = "0.53.1"
@@ -5082,7 +5557,7 @@ dependencies = [
"gtk", "gtk",
"http", "http",
"javascriptcore-rs", "javascriptcore-rs",
"jni", "jni 0.21.1",
"libc", "libc",
"ndk", "ndk",
"objc2", "objc2",
@@ -5129,6 +5604,16 @@ dependencies = [
"pkg-config", "pkg-config",
] ]
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.3" version = "0.8.3"
@@ -5243,6 +5728,12 @@ dependencies = [
"synstructure", "synstructure",
] ]
[[package]]
name = "zeroize"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
[[package]] [[package]]
name = "zerotrie" name = "zerotrie"
version = "0.2.5" version = "0.2.5"
@@ -5276,6 +5767,22 @@ dependencies = [
"syn 3.0.6", "syn 3.0.6",
] ]
[[package]]
name = "zip"
version = "9.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eaeb7f16d2605b49aef586996ded29e65642fab7259a3077e537351df6d80426"
dependencies = [
"crc32fast",
"getrandom 0.4.3",
"indexmap 2.14.2",
"libc",
"memchr",
"nix",
"typed-path",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "zlib-rs" name = "zlib-rs"
version = "0.6.8" version = "0.6.8"
-20
View File
@@ -560,26 +560,6 @@ impl WaypointDetails {
} }
} }
#[derive(Serialize)]
pub struct TrackpointDetails {
lng: f64,
lat: f64,
ele: f64,
#[serde(skip_serializing_if = "Option::is_none")]
time: Option<f64>,
}
impl TrackpointDetails {
pub fn new(pt: &engine::Trackpoint) -> Self {
Self {
lng: pt.coordinates.lng,
lat: pt.coordinates.lat,
ele: pt.ele,
time: pt.time.map(|t| t as f64),
}
}
}
/// A trackpoint of the selection: what the statistics buffers do not hold for each trackpoint. /// A trackpoint of the selection: what the statistics buffers do not hold for each trackpoint.
/// Absent fields are unknown. /// Absent fields are unknown.
#[derive(Serialize)] #[derive(Serialize)]
-12
View File
@@ -63,18 +63,6 @@ pub fn waypoint<H: History>(
.map(WaypointDetails::new) .map(WaypointDetails::new)
} }
/// A trackpoint of a segment of a file, `None` if it does not exist.
pub fn trackpoint<H: History>(
engine: &Engine<H>,
file_id: Uuid,
segment_id: Uuid,
index: usize,
) -> Option<TrackpointDetails> {
engine
.trackpoint(&FileId(file_id), &engine::TrackSegmentId(segment_id), index)
.map(TrackpointDetails::new)
}
/// A file as GPX (UTF-8 bytes), `None` if it does not exist. The options say which data of the /// A file as GPX (UTF-8 bytes), `None` if it does not exist. The options say which data of the
/// trackpoints to keep. /// trackpoints to keep.
pub fn export_file<H: History>( pub fn export_file<H: History>(
+52 -20
View File
@@ -44,14 +44,24 @@ where
let last = trkseg.last_index().unwrap(); let last = trkseg.last_index().unwrap();
let mut kept = vec![(first, None)]; let mut kept = vec![(first, None)];
ramer_douglas_peucker_helper(trkseg, first, last, measure, epsilon, &mut kept); ramer_douglas_peucker_ranges(trkseg, first, last, measure, epsilon, &mut kept);
kept.push((last, None)); kept.push((last, None));
kept kept
} }
} }
} }
fn ramer_douglas_peucker_helper<M>( /// What is left to do for the points between two ends, in the order of the points.
enum Step {
/// Looks for the point to keep between these two.
Range(TrackSegmentIndex, TrackSegmentIndex),
/// Keeps a point, selected at this distance.
Keep(TrackSegmentIndex, f64),
}
/// Adds the points to keep between `start` and `end` to `kept`, in order. It does not recurse: a
/// track can make it go as deep as it has points, which would overflow the stack.
fn ramer_douglas_peucker_ranges<M>(
trkseg: &TrackSegment, trkseg: &TrackSegment,
start: TrackSegmentIndex, start: TrackSegmentIndex,
end: TrackSegmentIndex, end: TrackSegmentIndex,
@@ -61,28 +71,40 @@ fn ramer_douglas_peucker_helper<M>(
) where ) where
M: Fn(TrackSegmentIndex, TrackSegmentIndex, TrackSegmentIndex) -> f64, M: Fn(TrackSegmentIndex, TrackSegmentIndex, TrackSegmentIndex) -> f64,
{ {
let mut max_idx = None; let mut steps = vec![Step::Range(start, end)];
let mut max_dist = 0.0; while let Some(step) = steps.pop() {
let (start, end) = match step {
Step::Keep(idx, dist) => {
kept.push((idx, Some(dist)));
continue;
}
Step::Range(start, end) => (start, end),
};
let mut cur = trkseg.next_index(Some(start)); let mut max_idx = None;
while let Some(idx) = cur { let mut max_dist = 0.0;
if idx == end {
break; let mut cur = trkseg.next_index(Some(start));
while let Some(idx) = cur {
if idx == end {
break;
}
let dist = measure(start, end, idx);
if dist > max_dist {
max_idx = Some(idx);
max_dist = dist;
}
cur = trkseg.next_index(cur);
} }
let dist = measure(start, end, idx); if let Some(idx) = max_idx.filter(|_| max_dist > epsilon) {
if dist > max_dist { // the last one pushed is done first
max_idx = Some(idx); steps.push(Step::Range(idx, end));
max_dist = dist; steps.push(Step::Keep(idx, max_dist));
steps.push(Step::Range(start, idx));
} }
cur = trkseg.next_index(cur);
}
if let Some(idx) = max_idx.filter(|_| max_dist > epsilon) {
ramer_douglas_peucker_helper(trkseg, start, idx, measure, epsilon, kept);
kept.push((idx, Some(max_dist)));
ramer_douglas_peucker_helper(trkseg, idx, end, measure, epsilon, kept);
} }
} }
@@ -195,6 +217,16 @@ mod tests {
.collect() .collect()
} }
#[test]
fn test_goes_as_deep_as_the_segment_is_long_in_order() {
let n = 12_000;
let trkseg = segment(&vec![0.0; n]);
// the point before the end is always the farthest one, so each range only loses one point
let kept = ramer_douglas_peucker_by(&trkseg, &|_, _, idx| idx.flat as f64 + 1.0, 0.5);
let kept: Vec<usize> = kept.iter().map(|(idx, _)| idx.flat).collect();
assert_eq!(kept, (0..n).collect::<Vec<_>>());
}
#[test] #[test]
fn test_empty_and_single_point() { fn test_empty_and_single_point() {
assert!(simplify(&[], 1.0).is_empty()); assert!(simplify(&[], 1.0).is_empty());
+183 -6
View File
@@ -3,10 +3,10 @@ use crate::{
TrackpointCategories, TrackpointChunk, Waypoint, WaypointChunk, distance, TrackpointCategories, TrackpointChunk, Waypoint, WaypointChunk, distance,
}; };
use chrono::DateTime; use chrono::DateTime;
use quick_xml::Error; use quick_xml::{Error, XmlVersion};
use quick_xml::escape::resolve_predefined_entity; use quick_xml::escape::resolve_predefined_entity;
use quick_xml::events::Event; use quick_xml::events::Event;
use quick_xml::events::attributes::Attributes; use quick_xml::events::attributes::{Attribute, Attributes};
use quick_xml::reader::Reader; use quick_xml::reader::Reader;
enum GPXElement { enum GPXElement {
@@ -37,6 +37,8 @@ enum GPXElement {
Symbol, Symbol,
Type, Type,
Color, Color,
/// The color of a track or a route in the Garmin extensions (`gpxx:DisplayColor`).
DisplayColor,
Opacity, Opacity,
Width, Width,
} }
@@ -58,6 +60,50 @@ fn is_route_point_extension(name: &str) -> bool {
name == "rpt" || name.ends_with(":rpt") name == "rpt" || name.ends_with(":rpt")
} }
/// The color of a track (`RRGGBB`, the format of the style schema) and its opacity, from a color
/// that may start with `#` and may have an alpha channel first (`AARRGGBB`), as some applications
/// write it. A color in any other format is kept as it is, without opacity.
fn parse_color(value: &str) -> (String, Option<f64>) {
let value = value.trim();
let hex = value.strip_prefix('#').unwrap_or(value);
if !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
return (value.to_owned(), None);
}
match hex.len() {
6 => (hex.to_owned(), None),
8 => {
let alpha = u8::from_str_radix(&hex[..2], 16).unwrap_or(u8::MAX);
let opacity = (f64::from(alpha) / 255.0 * 100.0).round() / 100.0;
(hex[2..].to_owned(), Some(opacity))
}
_ => (value.to_owned(), None),
}
}
/// The hexadecimal color of a value of `DisplayColor_t` in the Garmin extensions. None for
/// `Transparent` and for what is not part of the schema.
fn garmin_display_color(name: &str) -> Option<&'static str> {
match name.trim() {
"Black" => Some("000000"),
"DarkRed" => Some("800000"),
"DarkGreen" => Some("008000"),
"DarkYellow" => Some("808000"),
"DarkBlue" => Some("000080"),
"DarkMagenta" => Some("800080"),
"DarkCyan" => Some("008080"),
"LightGray" => Some("c0c0c0"),
"DarkGray" => Some("808080"),
"Red" => Some("ff0000"),
"Green" => Some("00ff00"),
"Yellow" => Some("ffff00"),
"Blue" => Some("0000ff"),
"Magenta" => Some("ff00ff"),
"Cyan" => Some("00ffff"),
"White" => Some("ffffff"),
_ => None,
}
}
/// Whether the element holds a value, which is its text. /// Whether the element holds a value, which is its text.
fn holds_text(element: Option<&GPXElement>) -> bool { fn holds_text(element: Option<&GPXElement>) -> bool {
matches!( matches!(
@@ -81,6 +127,7 @@ fn holds_text(element: Option<&GPXElement>) -> bool {
| GPXElement::Symbol | GPXElement::Symbol
| GPXElement::Type | GPXElement::Type
| GPXElement::Color | GPXElement::Color
| GPXElement::DisplayColor
| GPXElement::Opacity | GPXElement::Opacity
| GPXElement::Width | GPXElement::Width
) )
@@ -241,7 +288,21 @@ fn apply_text(
Some(GPXElement::Color) => { Some(GPXElement::Color) => {
stack.pop(); stack.pop();
if let Some(GPXElement::Track(trk)) = stack.last_mut() { if let Some(GPXElement::Track(trk)) = stack.last_mut() {
trk.info.color = Some(e.to_owned()); let (color, opacity) = parse_color(e);
trk.info.color = Some(color);
// The opacity of the style schema has priority, wherever it is in the extensions
if trk.info.opacity.is_none() {
trk.info.opacity = opacity;
}
}
}
// The color of the style schema has priority, wherever it is in the extensions
Some(GPXElement::DisplayColor) => {
stack.pop();
if let Some(GPXElement::Track(trk)) = stack.last_mut()
&& trk.info.color.is_none()
{
trk.info.color = garmin_display_color(e).map(str::to_owned);
} }
} }
Some(GPXElement::Opacity) => { Some(GPXElement::Opacity) => {
@@ -260,6 +321,13 @@ fn apply_text(
} }
} }
/// The text of an attribute, without its escaping (`&amp;` is `&`), which is written back when the
/// file is. As it is in the file if it cannot be read.
fn attribute_text(attr: &Attribute<'_>) -> String {
attr.normalized_value(XmlVersion::Implicit1_0)
.map_or_else(|_| attr.value.to_string(), |text| text.into_owned())
}
/// The email of the author, given as the `id` and `domain` attributes of the element. /// The email of the author, given as the `id` and `domain` attributes of the element.
fn set_email(stack: &mut [GPXElement], attributes: Attributes<'_>) { fn set_email(stack: &mut [GPXElement], attributes: Attributes<'_>) {
let Some(GPXElement::Author(author)) = stack.last_mut() else { let Some(GPXElement::Author(author)) = stack.last_mut() else {
@@ -268,8 +336,8 @@ fn set_email(stack: &mut [GPXElement], attributes: Attributes<'_>) {
let (mut id, mut domain) = (None, None); let (mut id, mut domain) = (None, None);
for attr in attributes.flatten() { for attr in attributes.flatten() {
match attr.key.as_ref() { match attr.key.as_ref() {
"id" => id = Some(attr.value.to_string()), "id" => id = Some(attribute_text(&attr)),
"domain" => domain = Some(attr.value.to_string()), "domain" => domain = Some(attribute_text(&attr)),
_ => (), _ => (),
} }
} }
@@ -283,7 +351,7 @@ fn parse_link(attributes: Attributes<'_>) -> Link {
let mut link = Link::default(); let mut link = Link::default();
for attr in attributes.flatten() { for attr in attributes.flatten() {
if attr.key.as_ref() == "href" { if attr.key.as_ref() == "href" {
link.href = attr.value.to_string(); link.href = attribute_text(&attr);
} }
} }
link link
@@ -448,6 +516,7 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File,
"sym" => stack.push(GPXElement::Symbol), "sym" => stack.push(GPXElement::Symbol),
"type" => stack.push(GPXElement::Type), "type" => stack.push(GPXElement::Type),
e if e.ends_with("color") => stack.push(GPXElement::Color), e if e.ends_with("color") => stack.push(GPXElement::Color),
e if e.ends_with("DisplayColor") => stack.push(GPXElement::DisplayColor),
e if e.ends_with("opacity") => stack.push(GPXElement::Opacity), e if e.ends_with("opacity") => stack.push(GPXElement::Opacity),
e if e.ends_with("width") => stack.push(GPXElement::Width), e if e.ends_with("width") => stack.push(GPXElement::Width),
_ => (), _ => (),
@@ -626,6 +695,14 @@ mod tests {
.unwrap() .unwrap()
} }
#[test]
fn test_link_href_is_unescaped_and_written_back_once() {
let data =
r#"<gpx><metadata><link href="https://a.example/?x=1&amp;y=2"/></metadata></gpx>"#;
let gpx = parse(data.as_bytes(), &mut Default::default()).unwrap();
assert_eq!(gpx.info.links[0].href, "https://a.example/?x=1&y=2");
}
#[test] #[test]
fn test_parse_entities_and_cdata_in_text() { fn test_parse_entities_and_cdata_in_text() {
let data = r#"<gpx><metadata><name>Tom &amp; Jerry &lt;3 &#233;&#x41; &quot;x&quot; &apos;y&apos;</name> let data = r#"<gpx><metadata><name>Tom &amp; Jerry &lt;3 &#233;&#x41; &quot;x&quot; &apos;y&apos;</name>
@@ -1259,4 +1336,104 @@ mod tests {
assert!(trk.info.opacity.is_some_and(|o| o == 0.5)); assert!(trk.info.opacity.is_some_and(|o| o == 0.5));
assert!(trk.info.width.is_some_and(|w| w == 6.0)); assert!(trk.info.width.is_some_and(|w| w == 6.0));
} }
fn parse_text(text: &str) -> File {
parse(text.as_bytes(), &mut Default::default()).unwrap()
}
#[test]
fn test_parse_garmin_display_color_of_tracks_and_routes() {
let gpx = parse_text(
r#"<gpx xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3"
xmlns:gpxtrx="http://www.garmin.com/xmlschemas/GpxExtensions/v3">
<trk><extensions><gpxx:TrackExtension>
<gpxx:DisplayColor>Blue</gpxx:DisplayColor>
</gpxx:TrackExtension></extensions></trk>
<trk><extensions><gpxtrx:TrackExtension>
<gpxtrx:DisplayColor>DarkRed</gpxtrx:DisplayColor>
</gpxtrx:TrackExtension></extensions></trk>
<rte><extensions><gpxx:RouteExtension>
<gpxx:DisplayColor>Green</gpxx:DisplayColor>
</gpxx:RouteExtension></extensions></rte>
</gpx>"#,
);
assert_eq!(gpx.trk[0].info.color.as_deref(), Some("0000ff"));
assert_eq!(gpx.trk[1].info.color.as_deref(), Some("800000"));
assert_eq!(gpx.rte[0].info.color.as_deref(), Some("00ff00"));
}
#[test]
fn test_garmin_display_color_without_a_color_leaves_the_track_uncolored() {
let gpx = parse_text(
r#"<gpx>
<trk><extensions><gpxx:TrackExtension>
<gpxx:DisplayColor>Transparent</gpxx:DisplayColor>
</gpxx:TrackExtension></extensions></trk>
<trk><extensions><gpxx:TrackExtension>
<gpxx:DisplayColor>Mauve</gpxx:DisplayColor>
</gpxx:TrackExtension></extensions></trk>
</gpx>"#,
);
assert_eq!(gpx.trk[0].info.color, None);
assert_eq!(gpx.trk[1].info.color, None);
}
#[test]
fn test_style_color_has_priority_over_garmin_display_color() {
let gpx = parse_text(
r#"<gpx>
<trk><extensions>
<gpxx:TrackExtension><gpxx:DisplayColor>Red</gpxx:DisplayColor></gpxx:TrackExtension>
<gpx_style:line><gpx_style:color>2d3ee9</gpx_style:color></gpx_style:line>
</extensions></trk>
<trk><extensions>
<gpx_style:line><gpx_style:color>2d3ee9</gpx_style:color></gpx_style:line>
<gpxx:TrackExtension><gpxx:DisplayColor>Red</gpxx:DisplayColor></gpxx:TrackExtension>
</extensions></trk>
</gpx>"#,
);
assert_eq!(gpx.trk[0].info.color.as_deref(), Some("2d3ee9"));
assert_eq!(gpx.trk[1].info.color.as_deref(), Some("2d3ee9"));
}
#[test]
fn test_parse_color_with_hash_and_alpha() {
assert_eq!(parse_color("2d3ee9"), ("2d3ee9".into(), None));
assert_eq!(parse_color("#0000FF"), ("0000FF".into(), None));
assert_eq!(parse_color("#800000FF"), ("0000FF".into(), Some(0.5)));
assert_eq!(parse_color("FF0000FF"), ("0000FF".into(), Some(1.0)));
assert_eq!(parse_color("#00ff0000"), ("ff0000".into(), Some(0.0)));
assert_eq!(parse_color("red"), ("red".into(), None));
assert_eq!(parse_color("#abc"), ("#abc".into(), None));
}
#[test]
fn test_parse_track_color_with_alpha() {
let gpx = parse_text(
r#"<gpx>
<trk><extensions><xsi:gpx>
<physicalColor>#400000FF</physicalColor>
<width>4</width>
<color>#800000FF</color>
</xsi:gpx></extensions></trk>
<trk><extensions><gpx_style:line>
<gpx_style:opacity>0.3</gpx_style:opacity>
<gpx_style:color>#800000FF</gpx_style:color>
</gpx_style:line></extensions></trk>
<trk><extensions><gpx_style:line>
<gpx_style:color>#800000FF</gpx_style:color>
<gpx_style:opacity>0.3</gpx_style:opacity>
</gpx_style:line></extensions></trk>
</gpx>"#,
);
assert_eq!(gpx.trk[0].info.color.as_deref(), Some("0000FF"));
assert_eq!(gpx.trk[0].info.opacity, Some(0.5));
assert_eq!(gpx.trk[0].info.width, Some(4.0));
assert_eq!(gpx.trk[1].info.opacity, Some(0.3));
assert_eq!(gpx.trk[2].info.opacity, Some(0.3));
}
} }
-8
View File
@@ -24,14 +24,6 @@ pub struct Statistics {
} }
impl Statistics { impl Statistics {
pub fn total_speed(&self) -> Option<f64> {
self.global.total_speed()
}
pub fn moving_speed(&self) -> Option<f64> {
self.global.moving_speed()
}
pub fn compute(trkseg: &TrackSegment) -> Self { pub fn compute(trkseg: &TrackSegment) -> Self {
let mut stats = Self::default(); let mut stats = Self::default();
if trkseg.is_empty() { if trkseg.is_empty() {
@@ -36,6 +36,7 @@ impl StatisticsCache {
} }
/// Statistics of the segments of the selection. /// Statistics of the segments of the selection.
#[cfg(test)]
pub fn get<'a>( pub fn get<'a>(
&'a self, &'a self,
state: Option<&'a Files>, state: Option<&'a Files>,
+1 -17
View File
@@ -11,7 +11,7 @@ pub use outcome::*;
pub use state::*; pub use state::*;
use crate::{ use crate::{
ExportOptions, FileId, GlobalStatistics, Restored, Snapshot, TrackSegmentId, Trackpoint, ExportOptions, FileId, GlobalStatistics, Restored, Snapshot, TrackSegmentId,
TrackpointCategories, Waypoint, WaypointId, reduction_distances, write, TrackpointCategories, Waypoint, WaypointId, reduction_distances, write,
}; };
@@ -82,22 +82,6 @@ impl<H: History> Engine<H> {
.find(|wpt| wpt.id == *id) .find(|wpt| wpt.id == *id)
} }
/// A trackpoint of a segment (of a route or a track) of a file. `None` if it does not exist.
pub fn trackpoint(
&self,
file_id: &FileId,
segment_id: &TrackSegmentId,
index: usize,
) -> Option<&Trackpoint> {
let segment = self
.history
.current()?
.get(file_id)?
.segments()
.find(|seg| seg.id == *segment_id)?;
(index < segment.len()).then(|| &segment[index])
}
/// For each trackpoint of the selection (numbered as in the statistics), the tolerance up to /// For each trackpoint of the selection (numbered as in the statistics), the tolerance up to
/// which `Reduce` keeps it, see [`reduction_distances`]. /// which `Reduce` keeps it, see [`reduction_distances`].
pub fn reduction_distances(&self) -> Vec<f64> { pub fn reduction_distances(&self) -> Vec<f64> {
-18
View File
@@ -1169,24 +1169,6 @@ fn test_categories_are_shared_and_kept_by_the_history() {
assert_eq!(expand(&engine.statistics().surface, 5), [1, 1, 0, 2, 2]); assert_eq!(expand(&engine.statistics().surface, 5), [1, 1, 0, 2, 2]);
} }
#[test]
fn test_trackpoint() {
let mut engine = TestEngine::default();
load(&mut engine, "data/simple.gpx");
let file = engine.order()[0];
let structure = engine.file_structure(&file).unwrap();
let seg = &structure.tracks[0].segments[0];
let (seg_id, len) = (seg.id, seg.len);
let coordinates = engine.segment_coordinates(&seg_id).to_vec();
let last = engine.trackpoint(&file, &seg_id, len - 1).unwrap();
assert_eq!(
[last.coordinates.lng, last.coordinates.lat],
coordinates[(len - 1) * 2..]
);
assert!(engine.trackpoint(&file, &seg_id, len).is_none());
assert!(engine.trackpoint(&Default::default(), &seg_id, 0).is_none());
}
#[test] #[test]
fn test_export_writes_the_file_as_it_is() { fn test_export_writes_the_file_as_it_is() {
let mut engine = TestEngine::default(); let mut engine = TestEngine::default();
+5 -3
View File
@@ -1,9 +1,9 @@
use std::cell::RefCell; use std::cell::RefCell;
use std::collections::HashMap; use std::collections::HashMap;
use crate::{ #[cfg(test)]
Batch, ChunkKey, ChunkKind, CommitError, FileId, Revision, Storage, StorageError, StoredData, use crate::ChunkKind;
}; use crate::{Batch, ChunkKey, CommitError, FileId, Revision, Storage, StorageError, StoredData};
/// A storage that lives as long as the session. It is what an engine without a host storage uses, /// A storage that lives as long as the session. It is what an engine without a host storage uses,
/// and what the tests use. /// and what the tests use.
@@ -22,6 +22,7 @@ struct State {
} }
/// How much there is in a storage. /// How much there is in a storage.
#[cfg(test)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StorageCounts { pub struct StorageCounts {
pub files: usize, pub files: usize,
@@ -29,6 +30,7 @@ pub struct StorageCounts {
pub waypoint_chunks: usize, pub waypoint_chunks: usize,
} }
#[cfg(test)]
impl MemoryStorage { impl MemoryStorage {
/// How many files and chunks are stored. /// How many files and chunks are stored.
pub fn counts(&self) -> StorageCounts { pub fn counts(&self) -> StorageCounts {
+4
View File
@@ -37,3 +37,7 @@ tauri-plugin-single-instance = "2.5.3"
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
trash = "5" trash = "5"
[target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies]
tauri-plugin-process = "2.4.0"
tauri-plugin-updater = "2.14.1"
+1 -1
View File
@@ -3,5 +3,5 @@
"identifier": "default", "identifier": "default",
"description": "Permissions for the main window", "description": "Permissions for the main window",
"windows": ["main"], "windows": ["main"],
"permissions": ["core:default"] "permissions": ["core:default", "updater:default", "process:allow-restart"]
} }
-10
View File
@@ -57,16 +57,6 @@ pub fn waypoint(
state.read(move |e| api::waypoint(e, file_id, waypoint_id)) state.read(move |e| api::waypoint(e, file_id, waypoint_id))
} }
#[tauri::command(async)]
pub fn trackpoint(
state: State<AppState>,
file_id: Uuid,
segment_id: Uuid,
index: usize,
) -> Option<api::TrackpointDetails> {
state.read(move |e| api::trackpoint(e, file_id, segment_id, index))
}
/// A file as GPX (UTF-8 bytes), the first byte telling whether the file exists. /// A file as GPX (UTF-8 bytes), the first byte telling whether the file exists.
#[tauri::command(async)] #[tauri::command(async)]
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
+6 -1
View File
@@ -55,6 +55,12 @@ pub fn run() {
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.setup(|app| { .setup(|app| {
#[cfg(desktop)]
{
app.handle()
.plugin(tauri_plugin_updater::Builder::new().build())?;
app.handle().plugin(tauri_plugin_process::init())?;
}
let handle = app.handle().clone(); let handle = app.handle().clone();
let emit: state::Emit = std::sync::Arc::new(move |event, payload| { let emit: state::Emit = std::sync::Arc::new(move |event, payload| {
let _ = handle.emit(event, payload); let _ = handle.emit(event, payload);
@@ -167,7 +173,6 @@ pub fn register_commands<R: tauri::Runtime>(builder: tauri::Builder<R>) -> tauri
queries::can_redo, queries::can_redo,
queries::can_paste, queries::can_paste,
queries::waypoint, queries::waypoint,
queries::trackpoint,
queries::export_file, queries::export_file,
queries::exportable_data, queries::exportable_data,
queries::reduction_distances, queries::reduction_distances,
+10 -1
View File
@@ -27,7 +27,8 @@
}, },
"bundle": { "bundle": {
"active": true, "active": true,
"targets": "all", "targets": ["app", "dmg", "appimage", "deb", "rpm", "nsis"],
"macOS": { "signingIdentity": "-" },
"fileAssociations": [ "fileAssociations": [
{ {
"ext": ["gpx"], "ext": ["gpx"],
@@ -44,5 +45,13 @@
"icons/icon.icns", "icons/icon.icns",
"icons/icon.ico" "icons/icon.ico"
] ]
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDE5NTFDQzVDOUYwNEMyNEMKUldSTXdnU2ZYTXhSR1ZXRDV6aVErc3dUWXBaK0VGSitYRnFFR21hZ2s5UEJTT01aYkE5Unp5c0cKdW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEFFNTU3Q0Y1NzhFODkyMTEKUldRUmt1aDQ5WHhWcmdXQmx0UG83V29TenRxSVJjdkc4ckN1Zi93ajc3ZUhVUVp3eFlCOXphb1IK",
"endpoints": [
"https://github.com/gpxstudio/gpx.studio/releases/latest/download/latest.json"
]
}
} }
} }
+18 -6
View File
@@ -26,6 +26,9 @@ thread_local! {
static PERSISTER: RefCell<Option<Persister>> = const { RefCell::new(None) }; static PERSISTER: RefCell<Option<Persister>> = const { RefCell::new(None) };
static SAVING: Cell<bool> = const { Cell::new(false) }; static SAVING: Cell<bool> = const { Cell::new(false) };
static DIRTY: Cell<bool> = const { Cell::new(false) }; static DIRTY: Cell<bool> = const { Cell::new(false) };
/// Whether the last write failed, or was not done because a newer version wrote the storage:
/// what the engine holds is then not all stored.
static NOT_WRITTEN: Cell<bool> = const { Cell::new(false) };
/// Resolve functions of the promises of `flush_storage`. /// Resolve functions of the promises of `flush_storage`.
/// Called after the files were written, and when a write was refused because they were /// Called after the files were written, and when a write was refused because they were
/// written by someone else since: see `open_storage`. /// written by someone else since: see `open_storage`.
@@ -73,20 +76,28 @@ pub(crate) fn schedule_save() {
let result = persister.save(&*storage, snapshot).await; let result = persister.save(&*storage, snapshot).await;
PERSISTER.with(|slot| *slot.borrow_mut() = Some(persister)); PERSISTER.with(|slot| *slot.borrow_mut() = Some(persister));
match result { match result {
Ok(()) => saved = true, Ok(()) => {
NOT_WRITTEN.set(false);
saved = true;
}
// nothing was written, so that the files of the other tab are not damaged: this // nothing was written, so that the files of the other tab are not damaged: this
// tab has to read them, which replaces what it did // tab has to read them, which replaces what it did
Err(engine::CommitError::Stale { .. }) => { Err(engine::CommitError::Stale { .. }) => {
NOT_WRITTEN.set(false);
DIRTY.set(false); DIRTY.set(false);
behind = true; behind = true;
} }
// nothing is written, so that the newer version finds its data again // nothing is written, so that the newer version finds its data again
Err(engine::CommitError::Failed(engine::StorageError::Newer)) => { Err(engine::CommitError::Failed(engine::StorageError::Newer)) => {
NOT_WRITTEN.set(true);
DIRTY.set(false); DIRTY.set(false);
newer = true; newer = true;
} }
// what failed is written with the next change // what failed is written with the next change
Err(error) => console_error(&error.to_string()), Err(error) => {
NOT_WRITTEN.set(true);
console_error(&error.to_string());
}
} }
} }
SAVING.set(false); SAVING.set(false);
@@ -188,17 +199,18 @@ pub(crate) fn to_js_error(error: engine::StorageError) -> JsValue {
JsValue::from_str(&error.to_string()) JsValue::from_str(&error.to_string())
} }
/// Resolves when what was changed has been written to the storage. /// Resolves when the writes of what was changed are done, to whether they went well. A write that
/// was refused because another tab wrote since is fine: this tab then reads what that tab wrote.
#[wasm_bindgen] #[wasm_bindgen]
pub async fn flush_storage() -> Result<(), JsValue> { pub async fn flush_storage() -> Result<bool, JsValue> {
if !SAVING.get() { if !SAVING.get() {
return Ok(()); return Ok(!NOT_WRITTEN.get());
} }
let promise = js_sys::Promise::new(&mut |resolve, _| { let promise = js_sys::Promise::new(&mut |resolve, _| {
WAITERS.with(|waiters| waiters.borrow_mut().push(resolve)); WAITERS.with(|waiters| waiters.borrow_mut().push(resolve));
}); });
wasm_bindgen_futures::JsFuture::from(promise).await?; wasm_bindgen_futures::JsFuture::from(promise).await?;
Ok(()) Ok(!NOT_WRITTEN.get())
} }
/// If another tab wrote the files since this one last did, reads them again and overwrites the /// If another tab wrote the files since this one last did, reads them again and overwrites the
-12
View File
@@ -101,18 +101,6 @@ pub fn waypoint(file_id: &str, waypoint_id: &str) -> WaypointDetails {
optional_js(details).unchecked_into() optional_js(details).unchecked_into()
} }
/// A trackpoint of a segment of a file, `undefined` if it does not exist.
#[wasm_bindgen]
pub fn trackpoint(file_id: &str, segment_id: &str, index: usize) -> TrackpointDetails {
let details =
parse_uuid(file_id)
.zip(parse_uuid(segment_id))
.and_then(|(file_id, segment_id)| {
with_engine(|e| api::trackpoint(e, file_id, segment_id, index)).flatten()
});
optional_js(details).unchecked_into()
}
/// What was copied or cut and is waiting to be pasted, `undefined` if nothing: the kind of the /// What was copied or cut and is waiting to be pasted, `undefined` if nothing: the kind of the
/// elements and their ids (UUID strings). /// elements and their ids (UUID strings).
#[wasm_bindgen] #[wasm_bindgen]
-10
View File
@@ -59,14 +59,6 @@ export interface WaypointDetails {
links?: { href: string; text?: string }[]; links?: { href: string; text?: string }[];
sym?: string; sym?: string;
} }
/** The position, elevation and time of a trackpoint. */
export interface TrackpointDetails {
lng: number;
lat: number;
ele: number;
/** ms since epoch */
time?: number;
}
/** /**
* A trackpoint of the selection: what the statistics buffers do not hold for each trackpoint. * A trackpoint of the selection: what the statistics buffers do not hold for each trackpoint.
* Absent fields are unknown. * Absent fields are unknown.
@@ -189,8 +181,6 @@ extern "C" {
pub type Selection; pub type Selection;
#[wasm_bindgen(typescript_type = "WaypointDetails | undefined")] #[wasm_bindgen(typescript_type = "WaypointDetails | undefined")]
pub type WaypointDetails; pub type WaypointDetails;
#[wasm_bindgen(typescript_type = "TrackpointDetails | undefined")]
pub type TrackpointDetails;
#[wasm_bindgen(typescript_type = "SelectionPoint | undefined")] #[wasm_bindgen(typescript_type = "SelectionPoint | undefined")]
pub type SelectionPoint; pub type SelectionPoint;
#[wasm_bindgen(typescript_type = "MoveTarget")] #[wasm_bindgen(typescript_type = "MoveTarget")]
+22
View File
@@ -32,6 +32,8 @@
"@sveltejs/vite-plugin-svelte": "^6.2.4", "@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.1.8", "@tailwindcss/vite": "^4.1.8",
"@tauri-apps/api": "^2.12.3", "@tauri-apps/api": "^2.12.3",
"@tauri-apps/plugin-process": "^2.4.0",
"@tauri-apps/plugin-updater": "^2.14.1",
"@types/eslint": "^9.6.1", "@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",
@@ -2667,6 +2669,26 @@
"url": "https://opencollective.com/tauri" "url": "https://opencollective.com/tauri"
} }
}, },
"node_modules/@tauri-apps/plugin-process": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-process/-/plugin-process-2.4.0.tgz",
"integrity": "sha512-GddrmsSLAYrLa/yTXlXK/YRDzUgYeeOnUXTbiyvJWkmI424RmDPD3FVLeqcon7rv6O6ZfhBilcrHWnSo7o6hIQ==",
"dev": true,
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.12.0"
}
},
"node_modules/@tauri-apps/plugin-updater": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.14.1.tgz",
"integrity": "sha512-Hr7nlBm1wT6xbwzjZ/S36YULazu8MNlj5vvOwMl47fXI7HTa5Bjl1PbDEwVHaZTAKzE1VZsUy35UqnbrQxmYQw==",
"dev": true,
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.12.0"
}
},
"node_modules/@types/cookie": { "node_modules/@types/cookie": {
"version": "0.6.0", "version": "0.6.0",
"dev": true, "dev": true,
+2
View File
@@ -27,6 +27,8 @@
"@sveltejs/vite-plugin-svelte": "^6.2.4", "@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.1.8", "@tailwindcss/vite": "^4.1.8",
"@tauri-apps/api": "^2.12.3", "@tauri-apps/api": "^2.12.3",
"@tauri-apps/plugin-process": "^2.4.0",
"@tauri-apps/plugin-updater": "^2.14.1",
"@types/eslint": "^9.6.1", "@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",
@@ -31,6 +31,7 @@
statistics, statistics,
slicedStatistics, slicedStatistics,
hoveredPoint, hoveredPoint,
hoveredTrackpoint,
additionalDatasets, additionalDatasets,
elevationFill, elevationFill,
showControls = true, showControls = true,
@@ -38,6 +39,7 @@
statistics: Readable<SelectionStatistics>; statistics: Readable<SelectionStatistics>;
slicedStatistics: Writable<SlicedStatistics | undefined>; slicedStatistics: Writable<SlicedStatistics | undefined>;
hoveredPoint: Writable<Coordinates | null>; hoveredPoint: Writable<Coordinates | null>;
hoveredTrackpoint?: Readable<number | null>;
additionalDatasets: Writable<string[]>; additionalDatasets: Writable<string[]>;
elevationFill: Writable<'slope' | 'surface' | 'highway' | undefined>; elevationFill: Writable<'slope' | 'surface' | 'highway' | undefined>;
showControls?: boolean; showControls?: boolean;
@@ -55,7 +57,8 @@
additionalDatasets, additionalDatasets,
elevationFill, elevationFill,
canvas, canvas,
overlay overlay,
hoveredTrackpoint
); );
}); });
@@ -15,10 +15,15 @@ import {
getVelocityWithUnits, getVelocityWithUnits,
} from '$lib/units/units'; } from '$lib/units/units';
import Chart, { import Chart, {
Tooltip,
type ActiveDataPoint,
type ChartEvent, type ChartEvent,
type ChartOptions, type ChartOptions,
type ChartType,
type Point,
type ScriptableLineSegmentContext, type ScriptableLineSegmentContext,
type TooltipItem, type TooltipItem,
type TooltipPositionerFunction,
} from 'chart.js/auto'; } from 'chart.js/auto';
import { get, type Readable, type Writable } from 'svelte/store'; import { get, type Readable, type Writable } from 'svelte/store';
import type { Coordinates } from '$lib/geo/coordinates'; import type { Coordinates } from '$lib/geo/coordinates';
@@ -40,6 +45,25 @@ const { distanceUnits, velocityUnits, temperatureUnits } = settings;
Chart.defaults.font.family = 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 'ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"'; // Tailwind CSS font
declare module 'chart.js' {
interface TooltipPositionerMap {
trackpoint: TooltipPositionerFunction<ChartType>;
}
}
/** Marks the positions of the trackpoints shown without an event of the chart. */
const trackpointPosition = Symbol('trackpointPosition');
/**
* Like the default for the events of the chart. A trackpoint that is shown without one is given by
* its position, as it can be between the points of the chart (see `activateTrackpoint`).
*/
Tooltip.positioners.trackpoint = function (items, position) {
return trackpointPosition in position
? position
: Tooltip.positioners.average.call(this, items, position);
};
/** `undefined` for a missing measure (NaN), so that Chart.js leaves a gap instead of a value. */ /** `undefined` for a missing measure (NaN), so that Chart.js leaves a gap instead of a value. */
function optional(value: number, convert: (value: number) => number = (v) => v) { function optional(value: number, convert: (value: number) => number = (v) => v) {
return Number.isNaN(value) ? undefined : convert(value); return Number.isNaN(value) ? undefined : convert(value);
@@ -55,6 +79,21 @@ interface ElevationProfilePoint {
index: number; index: number;
} }
/** The index of the point the closest to `x`, in points sorted by `x`. */
function nearestPointIndex(points: { x: number }[], x: number): number {
let low = 0;
let high = points.length - 1;
while (low < high) {
const middle = (low + high) >> 1;
if (points[middle].x < x) {
low = middle + 1;
} else {
high = middle;
}
}
return low > 0 && x - points[low - 1].x < points[low].x - x ? low - 1 : low;
}
export class ElevationProfile { export class ElevationProfile {
private _chart: Chart | null = null; private _chart: Chart | null = null;
private _canvas: HTMLCanvasElement; private _canvas: HTMLCanvasElement;
@@ -71,10 +110,14 @@ export class ElevationProfile {
*/ */
private _hovered: { index: number; loaded: boolean; point?: SelectionPoint } | null = null; private _hovered: { index: number; loaded: boolean; point?: SelectionPoint } | null = null;
private _loadingHovered = false; private _loadingHovered = false;
/** The last move over the chart, held until the data of its trackpoint is read. */
private _heldMove: MouseEvent | null = null;
private _slicedStatistics: Writable<SlicedStatistics | undefined>; private _slicedStatistics: Writable<SlicedStatistics | undefined>;
private _hoveredPoint: Writable<Coordinates | null>; private _hoveredPoint: Writable<Coordinates | null>;
/** All the points of each dataset, including the ones that the chart decimated. */
private _points: ElevationProfilePoint[][] = [];
/** The trackpoint hovered somewhere else, that the tooltip shows instead of its elements. */
private _shownTrackpoint: number | null = null;
private _hoveredTrackpoint: Readable<number | null> | undefined;
private _unsubscribeHoveredTrackpoint: (() => void) | undefined;
private _additionalDatasets: Readable<string[]>; private _additionalDatasets: Readable<string[]>;
private _elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>; private _elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>;
/** The statistics that are only read from the engine while the profile shows them. */ /** The statistics that are only read from the engine while the profile shows them. */
@@ -87,7 +130,8 @@ export class ElevationProfile {
additionalDatasets: Readable<string[]>, additionalDatasets: Readable<string[]>,
elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>, elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>,
canvas: HTMLCanvasElement, canvas: HTMLCanvasElement,
overlay: HTMLCanvasElement overlay: HTMLCanvasElement,
hoveredTrackpoint?: Readable<number | null>
) { ) {
this._statistics = statistics; this._statistics = statistics;
this._slicedStatistics = slicedStatistics; this._slicedStatistics = slicedStatistics;
@@ -96,6 +140,7 @@ export class ElevationProfile {
this._elevationFill = elevationFill; this._elevationFill = elevationFill;
this._canvas = canvas; this._canvas = canvas;
this._overlay = overlay; this._overlay = overlay;
this._hoveredTrackpoint = hoveredTrackpoint;
this._request = engine.requestStatistics(); this._request = engine.requestStatistics();
this.updateRequest(); this.updateRequest();
@@ -127,6 +172,9 @@ export class ElevationProfile {
this.updateRequest(); this.updateRequest();
this.updateFill(); this.updateFill();
}); });
this._unsubscribeHoveredTrackpoint = this._hoveredTrackpoint?.subscribe((index) => {
this.showTrackpoint(index);
});
}); });
} }
@@ -195,20 +243,16 @@ export class ElevationProfile {
}, },
tooltip: { tooltip: {
enabled: () => !this._dragging && !this._panning, enabled: () => !this._dragging && !this._panning,
position: 'trackpoint',
callbacks: { callbacks: {
title: () => { title: () => {
return ''; return '';
}, },
label: (context: TooltipItem<'line'>) => { label: (context: TooltipItem<'line'>) => {
const point = context.raw as ElevationProfilePoint; const point = this.pointOf(context);
if (context.datasetIndex === 0) { if (context.datasetIndex === 0) {
this._hoveredPoint.set( this._hoveredPoint.set(
this._dragging this._dragging ? null : this.coordinatesOf(point.index)
? null
: {
lat: this._data.lat[point.index],
lng: this._data.lng[point.index],
}
); );
return `${i18n._('quantities.elevation')}: ${getElevationWithUnits(point.y, false)}`; return `${i18n._('quantities.elevation')}: ${getElevationWithUnits(point.y, false)}`;
} else if (context.datasetIndex === 1) { } else if (context.datasetIndex === 1) {
@@ -228,7 +272,7 @@ export class ElevationProfile {
(context) => context.datasetIndex === 0 (context) => context.datasetIndex === 0
); );
if (context.length === 0) return; if (context.length === 0) return;
const point = context[0].raw as ElevationProfilePoint; const point = this.pointOf(context[0]);
// the rest of the data is there once it is read (see `hover`) // the rest of the data is there once it is read (see `hover`)
const hovered = this._hovered; const hovered = this._hovered;
const details = const details =
@@ -365,29 +409,21 @@ export class ElevationProfile {
afterEvent: (chart: Chart, args: { event: ChartEvent }) => { afterEvent: (chart: Chart, args: { event: ChartEvent }) => {
if (args.event.type === 'mouseout') { if (args.event.type === 'mouseout') {
this._hovered = null; this._hovered = null;
this._heldMove = null;
this._hoveredPoint.set(null); this._hoveredPoint.set(null);
} }
}, },
}, },
{ {
// The tooltip only shows a trackpoint once all its data is read: a move to // The tooltip follows the pointer at once with what the chart knows of the
// another one is held until then, and replayed (see `loadHovered`). // trackpoint, and shows the rest of its data once it is read (see
id: 'waitForTrackpoint', // `loadHovered`).
id: 'loadTrackpoint',
events: ['mousemove'], events: ['mousemove'],
beforeEvent: (chart: Chart, args: { event: ChartEvent }) => { beforeEvent: (chart: Chart, args: { event: ChartEvent }) => {
const index = this.indexAt(args.event); const index = this.indexAt(args.event);
if (index === undefined) { if (index !== undefined) {
return; this.hover(index);
} }
const hovered = this._hovered;
if (hovered?.index === index && hovered.loaded) {
this._heldMove = null;
return;
}
this._heldMove = args.event.native as MouseEvent;
this.hover(index);
return false;
}, },
}, },
], ],
@@ -478,6 +514,7 @@ export class ElevationProfile {
} }
const data = get(this._statistics); const data = get(this._statistics);
this._data = data; this._data = data;
const shownTrackpoint = this._shownTrackpoint;
// the indices of the trackpoints are the ones of other statistics // the indices of the trackpoints are the ones of other statistics
this._hovered = null; this._hovered = null;
const units = { const units = {
@@ -524,6 +561,7 @@ export class ElevationProfile {
} }
} }
this._points = datasets;
this._chart.data.datasets[0] = { this._chart.data.datasets[0] = {
label: i18n._('quantities.elevation'), label: i18n._('quantities.elevation'),
data: datasets[0], data: datasets[0],
@@ -570,6 +608,10 @@ export class ElevationProfile {
this.setFill(); this.setFill();
this._chart.update(); this._chart.update();
if (shownTrackpoint !== null) {
this.showTrackpoint(shownTrackpoint);
}
} }
updateDataVisibility() { updateDataVisibility() {
@@ -678,6 +720,74 @@ export class ElevationProfile {
} }
} }
/** The point that a line of the tooltip is about. */
private pointOf(context: TooltipItem<'line'>): ElevationProfilePoint {
return this._shownTrackpoint === null
? (context.raw as ElevationProfilePoint)
: this._points[context.datasetIndex][this._shownTrackpoint];
}
private coordinatesOf(index: number): Coordinates {
return { lat: this._data.lat[index], lng: this._data.lng[index] };
}
/** Shows a trackpoint in the tooltip like when hovering it, or nothing anymore with `null`. */
private showTrackpoint(index: number | null) {
const shown = this._shownTrackpoint;
this._shownTrackpoint = index;
if (index !== null) {
this.hover(index);
if (this._hovered?.loaded) {
this.activateTrackpoint(index);
}
} else if (shown !== null) {
this._hovered = null;
this.setActiveElements([], { x: 0, y: 0 });
this._hoveredPoint.set(null);
}
}
/**
* Opens the tooltip on a trackpoint, once its data is read. The chart can only activate the
* points that it kept after decimating its data, which are different for each dataset and might
* not include the trackpoint: it activates the nearest ones, within a pixel of it, and the
* tooltip reads the values of the trackpoint (see `pointOf`).
*/
private activateTrackpoint(index: number) {
const point = this._points[0]?.[index];
if (!this._chart || !point) {
return;
}
const { x: xScale, y: yScale } = this._chart.scales;
const elements: ActiveDataPoint[] = [];
// when the chart is zoomed, the trackpoint can be outside of it
if (xScale.min <= point.x && point.x <= xScale.max) {
for (const { index: datasetIndex } of this._chart.getSortedVisibleDatasetMetas()) {
if (this._points[datasetIndex]?.[index]?.y !== undefined) {
const data = this._chart.data.datasets[datasetIndex].data as { x: number }[];
elements.push({ datasetIndex, index: nearestPointIndex(data, point.x) });
}
}
}
if (elements.length === 0) {
// no tooltip to tell the map about the point
this._hoveredPoint.set(this.coordinatesOf(index));
}
this.setActiveElements(elements, {
x: xScale.getPixelForValue(point.x),
y: yScale.getPixelForValue(point.y),
[trackpointPosition]: true,
} as Point);
}
private setActiveElements(elements: ActiveDataPoint[], position: Point) {
if (this._chart) {
this._chart.tooltip?.setActiveElements(elements, position);
this._chart.setActiveElements(elements);
this._chart.render();
}
}
/** The trackpoint that the tooltip shows for an event, `undefined` outside of the chart. */ /** The trackpoint that the tooltip shows for an event, `undefined` outside of the chart. */
private indexAt(event: ChartEvent): number | undefined { private indexAt(event: ChartEvent): number | undefined {
const elements = this._chart?.getElementsAtEventForMode( const elements = this._chart?.getElementsAtEventForMode(
@@ -718,11 +828,10 @@ export class ElevationProfile {
} }
hovered.loaded = true; hovered.loaded = true;
hovered.point = point; hovered.point = point;
// the move that was held can now show the tooltip if (this._shownTrackpoint === hovered.index) {
const move = this._heldMove; this.activateTrackpoint(hovered.index);
this._heldMove = null; } else {
if (move) { this.refreshTooltip();
this._canvas.dispatchEvent(new MouseEvent(move.type, move));
} }
} }
} finally { } finally {
@@ -730,6 +839,18 @@ export class ElevationProfile {
} }
} }
/** Writes the tooltip again, on the same trackpoint, for the data that was read since. */
private refreshTooltip() {
// the plugin of the tooltip, whose type only has its model
const tooltip = this._chart?.tooltip as
| (Chart['tooltip'] & { update(changed: boolean): void })
| undefined;
if (tooltip && tooltip.getActiveElements().length > 0) {
tooltip.update(true);
this._chart!.render();
}
}
/** The trackpoint that a segment of the chart starts at. */ /** The trackpoint that a segment of the chart starts at. */
private static segmentStart(context: ScriptableLineSegmentContext & { p0: { raw: any } }) { private static segmentStart(context: ScriptableLineSegmentContext & { p0: { raw: any } }) {
return (context.p0.raw as ElevationProfilePoint).index; return (context.p0.raw as ElevationProfilePoint).index;
@@ -758,6 +879,7 @@ export class ElevationProfile {
destroy() { destroy() {
this._request.release(); this._request.release();
this._unsubscribeHoveredTrackpoint?.();
if (this._chart) { if (this._chart) {
this._chart.destroy(); this._chart.destroy();
this._chart = null; this._chart = null;
@@ -18,7 +18,11 @@
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { isSelected, toggle } from '$lib/components/map/layer-control/utils'; import { isSelected, toggle } from '$lib/components/map/layer-control/utils';
import { engine } from '$lib/engine'; import { engine } from '$lib/engine';
import { hoveredPoint, slicedStatistics } from '$lib/state/selection-statistics'; import {
hoveredPoint,
hoveredTrackpoint,
slicedStatistics,
} from '$lib/state/selection-statistics';
let { let {
useHash = true, useHash = true,
@@ -119,6 +123,7 @@
{statistics} {statistics}
{slicedStatistics} {slicedStatistics}
{hoveredPoint} {hoveredPoint}
{hoveredTrackpoint}
{additionalDatasets} {additionalDatasets}
{elevationFill} {elevationFill}
showControls={options.elevation.controls} showControls={options.elevation.controls}
@@ -5,6 +5,7 @@
import { StartEndMarkers } from '$lib/components/map/gpx-layer/start-end-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 { createPopups, removePopups } from '$lib/components/map/gpx-layer/gpx-layer-popup';
import { map } from '$lib/components/map/map'; import { map } from '$lib/components/map/map';
import { hoveredTrackpoint } from '$lib/state/selection-statistics';
let distanceMarkers: DistanceMarkers; let distanceMarkers: DistanceMarkers;
let startEndMarkers: StartEndMarkers; let startEndMarkers: StartEndMarkers;
@@ -14,6 +15,11 @@
startEndMarkers = new StartEndMarkers(); startEndMarkers = new StartEndMarkers();
distanceMarkers = new DistanceMarkers(); distanceMarkers = new DistanceMarkers();
createPopups(map_); createPopups(map_);
// the layers do not see the pointer when it leaves the map or when it drags something
const stopHovering = () => hoveredTrackpoint.set(null);
map_.on('mouseout', stopHovering);
map_.on('mousedown', stopHovering);
map_.on('movestart', stopHovering);
}); });
onDestroy(() => { onDestroy(() => {
@@ -2,17 +2,15 @@
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import CopyCoordinates from '$lib/components/map/gpx-layer/CopyCoordinates.svelte'; import CopyCoordinates from '$lib/components/map/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 { Compass, Earth } from '@lucide/svelte';
import { Compass, Earth, Mountain, Timer } from '@lucide/svelte';
import { i18n } from '$lib/i18n/i18n.svelte'; import { i18n } from '$lib/i18n/i18n.svelte';
import type { PopupItem } from '$lib/components/map/map-popup'; import type { PopupItem } from '$lib/components/map/map-popup';
import type { TrackpointDetails } from '$lib/engine';
import { map } from '$lib/components/map/map'; import { map } from '$lib/components/map/map';
let { let {
trackpoint, trackpoint,
}: { }: {
trackpoint: PopupItem<Pick<TrackpointDetails, 'lng' | 'lat'> & Partial<TrackpointDetails>>; trackpoint: PopupItem<{ lng: number; lat: number }>;
} = $props(); } = $props();
</script> </script>
@@ -22,33 +20,19 @@
<Compass size="14" /> <Compass size="14" />
{trackpoint.item.lat.toFixed(6)}&deg; {trackpoint.item.lng.toFixed(6)}&deg; {trackpoint.item.lat.toFixed(6)}&deg; {trackpoint.item.lng.toFixed(6)}&deg;
</div> </div>
{#if trackpoint.item.ele !== undefined}
<div class="flex flex-row items-center gap-1">
<Mountain size="14" />
<WithUnits value={trackpoint.item.ele} type="elevation" />
</div>
{/if}
{#if trackpoint.item.time !== undefined}
<div class="flex flex-row items-center gap-1">
<Timer size="14" />
{i18n.df.format(new Date(trackpoint.item.time))}
</div>
{/if}
<CopyCoordinates <CopyCoordinates
coordinates={trackpoint.item} coordinates={trackpoint.item}
onCopy={() => trackpoint.hide?.()} onCopy={() => trackpoint.hide?.()}
class="mt-0.5" class="mt-0.5"
/> />
{#if trackpoint.fileId === undefined} <Button
<Button variant="outline"
variant="outline" class="justify-start"
class="justify-start" href={`https://www.openstreetmap.org/edit?#map=${(($map?.getZoom() ?? 17) + 1).toFixed(0)}/${trackpoint.item.lat.toFixed(5)}/${trackpoint.item.lng.toFixed(5)}`}
href={`https://www.openstreetmap.org/edit?#map=${(($map?.getZoom() ?? 17) + 1).toFixed(0)}/${trackpoint.item.lat.toFixed(5)}/${trackpoint.item.lng.toFixed(5)}`} target="_blank"
target="_blank" >
> <Earth size="14" />
<Earth size="14" /> {i18n._('menu.edit_osm')}
{i18n._('menu.edit_osm')} </Button>
</Button>
{/if}
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>
@@ -7,8 +7,8 @@ import {
Point, Point,
} from 'maplibre-gl'; } from 'maplibre-gl';
import { map } from '$lib/components/map/map'; import { map } from '$lib/components/map/map';
import { waypointPopup, trackpointPopup } from './gpx-layer-popup'; import { waypointPopup } from './gpx-layer-popup';
import { closestPointIndex } from '$lib/geo/closest-point'; import { closestPointIndexIn } from '$lib/geo/closest-point';
import { getElevation, loadSVGIcon } from '$lib/utils'; import { getElevation, loadSVGIcon } from '$lib/utils';
import { selectedWaypoint } from '$lib/components/toolbar/tools/waypoint/waypoint'; import { selectedWaypoint } from '$lib/components/toolbar/tools/waypoint/waypoint';
import { MapPin, Square } from 'lucide-static'; import { MapPin, Square } from 'lucide-static';
@@ -21,6 +21,7 @@ import { currentTool, Tool } from '$lib/components/toolbar/tools';
import { splitAtPoint } from '$lib/components/toolbar/tools/scissors/scissors'; import { splitAtPoint } from '$lib/components/toolbar/tools/scissors/scissors';
import { mapCursor, MapCursorState } from '$lib/state/map-cursor'; import { mapCursor, MapCursorState } from '$lib/state/map-cursor';
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style'; import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
import { hoveredTrackpoint } from '$lib/state/selection-statistics';
export function getSvgForSymbol(symbol?: string | undefined, layerColor?: string | undefined) { export function getSvgForSymbol(symbol?: string | undefined, layerColor?: string | undefined) {
const symbolSvg = symbol ? symbols[symbol]?.iconSvg : undefined; const symbolSvg = symbol ? symbols[symbol]?.iconSvg : undefined;
@@ -63,6 +64,7 @@ export class GPXLayer {
currentWaypointData: GeoJSON.FeatureCollection | null = null; currentWaypointData: GeoJSON.FeatureCollection | null = null;
draggedWaypointIndex: number | null = null; draggedWaypointIndex: number | null = null;
draggingStartingPosition: Point = new Point(0, 0); draggingStartingPosition: Point = new Point(0, 0);
hoversTrackpoint: boolean = false;
unsubscribe: (() => void)[] = []; unsubscribe: (() => void)[] = [];
updateBinded: () => void = this.update.bind(this); updateBinded: () => void = this.update.bind(this);
@@ -400,25 +402,55 @@ export class GPXLayer {
layerOnMouseLeave() { layerOnMouseLeave() {
mapCursor.notify(MapCursorState.SCISSORS, false); mapCursor.notify(MapCursorState.SCISSORS, false);
mapCursor.notify(MapCursorState.LAYER_HOVER, false); mapCursor.notify(MapCursorState.LAYER_HOVER, false);
this.stopHoveringTrackpoint();
} }
async layerOnMouseMove(e: MapLayerMouseEvent) { layerOnMouseMove(e: MapLayerMouseEvent) {
if (e.originalEvent.shiftKey) { // these tools act on the track under the pointer, and show it themselves
const { segmentId } = e.features![0].properties; const tool = get(currentTool);
const lngLat = { lng: e.lngLat.lng, lat: e.lngLat.lat }; const index =
const index = closestPointIndex(await engine.segmentCoordinates(segmentId), lngLat); tool === Tool.ROUTING || tool === Tool.SCISSORS
const trackpoint = ? undefined
index === undefined : this.selectedTrackpointAt(e);
? undefined if (index === undefined) {
: await engine.trackpoint(this.fileId, segmentId, index); this.stopHoveringTrackpoint();
if (trackpoint) { } else {
trackpointPopup?.setItem({ this.hoversTrackpoint = true;
item: trackpoint, hoveredTrackpoint.set(index);
kind: 'trackpoint', }
fileId: this.fileId, }
});
stopHoveringTrackpoint() {
if (this.hoversTrackpoint) {
this.hoversTrackpoint = false;
hoveredTrackpoint.set(null);
}
}
/**
* The trackpoint of the statistics of the selection that is the closest to the pointer, in the
* first segment under it that is part of the selection.
*/
selectedTrackpointAt(e: MapLayerMouseEvent): number | undefined {
const statistics = get(engine.statistics);
if (!statistics.segments) {
return undefined;
}
const { starts, ids } = statistics.segments;
for (const { properties } of e.features!) {
const segment = ids.indexOf(properties.segmentId);
if (segment >= 0) {
const end = segment + 1 < starts.length ? starts[segment + 1] : statistics.length;
return closestPointIndexIn(
statistics.lng,
statistics.lat,
starts[segment],
end,
e.lngLat
);
} }
} }
return undefined;
} }
layerOnClick(e: MapLayerMouseEvent) { layerOnClick(e: MapLayerMouseEvent) {
@@ -1,4 +1,4 @@
import { FileStateCollectionObserver } from '$lib/engine'; import { engine, FileStateCollectionObserver } from '$lib/engine';
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
import { GPXLayer } from './gpx-layer'; import { GPXLayer } from './gpx-layer';
@@ -14,6 +14,7 @@ export class GPXLayerCollection {
if (this._fileStateCollectionObserver) { if (this._fileStateCollectionObserver) {
return; return;
} }
engine.requestStatistics().set(['segments']);
this._fileStateCollectionObserver = new FileStateCollectionObserver( this._fileStateCollectionObserver = new FileStateCollectionObserver(
(newFiles) => { (newFiles) => {
newFiles.forEach((fileStore, fileId) => { newFiles.forEach((fileStore, fileId) => {
@@ -17,7 +17,6 @@
} from '$lib/data/layers'; } from '$lib/data/layers';
import { getLayers, isSelected, toggle } from '$lib/components/map/layer-control/utils'; import { getLayers, isSelected, toggle } from '$lib/components/map/layer-control/utils';
import { i18n } from '$lib/i18n/i18n.svelte'; import { i18n } from '$lib/i18n/i18n.svelte';
import { map } from '$lib/components/map/map';
import CustomLayers from './CustomLayers.svelte'; import CustomLayers from './CustomLayers.svelte';
import { settings } from '$lib/state/settings'; import { settings } from '$lib/state/settings';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
@@ -209,19 +208,6 @@
disabled={selectedOverlay === undefined} disabled={selectedOverlay === undefined}
onValueChange={(value) => { onValueChange={(value) => {
if (selectedOverlay) { if (selectedOverlay) {
if (
$map &&
$currentOverlays &&
isSelected($currentOverlays, selectedOverlay)
) {
try {
if ($map.getLayer(selectedOverlay)) {
$map.removeLayer(selectedOverlay);
}
} catch {
// No reliable way to check if the map is ready to remove sources and layers
}
}
$opacities[selectedOverlay] = value; $opacities[selectedOverlay] = value;
} }
}} }}
@@ -12,6 +12,7 @@
let { let {
name, name,
node, node,
parent = '',
selected = '', selected = '',
onselect = () => {}, onselect = () => {},
multiple = false, multiple = false,
@@ -19,6 +20,7 @@
}: { }: {
name: string; name: string;
node: LayerTreeType; node: LayerTreeType;
parent?: string;
selected?: string; selected?: string;
onselect?: (value: string) => void; onselect?: (value: string) => void;
multiple: boolean; multiple: boolean;
@@ -26,7 +28,21 @@
} = $props(); } = $props();
const { customLayers } = settings; const { customLayers } = settings;
const { isLayerFromExtension, getLayerName } = extensionAPI; const { isLayerFromExtension, getLayerName, isExtensionGroup } = extensionAPI;
/** The groups of extensions are right below the overlays, with the name they were given. */
function getGroupLabel(id: string) {
return parent === 'overlays' && $isExtensionGroup(id)
? id
: i18n._(`layers.label.${id}`, id);
}
function getLabel(id: string) {
if (Object.hasOwn($customLayers, id)) {
return $customLayers[id].name;
}
return $isLayerFromExtension(id) ? $getLayerName(id) : i18n._(`layers.label.${id}`);
}
$effect.pre(() => { $effect.pre(() => {
if (checked !== undefined) { if (checked !== undefined) {
@@ -57,7 +73,7 @@
() => checked[id] === true, (value) => (checked[id] = value) () => checked[id] === true, (value) => (checked[id] = value)
} }
class="scale-90" class="scale-90"
aria-label={i18n._(`layers.label.${id}`)} aria-label={getLabel(id)}
/> />
{:else} {:else}
<input <input
@@ -74,25 +90,20 @@
/> />
{/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 Object.hasOwn($customLayers, id)} {getLabel(id)}
{$customLayers[id].name}
{:else if $isLayerFromExtension(id)}
{$getLayerName(id)}
{:else}
{i18n._(`layers.label.${id}`)}
{/if}
</Label> </Label>
</div> </div>
{/if} {/if}
{:else if anySelectedLayer(node[id])} {:else if anySelectedLayer(node[id])}
<CollapsibleTreeNode {id}> <CollapsibleTreeNode {id}>
{#snippet trigger()} {#snippet trigger()}
<span>{i18n._(`layers.label.${id}`, id)}</span> <span>{getGroupLabel(id)}</span>
{/snippet} {/snippet}
{#snippet content()} {#snippet content()}
<div class="ml-2"> <div class="ml-2">
<Self <Self
node={node[id] as LayerTreeType} node={node[id] as LayerTreeType}
parent={id}
{name} {name}
{selected} {selected}
{onselect} {onselect}
@@ -5,8 +5,10 @@ import { get } from 'svelte/store';
import { overlays, overlayTree, type LayerTreeType } from '$lib/data/layers'; import { overlays, overlayTree, type LayerTreeType } from '$lib/data/layers';
import { engine } from '$lib/engine'; import { engine } from '$lib/engine';
import { settings } from '$lib/state/settings'; import { settings } from '$lib/state/settings';
import { ExtensionAPI, type CustomOverlay } from './extension-api'; import { map } from '$lib/components/map/map';
import { isSelected } from './utils'; import type { StyleSpecification } from 'maplibre-gl';
import { ExtensionAPI, type CustomOverlay, type ExtensionOverlay } from './extension-api';
import { isSelected, prefixStyle } from './utils';
const original = { set: engine.setSetting, delete: engine.deleteSetting }; const original = { set: engine.setSetting, delete: engine.deleteSetting };
@@ -41,6 +43,8 @@ beforeEach(() => {
afterEach(() => { afterEach(() => {
// the layers are shared by the whole app: leave them as they were // the layers are shared by the whole app: leave them as they were
api.filterOverlays([]); api.filterOverlays([]);
api.setOverlays('Heatmaps', []);
api.setOverlays('Other', []);
for (const extension of ['My extension', 'Other extension']) { for (const extension of ['My extension', 'Other extension']) {
delete (overlayTree.overlays as LayerTreeType)[extension]; delete (overlayTree.overlays as LayerTreeType)[extension];
} }
@@ -151,3 +155,346 @@ describe('ExtensionAPI', () => {
assert.equal(Object.keys(extension as LayerTreeType).at(-1), 'extension-two'); assert.equal(Object.keys(extension as LayerTreeType).at(-1), 'extension-two');
}); });
}); });
const rasterStyle = (url = 'https://tiles.example.com/{z}/{x}/{y}.png'): StyleSpecification => ({
version: 8,
sources: { tiles: { type: 'raster', tiles: [url], tileSize: 256 } },
layers: [{ id: 'tiles', type: 'raster', source: 'tiles' }],
});
const v2 = (id: string, extra: Partial<ExtensionOverlay> = {}): ExtensionOverlay => ({
id,
name: `Overlay ${id}`,
style: rasterStyle(),
...extra,
});
const id = (overlayId: string, extension = 'Heatmaps') =>
`extension-${encodeURIComponent(extension)}/${encodeURIComponent(overlayId)}`;
/** The overlays of an extension in a tree, with their state. */
const group = (tree: LayerTreeType | undefined, extension = 'Heatmaps') =>
(tree!.overlays as LayerTreeType)[extension] as LayerTreeType | undefined;
/** What the user does in the layer menu. */
function setShown(overlayId: string, shown: boolean) {
settings.currentOverlays.update((tree) => {
group(tree)![overlayId] = shown;
return tree;
});
}
describe('prefixStyle', () => {
it('prefixes the sources, the layers, and the source of the layers', () => {
const prefixed = prefixStyle('overlay', {
version: 8,
sources: { dem: { type: 'raster-dem', tiles: ['https://dem/{z}/{x}/{y}.png'] } },
layers: [
{ id: 'relief', type: 'color-relief', source: 'dem' },
{ id: 'background', type: 'background' },
],
});
assert.deepEqual(Object.keys(prefixed.sources), ['overlay/dem']);
assert.deepEqual(
prefixed.layers.map((layer) => layer.id),
['overlay/relief', 'overlay/background']
);
assert.equal((prefixed.layers[0] as { source: string }).source, 'overlay/dem');
assert.equal('source' in prefixed.layers[1], false);
});
it('refuses a style whose layers cannot work', () => {
const style = (layers: unknown) =>
({ version: 8, sources: {}, layers }) as StyleSpecification;
assert.throws(() => prefixStyle('o', style(undefined)), /list of layers/);
assert.throws(
() => prefixStyle('o', style([{ id: 'a', type: 'raster', source: 'none' }])),
/unknown source/
);
assert.throws(
() =>
prefixStyle(
'o',
style([
{ id: 'a', type: 'background' },
{ id: 'a', type: 'background' },
])
),
/repeated layer id/
);
});
});
describe('ExtensionAPI v2', () => {
it('adds the overlays of an extension, in the order of the list, hidden by default', () => {
api.setOverlays('Heatmaps', [v2('b'), v2('a', { name: 'A' }), v2('c', { visible: true })]);
const expected = [id('b'), id('a'), id('c')];
assert.deepEqual(Object.keys(group(overlayTree)!), expected);
assert.deepEqual(Object.keys(group(get(settings.selectedOverlayTree))!), expected);
assert.deepEqual(group(get(settings.currentOverlays)), {
[id('b')]: false,
[id('a')]: false,
[id('c')]: true,
});
assert.ok(get(api.isLayerFromExtension)(id('a')));
assert.equal(get(api.getLayerName)(id('a')), 'A');
assert.equal(api.version, 2);
});
it('uses the style as is, with prefixed ids', () => {
api.setOverlays('Heatmaps', [v2('a')]);
const style = overlays[id('a')] as StyleSpecification;
assert.deepEqual(Object.keys(style.sources), [`${id('a')}/tiles`]);
assert.equal(style.layers[0].id, `${id('a')}/tiles`);
assert.equal((style.layers[0] as { source: string }).source, `${id('a')}/tiles`);
});
it('names its overlays after the extension, the way the v1 API does without it', () => {
api.setOverlays('My heatmap', [v2('all')]);
assert.ok(isSelected(overlayTree, 'extension-My%20heatmap/all'));
api.setOverlays('My heatmap', []);
});
it('keeps two extensions with the same overlay ids, or similar names, apart', () => {
api.setOverlays('Heatmaps', [v2('a')]);
api.setOverlays('Other', [v2('a')]);
assert.deepEqual(Object.keys(group(overlayTree)!), [id('a')]);
assert.deepEqual(Object.keys(group(overlayTree, 'Other')!), [id('a', 'Other')]);
api.setOverlays('Heatmaps/a', [v2('b')]);
api.setOverlays('Heatmaps', [v2('a'), v2('a/b')]);
assert.notEqual(id('a/b'), id('b', 'Heatmaps/a'));
assert.ok(get(api.isLayerFromExtension)(id('a/b')));
assert.ok(get(api.isLayerFromExtension)(id('b', 'Heatmaps/a')));
api.setOverlays('Other', []);
assert.deepEqual(Object.keys(group(overlayTree)!).length, 2);
api.setOverlays('Heatmaps/a', []);
});
it('knows the groups of the extensions, of either version', () => {
api.setOverlays('Heatmaps', [v2('a')]);
api.addOrUpdateOverlay(overlay('one'));
assert.equal(get(api.isExtensionGroup)('Heatmaps'), true);
assert.equal(get(api.isExtensionGroup)('My extension'), true);
assert.equal(get(api.isExtensionGroup)('france'), false);
api.setOverlays('Heatmaps', []);
assert.equal(get(api.isExtensionGroup)('Heatmaps'), false);
});
it('removes what is not in the list anymore, with its group when it is empty', () => {
api.setOverlays('Heatmaps', [v2('a', { visible: true }), v2('b')]);
api.setOverlays('Heatmaps', [v2('b')]);
assert.ok(!(id('a') in overlays));
assert.equal(get(api.isLayerFromExtension)(id('a')), false);
assert.equal(isSelected(get(settings.currentOverlays)!, id('a')), false);
assert.deepEqual(Object.keys(group(overlayTree)!), [id('b')]);
api.setOverlays('Heatmaps', []);
assert.equal(group(overlayTree), undefined);
assert.equal(group(get(settings.selectedOverlayTree)), undefined);
assert.equal(group(get(settings.currentOverlays)), undefined);
});
it('keeps the state of an overlay unless told otherwise, and the opacity and menu choices', () => {
api.setOverlays('Heatmaps', [v2('a'), v2('b')]);
setShown(id('a'), true);
settings.opacities.update((opacities) => ({ ...opacities, [id('a')]: 0.3 }));
settings.selectedOverlayTree.update((tree) => {
group(tree)![id('b')] = false;
return tree;
});
api.setOverlays('Heatmaps', [v2('b'), v2('a')]);
assert.equal(isSelected(get(settings.currentOverlays)!, id('a')), true);
assert.equal(isSelected(get(settings.selectedOverlayTree), id('b')), false);
assert.equal(get(settings.opacities)[id('a')], 0.3);
assert.deepEqual(Object.keys(group(get(settings.selectedOverlayTree))!), [
id('b'),
id('a'),
]);
api.setOverlays('Heatmaps', [v2('a', { visible: false }), v2('b', { visible: true })]);
assert.equal(isSelected(get(settings.currentOverlays)!, id('a')), false);
// the user took it out of the menu, where it could not be hidden again
assert.equal(isSelected(get(settings.currentOverlays)!, id('b')), false);
});
it('changes nothing when the list is not valid', () => {
api.setOverlays('Heatmaps', [v2('a')]);
const invalid: unknown[] = [
[v2('b'), v2('b')],
[v2('b'), { id: 'c', name: 'C' }],
[v2('b'), { ...v2('c'), style: { version: 8, sources: {} } }],
'not a list',
];
for (const list of invalid) {
assert.throws(() => api.setOverlays('Heatmaps', list as ExtensionOverlay[]));
}
assert.deepEqual(Object.keys(group(overlayTree)!), [id('a')]);
assert.ok(!(id('b') in overlays));
});
it('refuses the names that cannot be the group of an extension', () => {
for (const name of ['', 'world', 'countries', '__proto__']) {
assert.throws(() => api.setOverlays(name, [v2('a')]));
}
});
it('waits for the settings', () => {
const current = get(settings.currentOverlays);
(settings.currentOverlays as unknown as { _value: { set(v: unknown): void } })._value.set(
undefined
);
assert.throws(() => api.setOverlays('Heatmaps', [v2('a')]), /gpxstudio\.ready/);
settings.currentOverlays.set(current!);
});
it('tells the extension about the actions of the user only', () => {
const calls: Record<string, boolean>[] = [];
const stop = api.onVisibilityChange('Heatmaps', (visible) => calls.push(visible));
const others: unknown[] = [];
api.onVisibilityChange('Other', (visible) => others.push(visible));
api.setOverlays('Other', [v2('x')]);
api.setOverlays('Heatmaps', [v2('a'), v2('b', { visible: true })]);
api.setOverlays('Heatmaps', [v2('a', { visible: true }), v2('b', { visible: false })]);
assert.deepEqual(calls, []);
setShown(id('b'), true);
assert.deepEqual(calls, [{ a: true, b: true }]);
// nothing changed
setShown(id('b'), true);
assert.equal(calls.length, 1);
assert.deepEqual(others, []);
stop();
setShown(id('b'), false);
assert.equal(calls.length, 1);
});
it('tells the extension when the overlays are all hidden from the menu', () => {
const calls: Record<string, boolean>[] = [];
api.onVisibilityChange('Heatmaps', (visible) => calls.push(visible));
api.setOverlays('Heatmaps', [v2('a', { visible: true })]);
settings.currentOverlays.set({ overlays: {} });
assert.deepEqual(calls, [{ a: false }]);
});
it('survives a callback that throws, or that sets the overlays', () => {
const error = console.error;
console.error = () => {};
const calls: unknown[] = [];
api.onVisibilityChange('Heatmaps', () => {
throw new Error('from the extension');
});
api.onVisibilityChange('Heatmaps', (visible) => {
calls.push(visible);
api.setOverlays('Heatmaps', [v2('a', { visible: visible.a })]);
});
api.setOverlays('Heatmaps', [v2('a')]);
setShown(id('a'), true);
console.error = error;
assert.deepEqual(calls, [{ a: true }]);
});
it('forgets the state of the overlays with the next session', () => {
const saved = () => ({
currentOverlays: JSON.stringify(get(settings.currentOverlays)),
previousOverlays: JSON.stringify(get(settings.previousOverlays)),
selectedOverlayTree: JSON.stringify(get(settings.selectedOverlayTree)),
opacities: JSON.stringify(get(settings.opacities)),
});
// what was saved, not the default trees, which are the ones that are allowed
settings.connect(saved());
api.setOverlays('Heatmaps', [v2('a', { visible: true }), v2('b')]);
api.addOrUpdateOverlay(overlay('one'));
settings.selectedOverlayTree.update((tree) => {
group(tree)![id('b')] = false;
return tree;
});
settings.opacities.update((opacities) => ({ ...opacities, [id('a')]: 0.3 }));
const stored = saved();
assert.ok(stored.currentOverlays.includes(id('a')));
settings.connect(stored);
for (const tree of [
get(settings.currentOverlays),
get(settings.previousOverlays),
get(settings.selectedOverlayTree),
]) {
assert.ok(!JSON.stringify(tree).includes('extension-'));
}
assert.equal(get(settings.opacities)[id('a')], 0.3);
// an extension that is still there starts again, as the source of what it remembers
const next = new ExtensionAPI();
next.setOverlays('Heatmaps', [v2('a'), v2('b', { visible: true })]);
assert.equal(isSelected(get(settings.currentOverlays)!, id('a')), false);
assert.equal(isSelected(get(settings.currentOverlays)!, id('b')), true);
assert.equal(isSelected(get(settings.selectedOverlayTree), id('b')), true);
next.setOverlays('Heatmaps', []);
});
it('leaves a style URL for the map to fetch', () => {
const fetchBefore = globalThis.fetch;
globalThis.fetch = (() => assert.fail('fetched')) as typeof fetch;
try {
const url = 'https://example.com/style.json';
api.setOverlays('Heatmaps', [v2('a', { style: url })]);
assert.equal(overlays[id('a')], url);
} finally {
globalThis.fetch = fetchBefore;
}
});
it('removes a style that changes from the map, layers first, but not one that stays', () => {
const calls: string[] = [];
const prefix = id('a');
const fakeMap = {
getStyle: () => ({
sources: { [`${prefix}/tiles`]: {}, [`${prefix}2/tiles`]: {}, core: {} },
layers: [{ id: `${prefix}/tiles` }, { id: `${prefix}2/tiles` }, { id: 'core' }],
}),
removeLayer: (layer: string) => calls.push(`layer ${layer}`),
removeSource: (source: string) => calls.push(`source ${source}`),
};
const store = (map as unknown as { _mapStore: { set(value: unknown): void } })._mapStore;
store.set(fakeMap);
try {
api.setOverlays('Heatmaps', [v2('a', { visible: true })]);
api.setOverlays('Heatmaps', [v2('a', { name: 'Renamed' })]);
assert.deepEqual(calls, []);
api.setOverlays('Heatmaps', [v2('a', { style: rasterStyle('https://other/{z}') })]);
assert.deepEqual(calls, [`layer ${prefix}/tiles`, `source ${prefix}/tiles`]);
calls.length = 0;
api.setOverlays('Heatmaps', [v2('a', { style: 'https://example.com/style.json' })]);
assert.equal(calls.length, 2);
calls.length = 0;
api.setOverlays('Heatmaps', []);
assert.equal(calls.length, 2);
} finally {
store.set(null);
}
});
it('is not affected by the v1 API, and can live next to it', () => {
api.setOverlays('Heatmaps', [v2('a')]);
api.addOrUpdateOverlay(overlay('one', 'Heatmaps'));
api.filterOverlays([]);
assert.ok(id('a') in overlays);
assert.ok(get(api.isLayerFromExtension)(id('a')));
assert.ok(isSelected(overlayTree, id('a')));
api.addOrUpdateOverlay(overlay('one', 'Heatmaps'));
api.setOverlays('Heatmaps', [v2('a'), v2('b')]);
assert.ok(isSelected(overlayTree, 'extension-one'));
api.setOverlays('Heatmaps', []);
assert.ok(isSelected(overlayTree, 'extension-one'));
});
});
@@ -1,12 +1,14 @@
import { settings } from '$lib/state/settings'; import { settings } from '$lib/state/settings';
import type { StyleSpecification } from 'maplibre-gl';
import { derived, get, writable, type Writable } from 'svelte/store'; import { derived, get, writable, type Writable } from 'svelte/store';
import { isSelected, remove, removeAll } from './utils'; import { isSelected, prefixStyle, removeAll } from './utils';
import { layerGroup, overlays, overlayTree, type LayerTreeType } from '$lib/data/layers'; import { layerGroup, overlays, overlayTree, type LayerTreeType } from '$lib/data/layers';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { map } from '$lib/components/map/map'; import { map } from '$lib/components/map/map';
const { currentOverlays, previousOverlays, selectedOverlayTree } = settings; const { currentOverlays, previousOverlays, selectedOverlayTree } = settings;
/** An overlay of the v1 API. */
export type CustomOverlay = { export type CustomOverlay = {
extensionName: string; extensionName: string;
id: string; id: string;
@@ -15,20 +17,97 @@ export type CustomOverlay = {
maxZoom?: number; maxZoom?: number;
}; };
/** An overlay of the v2 API. */
export type ExtensionOverlay = {
/** Unique within the extension. Keep it stable: the opacity and menu choices are tied to it. */
id: string;
name: string;
/**
* The sources and layers of the overlay, or the URL of a style JSON (which must allow
* cross-origin requests). It is used as is, with MapLibre's defaults: a `raster` source has
* tiles of 512 pixels unless its `tileSize` says otherwise. Layers can only use the sources of
* this style, and the rest of a style (`sprite`, `glyphs`, `terrain`...) is ignored.
*/
style: StyleSpecification | string;
/** Applied if given, otherwise the current state is kept (hidden for a new overlay). */
visible?: boolean;
};
/** What the API knows of an overlay. */
type RegisteredOverlay = {
extensionName: string;
name: string;
/** The style given by the extension, to tell when it changes; only for the v2 API. */
signature?: string;
};
type VisibilityCallback = (visible: Record<string, boolean>) => void;
/** The groups of the overlay tree that an extension cannot put its overlays in. */
const reservedGroups = new Set(['__proto__', ...Object.keys(layerGroup(overlayTree.overlays))]);
/** The id of an overlay of the v1 API, whose methods do not all get the name of the extension. */
function getOverlayId(id: string): string {
return `extension-${id}`;
}
/** The id of an overlay of the v2 API. It is encoded so that two extensions cannot clash. */
function getExtensionOverlayId(extensionName: string, id: string): string {
return getOverlayId(`${encodeURIComponent(extensionName)}/${encodeURIComponent(id)}`);
}
/**
* Sets the overlays of an extension in a tree, in order. Its other entries are dropped, except for
* those of `keep`: its overlays of the v1 API, which can be in the same group. An extension that
* has nothing left has no group anymore.
*/
function setGroup(
tree: LayerTreeType,
extensionName: string,
entries: Record<string, boolean>,
keep: (id: string) => boolean
) {
const groups = layerGroup(tree.overlays);
const group = groups[extensionName];
const kept =
typeof group === 'object'
? Object.entries(group).filter(([id]) => keep(id) && !Object.hasOwn(entries, id))
: [];
const updated = { ...entries, ...Object.fromEntries(kept) };
if (Object.keys(updated).length > 0) {
groups[extensionName] = updated;
} else {
delete groups[extensionName];
}
return tree;
}
export class ExtensionAPI { export class ExtensionAPI {
private _overlays: Writable<Map<string, CustomOverlay>> = writable(new Map()); /** The version of the API; the methods of v1 are still there, until extensions have moved. */
readonly version = 2;
/**
* Resolved once overlays can be set. `gpxstudio:extension-api-ready` is sent on `window` at that
* moment.
*/
ready!: Promise<void>;
private _overlays: Writable<Map<string, RegisteredOverlay>> = writable(new Map());
/** What each extension knows of the visibility of its overlays, by its own overlay ids. */
private _visibility: Map<string, Record<string, boolean>> = new Map();
private _visibilityCallbacks: Map<string, Set<VisibilityCallback>> = new Map();
private _watchingVisibility = false;
init() { init() {
if (browser && !Object.hasOwn(window, 'gpxstudio')) { if (browser && !Object.hasOwn(window, 'gpxstudio')) {
this.ready = this.ensureLoaded();
this.ready.then(() => window.dispatchEvent(new Event('gpxstudio:extension-api-ready')));
Object.defineProperty(window, 'gpxstudio', { Object.defineProperty(window, 'gpxstudio', {
value: this, value: this,
}); });
addEventListener('beforeunload', () => {
this.destroy();
});
} }
} }
/** @deprecated v1: use `ready`. */
ensureLoaded(): Promise<void> { ensureLoaded(): Promise<void> {
let unsubscribe: () => void; let unsubscribe: () => void;
const promise = new Promise<void>((resolve) => { const promise = new Promise<void>((resolve) => {
@@ -46,6 +125,168 @@ export class ExtensionAPI {
return promise; return promise;
} }
/**
* Replaces the overlays of an extension: the order of the list is the order in the layer menu,
* and the overlays that are not in the list are removed. The opacity and the choices of the
* layer menu are kept for the ones that stay. Throws, changing nothing, if the list is invalid;
* a style URL is only fetched when the overlay is shown, and a failure is then only logged.
*/
setOverlays(extensionName: string, newOverlays: ExtensionOverlay[]) {
if (typeof extensionName !== 'string' || !extensionName) {
throw new Error('An extension must have a name.');
}
if (reservedGroups.has(extensionName)) {
throw new Error(`The extension name ${extensionName} is reserved.`);
}
if (!Array.isArray(newOverlays)) {
throw new Error('The overlays must be a list.');
}
const current = get(currentOverlays);
if (!current) {
throw new Error('Overlays cannot be set yet: wait for `gpxstudio.ready`.');
}
const previous = this.getOverlaysOf(extensionName);
const next = new Map<string, RegisteredOverlay>();
const styles = new Map<string, StyleSpecification | string>();
for (const overlay of newOverlays) {
if (!overlay?.id || !overlay.name || !overlay.style) {
throw new Error('Overlay must have an id, a name and a style.');
}
const id = getExtensionOverlayId(extensionName, overlay.id);
if (next.has(id)) {
throw new Error(`Overlay ${overlay.id} is in the list twice.`);
}
const signature = JSON.stringify(overlay.style);
// the ids of the style at a URL are prefixed when the map fetches it
styles.set(
id,
typeof overlay.style === 'string'
? overlay.style
: prefixStyle(id, JSON.parse(signature))
);
next.set(id, { extensionName, name: overlay.name, signature });
}
// Nothing can fail from here. The map does not replace a source that it already has, so
// what has changed has to be removed first; it is then added back with the new style.
const removedIds: string[] = [];
previous.forEach((overlay, id) => {
if (next.get(id)?.signature !== overlay.signature) {
this.removeFromMap(id);
}
if (!next.has(id)) {
removedIds.push(id);
delete overlays[id];
}
});
styles.forEach((style, id) => {
overlays[id] = style;
});
this._overlays.update(($overlays) => {
removedIds.forEach((id) => $overlays.delete(id));
next.forEach((overlay, id) => $overlays.set(id, overlay));
return $overlays;
});
const available: Record<string, boolean> = {};
const selected: Record<string, boolean> = {};
const shown: Record<string, boolean> = {};
const visibility: Record<string, boolean> = {};
const selectedGroup = layerGroup(get(selectedOverlayTree).overlays)[extensionName];
newOverlays.forEach((overlay) => {
const id = getExtensionOverlayId(extensionName, overlay.id);
available[id] = true;
// an overlay that the user took out of the layer menu could not be hidden again
selected[id] =
typeof selectedGroup !== 'object' ||
!Object.hasOwn(selectedGroup, id) ||
selectedGroup[id] === true;
shown[id] = selected[id] && Boolean(overlay.visible ?? isSelected(current, id));
visibility[overlay.id] = shown[id];
});
const keep = (id: string) => {
const overlay = get(this._overlays).get(id);
return overlay !== undefined && overlay.signature === undefined;
};
setGroup(overlayTree, extensionName, available, keep);
selectedOverlayTree.update((tree) => setGroup(tree, extensionName, selected, keep));
previousOverlays.update((tree) => removeAll(tree, removedIds));
// this is not an action of the user: there must be nothing to tell the extension about
this._visibility.set(extensionName, visibility);
currentOverlays.update((tree) => setGroup(tree, extensionName, shown, keep));
}
/**
* Calls `callback` with the visibility of all the overlays of the extension, by id, when the
* user shows or hides one of them (not when `setOverlays` does). Returns how to stop.
*/
onVisibilityChange(extensionName: string, callback: VisibilityCallback): () => void {
const callbacks = this._visibilityCallbacks.get(extensionName) ?? new Set();
this._visibilityCallbacks.set(extensionName, callbacks);
callbacks.add(callback);
if (!this._watchingVisibility) {
this._watchingVisibility = true;
currentOverlays.subscribe(() => this.notifyVisibility());
}
return () => {
callbacks.delete(callback);
};
}
private notifyVisibility() {
const current = get(currentOverlays);
if (!current) return;
this._visibilityCallbacks.forEach((callbacks, extensionName) => {
const known = this._visibility.get(extensionName);
if (!known) return;
const visibility = Object.fromEntries(
Object.keys(known).map((id) => [
id,
isSelected(current, getExtensionOverlayId(extensionName, id)),
])
);
if (Object.keys(known).every((id) => known[id] === visibility[id])) return;
this._visibility.set(extensionName, visibility);
callbacks.forEach((callback) => {
try {
callback({ ...visibility });
} catch (e) {
// an extension must not break the updates of the store for the others
console.error(e);
}
});
});
}
private getOverlaysOf(extensionName: string): Map<string, RegisteredOverlay> {
return new Map(
Array.from(get(this._overlays)).filter(
([, overlay]) =>
overlay.extensionName === extensionName && overlay.signature !== undefined
)
);
}
/** Removes the layers and sources of an overlay: their ids start with the one of the overlay. */
private removeFromMap(overlayId: string) {
const map_ = get(map);
if (!map_) return;
try {
const style = map_.getStyle();
style.layers
.filter((layer) => layer.id.startsWith(`${overlayId}/`))
.forEach((layer) => map_.removeLayer(layer.id));
Object.keys(style.sources)
.filter((source) => source.startsWith(`${overlayId}/`))
.forEach((source) => map_.removeSource(source));
} catch {
// No reliable way to check if the map is ready to remove sources and layers
}
}
/** @deprecated v1: use `setOverlays`. */
addOrUpdateOverlay(overlay: CustomOverlay) { addOrUpdateOverlay(overlay: CustomOverlay) {
if ( if (
!overlay.extensionName || !overlay.extensionName ||
@@ -58,7 +299,7 @@ export class ExtensionAPI {
'Overlay must have an extensionName, id, name, and at least one tile URL.' 'Overlay must have an extensionName, id, name, and at least one tile URL.'
); );
} }
overlay.id = this.getOverlayId(overlay.id); overlay.id = getOverlayId(overlay.id);
this._overlays.update(($overlays) => { this._overlays.update(($overlays) => {
$overlays.set(overlay.id, overlay); $overlays.set(overlay.id, overlay);
@@ -119,11 +360,12 @@ export class ExtensionAPI {
}); });
} }
/** @deprecated v1: use `setOverlays`. Leaves the overlays of the v2 API alone. */
filterOverlays(ids: string[]) { filterOverlays(ids: string[]) {
ids = ids.map((id) => this.getOverlayId(id)); ids = ids.map((id) => getOverlayId(id));
const idsToRemove = Array.from(get(this._overlays).keys()).filter( const idsToRemove = Array.from(get(this._overlays))
(id) => !ids.includes(id) .filter(([id, overlay]) => overlay.signature === undefined && !ids.includes(id))
); .map(([id]) => id);
currentOverlays.update((current) => { currentOverlays.update((current) => {
removeAll(current, idsToRemove); removeAll(current, idsToRemove);
@@ -153,8 +395,9 @@ export class ExtensionAPI {
}); });
} }
/** @deprecated v1: use `setOverlays`. */
updateOverlaysOrder(ids: string[]) { updateOverlaysOrder(ids: string[]) {
ids = ids.map((id) => this.getOverlayId(id)); ids = ids.map((id) => getOverlayId(id));
selectedOverlayTree.update((selected) => { selectedOverlayTree.update((selected) => {
const isSelected: Record<string, boolean> = {}; const isSelected: Record<string, boolean> = {};
ids.forEach((id) => { ids.forEach((id) => {
@@ -184,31 +427,11 @@ export class ExtensionAPI {
return (id: string) => $overlays.get(id)?.name || ''; return (id: string) => $overlays.get(id)?.name || '';
}); });
private getOverlayId(id: string): string { /** Whether a group of the layer trees is an extension, whose name is shown as it was given. */
return `extension-${id}`; isExtensionGroup = derived(this._overlays, ($overlays) => {
} const names = new Set(Array.from($overlays.values(), (overlay) => overlay.extensionName));
return (id: string) => names.has(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(); export const extensionAPI = new ExtensionAPI();
@@ -1,7 +1,16 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { describe, it } from 'node:test'; import { describe, it } from 'node:test';
import type { LayerTreeType } from '$lib/data/layers'; import type { LayerTreeType } from '$lib/data/layers';
import { anySelectedLayer, getLayers, isSelected, remove, removeAll, toggle } from './utils'; import type { LayerSpecification } from 'maplibre-gl';
import {
anySelectedLayer,
getLayers,
getOpacityPaint,
isSelected,
remove,
removeAll,
toggle,
} from './utils';
// roads: bike (selected), foot // roads: bike (selected), foot
// satellite: esri, ign: fr (selected) // satellite: esri, ign: fr (selected)
@@ -72,3 +81,39 @@ describe('layer trees', () => {
assert.deepEqual(removeAll(tree(), []), tree()); assert.deepEqual(removeAll(tree(), []), tree());
}); });
}); });
describe('getOpacityPaint', () => {
const layer = (type: string, paint?: Record<string, unknown>) =>
({ id: 'layer', type, source: 'source', paint }) as LayerSpecification;
it('uses the opacity properties of each type of layer', () => {
assert.deepEqual(getOpacityPaint(layer('raster'), 0.5), { 'raster-opacity': 0.5 });
assert.deepEqual(getOpacityPaint(layer('color-relief'), 0.5), {
'color-relief-opacity': 0.5,
});
assert.deepEqual(getOpacityPaint(layer('symbol'), 0.5), {
'icon-opacity': 0.5,
'text-opacity': 0.5,
});
assert.deepEqual(getOpacityPaint(layer('circle'), 0.5), {
'circle-opacity': 0.5,
'circle-stroke-opacity': 0.5,
});
assert.deepEqual(getOpacityPaint(layer('hillshade'), 0.5), {
'hillshade-exaggeration': 0.25,
});
});
it('scales the opacity that the layer already has', () => {
assert.deepEqual(getOpacityPaint(layer('line', { 'line-opacity': 0.5 }), 0.5), {
'line-opacity': 0.25,
});
});
it('replaces an expression, unless the opacity is left at 1', () => {
const expression = ['interpolate', ['linear'], ['zoom'], 5, 0, 10, 1];
const paint = { 'fill-opacity': expression };
assert.deepEqual(getOpacityPaint(layer('fill', paint), 0.5), { 'fill-opacity': 0.5 });
assert.deepEqual(getOpacityPaint(layer('fill', paint), 1), { 'fill-opacity': expression });
});
});
@@ -1,4 +1,89 @@
import type { LayerTreeType } from '$lib/data/layers'; import type { LayerTreeType } from '$lib/data/layers';
import type { LayerSpecification, StyleSpecification } from 'maplibre-gl';
/** Parts of a style that apply to a whole map, not to an overlay. */
const ignoredStyleProperties = ['sprite', 'glyphs', 'terrain', 'sky', 'light', 'projection'];
/** The overlays that were warned about those: a style at a URL is read again and again. */
const warnedOverlays = new Set<string>();
/**
* A copy of the style of an overlay whose sources and layers have ids of their own, so that they
* cannot clash with the ones of gpx.studio or of another overlay.
*/
export function prefixStyle(overlayId: string, style: StyleSpecification): StyleSpecification {
if (typeof style !== 'object' || style === null || !Array.isArray(style.layers)) {
throw new Error(`The style of overlay ${overlayId} must have a list of layers.`);
}
const ignored = ignoredStyleProperties.filter((property) => Object.hasOwn(style, property));
if (ignored.length > 0 && !warnedOverlays.has(overlayId)) {
warnedOverlays.add(overlayId);
console.warn(`Overlay ${overlayId} ignores these style properties: ${ignored.join(', ')}.`);
}
const prefixed = (id: string) => `${overlayId}/${id}`;
const sources = style.sources ?? {};
const layerIds = new Set<string>();
const layers = style.layers.map((layer) => {
if (!layer.id || layerIds.has(layer.id)) {
throw new Error(`Overlay ${overlayId} has a missing or repeated layer id.`);
}
layerIds.add(layer.id);
if ('source' in layer) {
if (!Object.hasOwn(sources, layer.source)) {
throw new Error(
`Layer ${layer.id} of overlay ${overlayId} uses unknown source ${layer.source}.`
);
}
return { ...layer, id: prefixed(layer.id), source: prefixed(layer.source) };
}
return { ...layer, id: prefixed(layer.id) };
}) as LayerSpecification[];
return {
...style,
sources: Object.fromEntries(
Object.entries(sources).map(([id, source]) => [prefixed(id), source])
),
layers,
};
}
/** The paint properties that make a layer transparent, by type of layer. */
const opacityProperties: Partial<Record<LayerSpecification['type'], string[]>> = {
background: ['background-opacity'],
circle: ['circle-opacity', 'circle-stroke-opacity'],
'color-relief': ['color-relief-opacity'],
fill: ['fill-opacity'],
'fill-extrusion': ['fill-extrusion-opacity'],
heatmap: ['heatmap-opacity'],
line: ['line-opacity'],
raster: ['raster-opacity'],
symbol: ['icon-opacity', 'text-opacity'],
};
/**
* The paint properties that give a layer an opacity. The opacity of an overlay applies on top of
* those of its layers, so that they stay different from one another. A value that is computed
* (an expression) cannot always be scaled: the opacity replaces it, unless it is left at 1.
*/
export function getOpacityPaint(
layer: LayerSpecification,
opacity: number
): Record<string, unknown> {
if (layer.type === 'hillshade') {
return { 'hillshade-exaggeration': opacity / 2 };
}
const paint = (layer.paint ?? {}) as Record<string, unknown>;
return Object.fromEntries(
(opacityProperties[layer.type] ?? []).map((property) => {
const value = paint[property];
if (typeof value === 'number') {
return [property, value * opacity];
}
return [property, value !== undefined && opacity === 1 ? value : opacity];
})
);
}
export function anySelectedLayer(node: LayerTreeType) { export function anySelectedLayer(node: LayerTreeType) {
return ( return (
+1 -1
View File
@@ -6,7 +6,7 @@ import MapPopupComponent from '$lib/components/map/MapPopup.svelte';
export type PopupItem<T = any> = { export type PopupItem<T = any> = {
/** Has a position (`lng` and `lat`). */ /** Has a position (`lng` and `lat`). */
item: T; item: T;
/** What the item is: a `WaypointDetails`, a `TrackpointDetails` or an Overpass element. */ /** What the item is: a `WaypointDetails`, the coordinates of a point or an Overpass element. */
kind: 'waypoint' | 'trackpoint' | 'overpass'; kind: 'waypoint' | 'trackpoint' | 'overpass';
fileId?: string; fileId?: string;
hide?: () => void; hide?: () => void;
@@ -0,0 +1,220 @@
// How the map shows the overlays: here, those of extensions that give the URL of their style.
import assert from 'node:assert/strict';
import { afterEach, beforeEach, describe, it } from 'node:test';
import type { Map as MapLibreMap, StyleSpecification } from 'maplibre-gl';
import { writable } from 'svelte/store';
import { overlays, type LayerTreeType } from '$lib/data/layers';
import { engine } from '$lib/engine';
import { settings } from '$lib/state/settings';
import { StyleManager } from './style';
const URL_A = 'https://example.com/a.json';
const URL_B = 'https://example.com/b.json';
const ID = 'extension-Heatmaps/a';
const style = (tiles: string): StyleSpecification => ({
version: 8,
sources: { tiles: { type: 'raster', tiles: [tiles], tileSize: 256 } },
layers: [{ id: 'tiles', type: 'raster', source: 'tiles' }],
});
const original = {
set: engine.setSetting,
delete: engine.deleteSetting,
fetch: globalThis.fetch,
error: console.error,
};
/** A map that only knows of its overlays. */
function fakeMap() {
const sources = new Map<string, { tiles: string[] }>([['empty-source', { tiles: [] }]]);
const layers = new Map<string, { source: string; paint: Record<string, unknown> }>();
return {
sources,
layers,
on: () => {},
setStyle: () => {},
getPitch: () => 0,
getTerrain: () => null,
setTerrain: () => {},
getSource: (id: string) => sources.get(id),
addSource: (id: string, source: { tiles: string[] }) => sources.set(id, source),
getLayer: (id: string) => layers.get(id),
addLayer: (layer: { id: string; source: string; paint: Record<string, unknown> }) =>
layers.set(layer.id, layer),
removeLayer: (id: string) => layers.delete(id),
setPaintProperty: (id: string, name: string, value: unknown) => {
layers.get(id)!.paint[name] = value;
},
};
}
/** Shows the overlay, and gives the manager of the map. */
function setUp(fake: ReturnType<typeof fakeMap>) {
settings.currentOverlays.update((tree) => {
(tree.overlays as LayerTreeType).Heatmaps = { [ID]: true };
return tree;
});
const store = writable<MapLibreMap | null>(null);
const manager = new StyleManager(store, '');
store.set(fake as unknown as MapLibreMap);
return manager;
}
beforeEach(() => {
engine.setSetting = () => {};
engine.deleteSetting = () => {};
settings.connect({});
settings.initialize();
});
afterEach(() => {
delete overlays[ID];
delete overlays.plainUrl;
settings.currentOverlays.update((tree) => {
delete (tree.overlays as LayerTreeType).Heatmaps;
delete (tree.overlays as LayerTreeType).plainUrl;
return tree;
});
engine.setSetting = original.set;
engine.deleteSetting = original.delete;
globalThis.fetch = original.fetch;
console.error = original.error;
});
/** Answers the style requests with the styles of `responses`, by URL. */
function answerWith(responses: Record<string, StyleSpecification>) {
globalThis.fetch = (async (url: string) =>
Object.hasOwn(responses, url)
? new Response(JSON.stringify(responses[url]))
: new Response(JSON.stringify(style('https://basemap/{z}')))) as typeof fetch;
}
describe('StyleManager, with the style URL of an extension', () => {
it('fetches it, and prefixes the ids of its sources and layers', async () => {
answerWith({ [URL_A]: style('https://a/{z}') });
overlays[ID] = URL_A;
const fake = fakeMap();
await setUp(fake).updateOverlays();
assert.deepEqual(fake.sources.get(`${ID}/tiles`)?.tiles, ['https://a/{z}']);
assert.equal(fake.layers.get(`${ID}/tiles`)?.source, `${ID}/tiles`);
assert.ok(!fake.sources.has('tiles'));
});
it('uses the opacity of the overlay', async () => {
answerWith({ [URL_A]: style('https://a/{z}') });
overlays[ID] = URL_A;
settings.opacities.update((opacities) => ({ ...opacities, [ID]: 0.3 }));
const fake = fakeMap();
await setUp(fake).updateOverlays();
assert.equal(fake.layers.get(`${ID}/tiles`)?.paint['raster-opacity'], 0.3);
});
it('removes its layers when it is hidden', async () => {
answerWith({ [URL_A]: style('https://a/{z}') });
overlays[ID] = URL_A;
const fake = fakeMap();
const manager = setUp(fake);
await manager.updateOverlays();
assert.ok(fake.layers.has(`${ID}/tiles`));
settings.currentOverlays.update((tree) => {
(tree.overlays as LayerTreeType).Heatmaps = { [ID]: false };
return tree;
});
await manager.updateOverlays();
assert.ok(!fake.layers.has(`${ID}/tiles`));
});
it('does not fetch it while the overlay is hidden', async () => {
const urls: string[] = [];
answerWith({});
const answer = globalThis.fetch;
globalThis.fetch = ((url: string) => {
urls.push(url);
return answer(url);
}) as typeof fetch;
overlays[ID] = URL_A;
settings.currentOverlays.update((tree) => {
(tree.overlays as LayerTreeType).Heatmaps = { [ID]: false };
return tree;
});
const store = writable<MapLibreMap | null>(null);
const manager = new StyleManager(store, '');
store.set(fakeMap() as unknown as MapLibreMap);
await manager.updateOverlays();
assert.ok(!urls.includes(URL_A));
});
it('ignores a style that was replaced or removed while it was fetched', async () => {
for (const next of [URL_B, undefined]) {
let respond: (response: Response) => void = () => {};
const answers: Record<string, StyleSpecification> = {
[URL_B]: style('https://b/{z}'),
};
answerWith(answers);
const answer = globalThis.fetch;
globalThis.fetch = ((url: string) =>
url === URL_A
? new Promise((resolve) => (respond = resolve))
: answer(url)) as typeof fetch;
overlays[ID] = URL_A;
const fake = fakeMap();
const manager = setUp(fake);
const pending = manager.updateOverlays();
await new Promise((resolve) => setTimeout(resolve));
if (next) {
overlays[ID] = next;
} else {
delete overlays[ID];
}
respond(new Response(JSON.stringify(style('https://a/{z}'))));
await pending;
assert.ok(!fake.sources.has(`${ID}/tiles`));
await manager.updateOverlays();
assert.deepEqual(
fake.sources.get(`${ID}/tiles`)?.tiles,
next ? ['https://b/{z}'] : undefined
);
}
});
it('logs a style that cannot be used, which leaves the others alone', async () => {
const logged: unknown[] = [];
console.error = (...args) => logged.push(args);
for (const answer of [
new Response('', { status: 404 }),
new Response(JSON.stringify({ version: 8, sources: {} })),
]) {
answerWith({});
const basemap = globalThis.fetch;
globalThis.fetch = ((url: string) =>
url === URL_A ? Promise.resolve(answer.clone()) : basemap(url)) as typeof fetch;
overlays[ID] = URL_A;
const fake = fakeMap();
logged.length = 0;
await setUp(fake).updateOverlays();
assert.ok(logged.length > 0);
assert.equal(fake.layers.size, 0);
}
});
it('does not prefix the style URL of anything else', async () => {
answerWith({ [URL_A]: style('https://a/{z}') });
overlays.plainUrl = URL_A;
settings.currentOverlays.update((tree) => {
(tree.overlays as LayerTreeType).plainUrl = { plainUrl: true };
return tree;
});
const fake = fakeMap();
const store = writable<MapLibreMap | null>(null);
const manager = new StyleManager(store, '');
store.set(fake as unknown as MapLibreMap);
await manager.updateOverlays();
assert.ok(fake.sources.has('tiles'));
assert.ok(fake.layers.has('tiles'));
});
});
+40 -20
View File
@@ -14,7 +14,7 @@ import {
overlays, overlays,
terrainSources, terrainSources,
} from '$lib/data/layers'; } from '$lib/data/layers';
import { getLayers } from '$lib/components/map/layer-control/utils'; import { getLayers, getOpacityPaint, prefixStyle } from '$lib/components/map/layer-control/utils';
import { i18n } from '$lib/i18n/i18n.svelte'; import { i18n } from '$lib/i18n/i18n.svelte';
const { currentBasemap, currentOverlays, customLayers, opacities, terrainSource, distanceUnits } = const { currentBasemap, currentOverlays, customLayers, opacities, terrainSource, distanceUnits } =
@@ -147,13 +147,18 @@ export class StyleManager {
const custom = get(customLayers); const custom = get(customLayers);
const overlayOpacities = get(opacities); const overlayOpacities = get(opacities);
try { try {
// an overlay on the map can be gone from the tree, which has been replaced as a whole
const layers = getLayers(get(currentOverlays) ?? {}); const layers = getLayers(get(currentOverlays) ?? {});
this._pastOverlays.forEach((id) => (layers[id] ??= false));
for (const overlay in layers) { for (const overlay in layers) {
if (!layers[overlay]) { const overlayInfo = custom[overlay]?.value ?? overlays[overlay];
if (!overlayInfo) {
// removed, together with what it had on the map
this._pastOverlays.delete(overlay);
} else if (!layers[overlay]) {
if (this._pastOverlays.has(overlay)) { if (this._pastOverlays.has(overlay)) {
const overlayInfo = custom[overlay]?.value ?? overlays[overlay];
try { try {
const overlayStyle = await this.get(overlayInfo); const overlayStyle = await this.getOverlayStyle(overlay, overlayInfo);
for (const layer of overlayStyle.layers ?? []) { for (const layer of overlayStyle.layers ?? []) {
if (map_.getLayer(layer.id)) { if (map_.getLayer(layer.id)) {
map_.removeLayer(layer.id); map_.removeLayer(layer.id);
@@ -165,9 +170,10 @@ export class StyleManager {
this._pastOverlays.delete(overlay); this._pastOverlays.delete(overlay);
} }
} else { } else {
const overlayInfo = custom[overlay]?.value ?? overlays[overlay];
try { try {
const overlayStyle = await this.get(overlayInfo); const overlayStyle = await this.getOverlayStyle(overlay, overlayInfo);
// replaced or removed while it was fetched: its new style has its own turn
if (!custom[overlay] && overlays[overlay] !== overlayInfo) continue;
const opacity = overlayOpacities[overlay]; const opacity = overlayOpacities[overlay];
for (const sourceId in overlayStyle.sources) { for (const sourceId in overlayStyle.sources) {
@@ -177,21 +183,24 @@ export class StyleManager {
} }
for (const layer of overlayStyle.layers ?? []) { for (const layer of overlayStyle.layers ?? []) {
if (!map_.getLayer(layer.id)) { const paint =
if (opacity !== undefined) { opacity === undefined ? {} : getOpacityPaint(layer, opacity);
if (layer.type === 'raster') { if (map_.getLayer(layer.id)) {
if (!layer.paint) { for (const property in paint) {
layer.paint = {}; map_.setPaintProperty(
} layer.id,
layer.paint['raster-opacity'] = opacity; property as never,
} else if (layer.type === 'hillshade') { paint[property] as never
if (!layer.paint) { );
layer.paint = {};
}
layer.paint['hillshade-exaggeration'] = opacity / 2;
}
} }
map_.addLayer(layer, ANCHOR_LAYER_KEY.overlays); } else {
map_.addLayer(
{
...layer,
paint: { ...layer.paint, ...paint },
} as LayerSpecification,
ANCHOR_LAYER_KEY.overlays
);
} }
} }
this._pastOverlays.add(overlay); this._pastOverlays.add(overlay);
@@ -221,6 +230,17 @@ export class StyleManager {
} }
} }
/** The style of an overlay. The ids of one from an extension's URL still have to be prefixed. */
private async getOverlayStyle(
overlay: string,
styleInfo: StyleSpecification | string
): Promise<StyleSpecification> {
const style = await this.get(styleInfo);
return typeof styleInfo === 'string' && overlay.startsWith('extension-')
? prefixStyle(overlay, style)
: style;
}
async get(styleInfo: StyleSpecification | string): Promise<StyleSpecification> { async get(styleInfo: StyleSpecification | string): Promise<StyleSpecification> {
if (typeof styleInfo === 'string') { if (typeof styleInfo === 'string') {
const styleUrl = styleInfo as string; const styleUrl = styleInfo as string;
@@ -218,6 +218,7 @@
</span> </span>
{:else} {:else}
<TimePicker <TimePicker
id="speed"
bind:value={speed} bind:value={speed}
showHours={false} showHours={false}
disabled={!canUpdate} disabled={!canUpdate}
@@ -243,6 +244,7 @@
{i18n._('toolbar.time.total_time')} {i18n._('toolbar.time.total_time')}
</Label> </Label>
<TimePicker <TimePicker
id="duration"
bind:value={movingTime} bind:value={movingTime}
disabled={!canUpdate} disabled={!canUpdate}
onChange={() => { onChange={() => {
@@ -633,6 +633,11 @@ export class RoutingControls {
const anchor = this.anchors[e.features![0].properties.anchorIndex]; const anchor = this.anchors[e.features![0].properties.anchorIndex];
if (!anchor) { if (!anchor) {
// The temporary anchor on the line is not one of the anchors (nor is a feature
// still rendered from before they were rebuilt), a click on it adds it
if (e.type === 'click') {
this.addIntermediateAnchor(e);
}
return; return;
} }
if (e.originalEvent.shiftKey) { if (e.originalEvent.shiftKey) {
@@ -45,6 +45,19 @@
let marker: Marker | null = null; let marker: Marker | null = null;
// A number field holds what is typed, out of range or empty (null) on the way to a
// coordinate, and a map click sets strings. MapLibre throws on a latitude past ±90.
function inRange(value: number | string | null, limit: number) {
if (value === null || value === '') {
return false;
}
const n = Number(value);
return Number.isFinite(n) && Math.abs(n) <= limit;
}
let latitudeValid = $derived(inRange(latitude, 90));
let longitudeValid = $derived(inRange(longitude, 180));
let coordinatesValid = $derived(latitudeValid && longitudeValid);
function reset() { function reset() {
if ($selectedWaypoint) { if ($selectedWaypoint) {
selectedWaypoint.reset(); selectedWaypoint.reset();
@@ -78,6 +91,9 @@
}); });
async function createOrUpdateWaypoint() { async function createOrUpdateWaypoint() {
if (!coordinatesValid) {
return;
}
if (typeof latitude === 'string') { if (typeof latitude === 'string') {
latitude = parseFloat(latitude); latitude = parseFloat(latitude);
} }
@@ -128,7 +144,8 @@
marker = null; marker = null;
} }
} else if (latitude != 0 || longitude != 0) { } else if (latitude != 0 || longitude != 0) {
if ($map) { // while a coordinate is being typed the pin stays where it last was
if ($map && coordinatesValid) {
if (marker) { if (marker) {
marker.setLngLat([longitude, latitude]).getElement().innerHTML = marker.setLngLat([longitude, latitude]).getElement().innerHTML =
getSvgForSymbol(symbolKey); getSvgForSymbol(symbolKey);
@@ -248,6 +265,7 @@
min={-90} min={-90}
max={90} max={90}
class="text-xs h-8" class="text-xs h-8"
aria-invalid={!latitudeValid}
disabled={!canCreate && !$selectedWaypoint} disabled={!canCreate && !$selectedWaypoint}
/> />
</div> </div>
@@ -261,6 +279,7 @@
min={-180} min={-180}
max={180} max={180}
class="text-xs h-8" class="text-xs h-8"
aria-invalid={!longitudeValid}
disabled={!canCreate && !$selectedWaypoint} disabled={!canCreate && !$selectedWaypoint}
/> />
</div> </div>
@@ -269,7 +288,7 @@
<div class="flex flex-row gap-1.5 items-center"> <div class="flex flex-row gap-1.5 items-center">
<Button <Button
variant="outline" variant="outline"
disabled={!canCreate && !$selectedWaypoint} disabled={(!canCreate && !$selectedWaypoint) || !coordinatesValid}
class="grow shrink h-fit min-h-8 whitespace-normal py-1" class="grow shrink h-fit min-h-8 whitespace-normal py-1"
onclick={createOrUpdateWaypoint} onclick={createOrUpdateWaypoint}
> >
@@ -1,63 +1,40 @@
<script lang="ts"> <script lang="ts" module>
import { Input } from '$lib/components/ui/input'; /** Shown by an empty field: real text rather than a placeholder, so that it can be selected. */
export const EMPTY_FIELD = '--';
let {
id,
value = $bindable(),
disabled,
oninput = () => {},
onchange = () => {},
onkeypress = () => {},
onfocusin = () => {},
class: className,
}: {
id: string;
value: string | number;
disabled?: boolean;
oninput?: (e: Event) => void;
onchange?: (e: Event) => void;
onkeypress?: (e: KeyboardEvent) => void;
onfocusin?: (e: FocusEvent) => void;
class?: string;
} = $props();
</script> </script>
<div> <script lang="ts">
<Input import { cn, type WithElementRef } from '$lib/utils.js';
{id} import type { HTMLInputAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
value,
class: className,
...restProps
}: WithElementRef<Omit<HTMLInputAttributes, 'type' | 'value'>> & { value: string } = $props();
// The padding of the fields of a native time input, which is larger in Firefox.
const padding = 'px-px [@supports(-moz-appearance:none)]:px-[3px]';
</script>
<!-- The invisible copy of the text sizes the field to its exact width, like in a native time input. -->
<span class="relative inline-block">
<span aria-hidden="true" class={cn('invisible block whitespace-pre', padding)}>
{value || EMPTY_FIELD}
</span>
<input
bind:this={ref}
type="text" type="text"
step={1} inputmode="numeric"
bind:value autocomplete="off"
{disabled} value={value || EMPTY_FIELD}
{oninput} class={cn(
{onchange} 'absolute inset-0 w-full bg-transparent text-center outline-none',
{onkeypress} padding,
onfocusin={(e) => { className
let input = document.activeElement; )}
if (input instanceof HTMLInputElement) { onfocus={(e) => e.currentTarget.select()}
input.select(); {...restProps}
}
onfocusin(e);
}}
class="w-[22px] {className ?? ''}"
/> />
</div> </span>
<style lang="postcss">
@reference "tailwindcss";
div :global(input) {
@apply px-0.5;
@apply py-0;
@apply bg-transparent;
@apply text-right;
@apply border-none;
@apply shadow-none;
@apply focus:ring-0;
@apply focus:ring-offset-0;
@apply focus:outline-none;
@apply focus-visible:ring-0;
@apply focus-visible:ring-offset-0;
@apply focus-visible:outline-none;
}
</style>
@@ -1,174 +1,176 @@
<script lang="ts"> <script lang="ts">
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import TimeComponentInput from './TimeComponentInput.svelte'; import { cn } from '$lib/utils.js';
import TimeComponentInput, { EMPTY_FIELD } from './TimeComponentInput.svelte';
let { let {
id,
showHours = true, showHours = true,
value = $bindable(), value = $bindable(),
disabled = false, disabled = false,
onChange = () => {}, onChange = () => {},
}: { }: {
/** Set on the first field, so that a label can target the picker. */
id?: string;
showHours?: boolean; showHours?: boolean;
value?: number; value?: number;
disabled?: boolean; disabled?: boolean;
onChange?: () => void; onChange?: () => void;
} = $props(); } = $props();
let hours: string | number = $state('--'); let hours = $state('');
let minutes: string | number = $state('--'); let minutes = $state('');
let seconds: string | number = $state('--'); let seconds = $state('');
function maybeParseInt(value: string | number): number { let hoursInput: HTMLInputElement | null = $state(null);
if (value === '--' || value === '') { let minutesInput: HTMLInputElement | null = $state(null);
return 0; let secondsInput: HTMLInputElement | null = $state(null);
function parseField(field: string): number {
return field === '' ? 0 : parseInt(field, 10);
}
function fieldsToSeconds(): number {
const total =
(showHours ? parseField(hours) * 3600 : 0) +
parseField(minutes) * 60 +
parseField(seconds);
// Callers divide by this value, so it must stay positive.
return Math.max(total, 1);
}
function pad(field: number): string {
return field.toString().padStart(2, '0');
}
function showValue(newValue: number | undefined) {
if (!Number.isFinite(newValue)) {
hours = minutes = seconds = '';
return;
} }
return typeof value === 'string' ? parseInt(value) : value; const total = Math.max(Math.round(newValue!), 1);
} const totalMinutes = Math.floor(total / 60);
if (showHours) {
function computeValue(): number { hours = Math.floor(total / 3600).toString();
return Math.max( minutes = pad(totalMinutes % 60);
maybeParseInt(hours) * 3600 + maybeParseInt(minutes) * 60 + maybeParseInt(seconds), } else {
1 minutes = totalMinutes.toString();
); }
seconds = pad(total % 60);
} }
// Only the display follows `value`: it is written back on user input only, so that showing a
// value never rounds or overwrites it. A value that matches the fields comes from them, and
// must not reformat them during typing.
$effect(() => { $effect(() => {
const val = computeValue(); const newValue = value;
void showHours;
untrack(() => { untrack(() => {
value = val; if (newValue !== fieldsToSeconds()) {
showValue(newValue);
}
}); });
}); });
$effect(() => { /** A field without an upper bound: hours, or minutes when there are no hours. */
if (value === undefined) { function normalizeUnbounded(input: string): string {
untrack(() => { const digits = input.replace(/\D/g, '').slice(-6);
hours = '--'; return digits === '' ? '' : parseInt(digits, 10).toString();
minutes = '--'; }
seconds = '--';
});
} else {
untrack(() => {
if (value != computeValue()) {
let rounded = Math.max(Math.round(value!), 1);
if (showHours) {
hours = Math.floor(rounded / 3600);
minutes = Math.floor((rounded % 3600) / 60)
.toString()
.padStart(2, '0');
} else {
minutes = Math.floor(rounded / 60).toString();
}
seconds = (rounded % 60).toString().padStart(2, '0');
}
});
}
});
let container: HTMLDivElement; /** Minutes or seconds: new digits shift in from the right, so "12" then "3" gives "23". */
let countKeyPress = 0; function normalizeSexagesimal(input: string): string {
function onKeyPress(e: KeyboardEvent) { const digits = input.replace(/\D/g, '').slice(-2);
const target = e.target as HTMLInputElement | null; return digits === '' ? '' : pad(Math.min(parseInt(digits, 10), 59));
if (target && ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'].includes(e.key)) { }
countKeyPress++;
if (countKeyPress === 2) { let digitsTyped = 0;
const nextInput =
target.id === 'hours' function handleInput(
? (container.querySelector('#minutes') as HTMLInputElement) e: Event & { currentTarget: HTMLInputElement },
: target.id === 'minutes' normalize: (input: string) => string,
? (container.querySelector('#seconds') as HTMLInputElement) setField: (field: string) => void,
: null; nextInput: HTMLInputElement | null
if (nextInput) { ) {
nextInput.focus(); const input = e.currentTarget;
} const field = normalize(input.value);
// The state is unchanged when the input is rejected (a letter, for example), in which case
// Svelte would leave the rejected text in the DOM.
input.value = field || EMPTY_FIELD;
if (!field) {
input.select();
}
setField(field);
value = fieldsToSeconds();
onChange();
if (/^\d$/.test((e as unknown as InputEvent).data ?? '')) {
digitsTyped++;
if (digitsTyped === 2) {
nextInput?.focus();
} }
} }
} }
function handlePointerDown(e: PointerEvent) {
// Like a native input, the whole box gives focus, not only the text.
if (e.target === e.currentTarget) {
e.preventDefault();
(hoursInput ?? minutesInput)?.focus();
}
}
function handleFocusOut(e: FocusEvent & { currentTarget: HTMLDivElement }) {
// Fill in the fields left empty once the user is done with the picker.
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
showValue(value);
}
}
</script> </script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div <div
bind:this={container} data-slot="time-picker"
class="h-9 flex flex-row items-center w-full min-w-fit border rounded-md px-3 focus-within:outline-none focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 {disabled class={cn(
? 'opacity-50 cursor-not-allowed' 'dark:bg-input/30 border-input focus-within:border-ring focus-within:ring-ring/50 flex h-8 w-full min-w-28 cursor-text items-center justify-end rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors focus-within:ring-3 md:text-sm',
: ''}" disabled && 'bg-input/50 dark:bg-input/80 pointer-events-none cursor-not-allowed opacity-50'
)}
onpointerdown={handlePointerDown}
onfocusin={() => {
digitsTyped = 0;
}}
onfocusout={handleFocusOut}
> >
{#if showHours} {#if showHours}
<TimeComponentInput <TimeComponentInput
id="hours" {id}
bind:value={hours} bind:ref={hoursInput}
value={hours}
{disabled} {disabled}
class="w-[30px]" oninput={(e) =>
oninput={() => { handleInput(e, normalizeUnbounded, (field) => (hours = field), minutesInput)}
if (typeof hours === 'string') {
hours = parseInt(hours);
}
if (hours < 0) {
hours = 0;
}
onChange();
}}
onkeypress={onKeyPress}
onfocusin={() => {
countKeyPress = 0;
}}
/> />
<span class="text-sm">:</span> <span>:</span>
{/if} {/if}
<TimeComponentInput <TimeComponentInput
id="minutes" id={showHours ? undefined : id}
bind:value={minutes} bind:ref={minutesInput}
value={minutes}
{disabled} {disabled}
oninput={() => { oninput={(e) =>
if (typeof minutes === 'string') { handleInput(
minutes = parseInt(minutes); e,
} showHours ? normalizeSexagesimal : normalizeUnbounded,
if (showHours && minutes > 59) { (field) => (minutes = field),
minutes = 59; secondsInput
} else if (minutes < 0) { )}
minutes = 0;
}
minutes = minutes.toString().padStart(showHours ? 2 : 1, '0');
onChange();
}}
onkeypress={onKeyPress}
onfocusin={() => {
countKeyPress = 0;
}}
/> />
<span class="text-sm">:</span> <span>:</span>
<TimeComponentInput <TimeComponentInput
id="seconds" bind:ref={secondsInput}
bind:value={seconds} value={seconds}
{disabled} {disabled}
oninput={() => { oninput={(e) => handleInput(e, normalizeSexagesimal, (field) => (seconds = field), null)}
if (typeof seconds === 'string') {
seconds = parseInt(seconds);
}
if (seconds > 59) {
seconds = 59;
} else if (seconds < 0) {
seconds = 0;
}
seconds = seconds.toString().padStart(2, '0');
onChange();
}}
onkeypress={onKeyPress}
onfocusin={() => {
countKeyPress = 0;
}}
/> />
</div> </div>
<style>
div :global(input::-webkit-outer-spin-button) {
-webkit-appearance: none;
margin: 0;
}
div :global(input::-webkit-inner-spin-button) {
-webkit-appearance: none;
margin: 0;
}
div :global(input[type='number']) {
appearance: textfield;
-moz-appearance: textfield;
}
</style>
+58
View File
@@ -0,0 +1,58 @@
import { BundleType, getBundleType, getVersion } from '@tauri-apps/api/app';
import { relaunch } from '@tauri-apps/plugin-process';
import { check, type Update } from '@tauri-apps/plugin-updater';
import { toast } from 'svelte-sonner';
import { engine } from '$lib/engine';
import { i18n } from '$lib/i18n/i18n.svelte';
/** Where the `.deb` and `.rpm` packages can be downloaded, as they do not update themselves. */
const releases = 'https://github.com/gpxstudio/gpx.studio/releases/latest';
/**
* Offers the update, if there is one. The app does not restart by itself, as it would interrupt
* the user. The beta versions are not updated, as they would be replaced by the stable release.
*/
export async function checkForUpdates() {
if (import.meta.env.DEV || (await getVersion()).includes('-')) {
return;
}
const update = await check().catch((error) => {
// most likely offline: it will be checked again the next time the app starts
console.warn('Could not check for updates', error);
return null;
});
if (update === null) {
return;
}
const message = i18n._('desktop.update_available').replace('{version}', update.version);
const bundle = await getBundleType();
if (bundle === BundleType.Deb || bundle === BundleType.Rpm) {
toast.info(message, {
duration: Number.POSITIVE_INFINITY,
action: {
label: i18n._('desktop.update_download'),
onClick: () => engine.openUrl(releases),
},
});
} else {
toast.info(message, {
duration: Number.POSITIVE_INFINITY,
action: {
label: i18n._('desktop.update_restart'),
onClick: () => install(update),
},
});
}
}
async function install(update: Update) {
const installing = toast.loading(i18n._('desktop.update_installing'));
try {
await update.downloadAndInstall();
await relaunch();
} catch (error) {
console.error('Could not install the update', error);
toast.error(i18n._('desktop.update_failed'), { id: installing });
}
}
+4 -4
View File
@@ -40,7 +40,7 @@ Además, la selección debe ser un solo archivo.
### <Plus size="16" class="inline-block" style="margin-bottom: 2px" /> New route ### <Plus size="16" class="inline-block" style="margin-bottom: 2px" /> New route
Create a new route in the selected file. Crear una nueva ruta en el archivo seleccionado.
<DocsNote> <DocsNote>
@@ -62,10 +62,10 @@ Además, la selección debe ser una sola pista.
### <ArrowRightLeft size="16" class="inline-block" style="margin-bottom: 2px" /> Convert to route/track ### <ArrowRightLeft size="16" class="inline-block" style="margin-bottom: 2px" /> Convert to route/track
Convert the selected routes to tracks, or the selected tracks or segments to routes. Convertir las rutas seleccionadas a recorridos o los recorridos o segmentos seleccionados a rutas.
Since a route has a single segment, a track with multiple segments is converted into one route per segment. Dado que una ruta tiene un solo segmento, un recorrido con múltiples segmentos se convierte en una ruta por segmento.
When the selected files contain only routes, or only tracks, you can also convert all of them at once. Cuando los archivos seleccionados solo contienen rutas o pistas, también puede convertir todos a la vez.
### <FileStack size="16" class="inline-block" style="margin-bottom: 2px" /> Seleccionar todo ### <FileStack size="16" class="inline-block" style="margin-bottom: 2px" /> Seleccionar todo
+1 -3
View File
@@ -159,8 +159,6 @@ export async function createTauriBackend(): Promise<Backend> {
(await invoke('file_statistics', { fileId })) ?? undefined, (await invoke('file_statistics', { fileId })) ?? undefined,
waypoint: async (fileId, waypointId) => waypoint: async (fileId, waypointId) =>
(await invoke('waypoint', { fileId, waypointId })) ?? undefined, (await invoke('waypoint', { fileId, waypointId })) ?? undefined,
trackpoint: async (fileId, segmentId, index) =>
(await invoke('trackpoint', { fileId, segmentId, index })) ?? undefined,
export_file: async (fileId, time, hr, cad, atemp, power, osm) => { export_file: async (fileId, time, hr, cad, atemp, power, osm) => {
const file = await optionalArray('export_file', Uint8Array, { const file = await optionalArray('export_file', Uint8Array, {
fileId, fileId,
@@ -245,7 +243,7 @@ export async function createTauriBackend(): Promise<Backend> {
importsLegacyData: false, importsLegacyData: false,
open_storage: () => invoke<StorageOpened>('open_storage'), open_storage: () => invoke<StorageOpened>('open_storage'),
flush_storage: () => invoke('flush_storage'), flush_storage: () => invoke('flush_storage').then(() => true),
sync_storage: () => invoke('sync_storage'), sync_storage: () => invoke('sync_storage'),
set_setting: (key, value) => invoke('set_setting', { key, value }), set_setting: (key, value) => invoke('set_setting', { key, value }),
delete_setting: (key) => invoke('delete_setting', { key }), delete_setting: (key) => invoke('delete_setting', { key }),
+2 -8
View File
@@ -8,7 +8,6 @@ import type {
Selection, Selection,
SelectionPoint, SelectionPoint,
StorageOpened, StorageOpened,
TrackpointDetails,
WaypointDetails, WaypointDetails,
} from 'gpx-wasm'; } from 'gpx-wasm';
import type { ExportOptions, MergeType, SelectMode, SplitType } from '../types'; import type { ExportOptions, MergeType, SelectMode, SplitType } from '../types';
@@ -266,11 +265,6 @@ export interface Backend {
file_structure(fileId: string): Promise<FileStructure | undefined>; file_structure(fileId: string): Promise<FileStructure | undefined>;
file_statistics(fileId: string): Promise<GlobalStatistics | undefined>; file_statistics(fileId: string): Promise<GlobalStatistics | undefined>;
waypoint(fileId: string, waypointId: string): Promise<WaypointDetails | undefined>; waypoint(fileId: string, waypointId: string): Promise<WaypointDetails | undefined>;
trackpoint(
fileId: string,
segmentId: string,
index: number
): Promise<TrackpointDetails | undefined>;
/** A file as GPX (UTF-8 bytes), `undefined` if it does not exist. */ /** A file as GPX (UTF-8 bytes), `undefined` if it does not exist. */
export_file( export_file(
fileId: string, fileId: string,
@@ -340,8 +334,8 @@ export interface Backend {
onBehind: () => void, onBehind: () => void,
onNewer: () => void onNewer: () => void
): Promise<StorageOpened>; ): Promise<StorageOpened>;
/** Resolves when what was changed has been written to the storage. */ /** Resolves when the writes of what was changed are done, to whether they went well. */
flush_storage(): Promise<void>; flush_storage(): Promise<boolean>;
/** Reads the files again if another window wrote them since, and tells what changed. */ /** Reads the files again if another window wrote them since, and tells what changed. */
sync_storage(): Promise<Outcome>; sync_storage(): Promise<Outcome>;
set_setting(key: string, json: string): Promise<void>; set_setting(key: string, json: string): Promise<void>;
-1
View File
@@ -123,7 +123,6 @@ export async function createWasmBackend(): Promise<Backend> {
file_structure: lift(w.file_structure), file_structure: lift(w.file_structure),
file_statistics: lift(w.file_statistics), file_statistics: lift(w.file_statistics),
waypoint: lift(w.waypoint), waypoint: lift(w.waypoint),
trackpoint: lift(w.trackpoint),
// a copy out of the memory of the engine, so a plain ArrayBuffer // a copy out of the memory of the engine, so a plain ArrayBuffer
export_file: lift( export_file: lift(
(...args: Parameters<Backend['export_file']>) => (...args: Parameters<Backend['export_file']>) =>
+3 -1
View File
@@ -259,7 +259,7 @@ describe('statistics', () => {
assert.equal(get(engine.statistics).hr, undefined); assert.equal(get(engine.statistics).hr, undefined);
const request = engine.requestStatistics(); const request = engine.requestStatistics();
await request.set(['hr', 'anchors', 'slopeSegment']); await request.set(['hr', 'anchors', 'slopeSegment', 'segments']);
const statistics = get(engine.statistics); const statistics = get(engine.statistics);
assert.equal(statistics.slopeSegments!.starts[0], 0); assert.equal(statistics.slopeSegments!.starts[0], 0);
assert.equal( assert.equal(
@@ -270,6 +270,8 @@ describe('statistics', () => {
assert.ok(statistics.hr!.some((hr) => !Number.isNaN(hr))); assert.ok(statistics.hr!.some((hr) => !Number.isNaN(hr)));
assert.equal(statistics.cad, undefined); assert.equal(statistics.cad, undefined);
assert.equal(statistics.anchors!.indices[0], 0); assert.equal(statistics.anchors!.indices[0], 0);
assert.deepEqual([...statistics.segments!.starts], [...statistics.anchors!.segmentStarts]);
assert.deepEqual(statistics.segments!.ids, statistics.anchors!.segmentIds);
assert.equal(statistics.anchors!.indices.at(-1), statistics.length - 1); assert.equal(statistics.anchors!.indices.at(-1), statistics.length - 1);
// they are read again after a change, without being asked again // they are read again after a change, without being asked again
-21
View File
@@ -33,7 +33,6 @@ import {
type FolderEntry, type FolderEntry,
type UnsavedChoice, type UnsavedChoice,
type WorkspaceState, type WorkspaceState,
type TrackpointDetails,
type WaypointDetails, type WaypointDetails,
} from './types'; } from './types';
@@ -881,15 +880,6 @@ class Engine {
return this.read((b) => b.waypoint(fileId, waypointId), undefined); return this.read((b) => b.waypoint(fileId, waypointId), undefined);
} }
/** A trackpoint of a segment, `undefined` if it does not exist. */
trackpoint(
fileId: string,
segmentId: string,
index: number
): Promise<TrackpointDetails | undefined> {
return this.read((b) => b.trackpoint(fileId, segmentId, index), undefined);
}
/** /**
* A file as GPX (UTF-8 bytes), `undefined` if it does not exist. The options say which data of the * A file as GPX (UTF-8 bytes), `undefined` if it does not exist. The options say which data of the
* trackpoints to keep. * trackpoints to keep.
@@ -935,17 +925,6 @@ class Engine {
return this.read((b) => b.reduction_distances(), new Float64Array()); return this.read((b) => b.reduction_distances(), new Float64Array());
} }
// Coordinates, as flat [lng, lat, ...] arrays: copies.
// Refetch them when the `rev` of the segment (or the `waypointsRev` of the file) changed.
segmentCoordinates(segmentId: string): Promise<Float64Array> {
return this.read((b) => b.segment_coordinates(segmentId), new Float64Array());
}
waypointCoordinates(fileId: string): Promise<Float64Array> {
return this.read((b) => b.waypoint_coordinates(fileId), new Float64Array());
}
/** /**
* Registers a consumer of the statistics that needs some of the metrics that are not part of * Registers a consumer of the statistics that needs some of the metrics that are not part of
* them by default (see `StatisticsMetric`). The metrics asked for by all the consumers are * them by default (see `StatisticsMetric`). The metrics asked for by all the consumers are
@@ -9,7 +9,6 @@ import {
hasSelectionWithin, hasSelectionWithin,
isCovered, isCovered,
isInClipboard, isInClipboard,
isEmpty,
isSelected, isSelected,
isSelectionHidden, isSelectionHidden,
selectedElementIds, selectedElementIds,
@@ -68,12 +67,6 @@ describe('selection helpers', () => {
} }
}); });
it('tells whether the selection is empty', () => {
assert.equal(isEmpty(empty), true);
assert.equal(isEmpty(files('F1')), false);
assert.equal(isEmpty(waypoints), false);
});
it('lists the files holding something selected', () => { it('lists the files holding something selected', () => {
assert.deepEqual(selectedFileIds(empty), []); assert.deepEqual(selectedFileIds(empty), []);
assert.deepEqual(selectedFileIds(files('F1', 'F2')), ['F1', 'F2']); assert.deepEqual(selectedFileIds(files('F1', 'F2')), ['F1', 'F2']);
@@ -65,10 +65,6 @@ export function coversSegments(selection: Selection): boolean {
); );
} }
export function isEmpty(selection: Selection): boolean {
return selection.type === 'empty';
}
/** Ids of the files holding something selected (or selected themselves). */ /** Ids of the files holding something selected (or selected themselves). */
export function selectedFileIds(selection: Selection): string[] { export function selectedFileIds(selection: Selection): string[] {
switch (selection.type) { switch (selection.type) {
+8
View File
@@ -143,6 +143,14 @@ export class StatisticsReader {
}; };
break; break;
} }
case 'segments': {
const [starts, ids] = await Promise.all([
backend.segment_starts(),
backend.segment_ids(),
]);
statistics.segments = { starts, ids };
break;
}
case 'highway': case 'highway':
[statistics.highway, statistics.sacScale, statistics.mtbScale] = [statistics.highway, statistics.sacScale, statistics.mtbScale] =
await Promise.all([ await Promise.all([
+120
View File
@@ -0,0 +1,120 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { LEGACY_IMPORTED_KEY, type LegacyData } from '$lib/storage/legacy-import';
import { openStoredFiles, type StorageHost } from '$lib/engine/storage';
// a channel would keep the tests running: there are no other tabs here
Object.defineProperty(globalThis, 'BroadcastChannel', { value: undefined, configurable: true });
const legacy: LegacyData = {
files: [{ gpx: '<gpx/>', name: 'a' }],
settings: { distanceUnits: '"imperial"', routing: 'false' },
};
type Options = {
stored?: Record<string, string>;
storedFiles?: number;
loads?: boolean;
writes?: boolean;
};
/** A host and a backend that note what is done, in order. */
function setup({ stored = {}, storedFiles = 0, loads = true, writes = true }: Options = {}) {
const calls: string[] = [];
const settings: Record<string, string> = { ...stored };
let files = storedFiles;
const host = {
backend: {
importsLegacyData: true,
open_storage: async () => ({ settings: { ...stored }, outcome: {} }),
flush_storage: async () => {
calls.push('flush');
return writes;
},
set_setting: async (key: string, value: string) => {
calls.push(key);
settings[key] = value;
},
},
visibility: new Map(),
// like the engine, what was stored is only known once this is done
sync: async () => {
await Promise.resolve();
calls.push('sync');
},
outdated() {},
hasFiles: () => calls.includes('sync') && files > 0,
loadFiles: async () => {
calls.push('load');
if (loads) {
files += 1;
}
return loads;
},
deselect: async () => true,
} as unknown as StorageHost;
return { host, calls, settings };
}
describe('importing the former database', () => {
it('counts as done once the files are written', async () => {
const { host, calls, settings } = setup();
const opened = await openStoredFiles(host, async () => legacy);
assert.deepEqual(calls, [
'sync',
'distanceUnits',
'routing',
'load',
'flush',
LEGACY_IMPORTED_KEY,
]);
assert.equal(settings[LEGACY_IMPORTED_KEY], 'true');
assert.equal(opened.distanceUnits, '"imperial"');
});
it('is tried again when the files cannot be loaded', async () => {
const { host, settings } = setup({ loads: false });
const opened = await openStoredFiles(host, async () => legacy);
assert.equal(settings[LEGACY_IMPORTED_KEY], undefined);
assert.equal(opened.distanceUnits, '"imperial"');
});
it('is tried again when the files cannot be written', async () => {
const { host, settings } = setup({ writes: false });
await openStoredFiles(host, async () => legacy);
assert.equal(settings[LEGACY_IMPORTED_KEY], undefined);
});
it('is tried again when the database cannot be read', async () => {
const { host, settings } = setup();
await openStoredFiles(host, async () => {
throw new Error('cannot read');
});
assert.equal(settings[LEGACY_IMPORTED_KEY], undefined);
});
it('keeps the settings that were changed since a previous try', async () => {
const { host, settings } = setup({ stored: { routing: 'true' } });
const opened = await openStoredFiles(host, async () => legacy);
assert.equal(settings.routing, 'true');
assert.equal(opened.routing, 'true');
assert.equal(settings.distanceUnits, '"imperial"');
});
it('does not load the files twice: the stored ones mean it was done', async () => {
const { host, calls, settings } = setup({ storedFiles: 3 });
await openStoredFiles(host, async () => legacy);
assert.ok(!calls.includes('load'));
assert.equal(settings[LEGACY_IMPORTED_KEY], 'true');
});
it('is done when there is nothing to import, and only once', async () => {
const nothing = setup();
await openStoredFiles(nothing.host, async () => undefined);
assert.equal(nothing.settings[LEGACY_IMPORTED_KEY], 'true');
const done = setup({ stored: { [LEGACY_IMPORTED_KEY]: 'true' } });
await openStoredFiles(done.host, async () => legacy);
assert.ok(!done.calls.includes('load'));
});
});
+57 -33
View File
@@ -1,4 +1,4 @@
import { LEGACY_IMPORTED_KEY, readLegacyData } from '$lib/storage/legacy-import'; import { LEGACY_IMPORTED_KEY, readLegacyData, type LegacyData } from '$lib/storage/legacy-import';
import { parseVisibility, VISIBILITY_KEY, type Visibility } from '$lib/engine/file-visibility'; import { parseVisibility, VISIBILITY_KEY, type Visibility } from '$lib/engine/file-visibility';
import type { Outcome, StorageOpened } from 'gpx-wasm'; import type { Outcome, StorageOpened } from 'gpx-wasm';
import type { Backend } from './backend'; import type { Backend } from './backend';
@@ -15,7 +15,7 @@ export type StorageHost = {
/** The explicit visibility per file, to fill with the one that was saved. */ /** The explicit visibility per file, to fill with the one that was saved. */
visibility: Map<string, Visibility>; visibility: Map<string, Visibility>;
/** Applies what a call changed to what the facade keeps of the files. */ /** Applies what a call changed to what the facade keeps of the files. */
sync(update: Outcome): void; sync(update: Outcome): Promise<void>;
/** Tells that what is stored comes from a newer version of the app: nothing can be saved. */ /** Tells that what is stored comes from a newer version of the app: nothing can be saved. */
outdated(): void; outdated(): void;
hasFiles(): boolean; hasFiles(): boolean;
@@ -66,7 +66,10 @@ function followOtherTabs(host: StorageHost): {
* *
* The first time, what the previous versions of the app kept in the browser is imported. * The first time, what the previous versions of the app kept in the browser is imported.
*/ */
export async function openStoredFiles(host: StorageHost): Promise<Record<string, string>> { export async function openStoredFiles(
host: StorageHost,
readLegacy: () => Promise<LegacyData | undefined> = readLegacyData
): Promise<Record<string, string>> {
const { backend } = host; const { backend } = host;
let opened: StorageOpened; let opened: StorageOpened;
const tabs = followOtherTabs(host); const tabs = followOtherTabs(host);
@@ -81,47 +84,68 @@ export async function openStoredFiles(host: StorageHost): Promise<Record<string,
parseVisibility(settings[VISIBILITY_KEY]).forEach((visibility, fileId) => parseVisibility(settings[VISIBILITY_KEY]).forEach((visibility, fileId) =>
host.visibility.set(fileId, visibility) host.visibility.set(fileId, visibility)
); );
host.sync(opened.outcome); await host.sync(opened.outcome);
if ( if (backend.importsLegacyData && settings[LEGACY_IMPORTED_KEY] === undefined) {
backend.importsLegacyData && settings = { ...settings, ...(await importLegacyData(host, settings, readLegacy)) };
settings[LEGACY_IMPORTED_KEY] === undefined &&
!host.hasFiles()
) {
settings = { ...settings, ...(await importLegacyData(host)) };
await backend.flush_storage();
} }
return settings; return settings;
} }
/** Puts the files and the settings of the former database in the engine. */ /**
async function importLegacyData(host: StorageHost): Promise<Record<string, string>> { * Puts the files and the settings of the former database in the engine. It only counts as done once
* the files are written: until then, it is tried again the next time. The files that are stored
* mean that it was done, by a visit that did not get to say so.
*/
async function importLegacyData(
host: StorageHost,
stored: Record<string, string>,
readLegacy: () => Promise<LegacyData | undefined>
): Promise<Record<string, string>> {
const { backend } = host; const { backend } = host;
const imported: Record<string, string> = {}; const imported: Record<string, string> = {};
try { if (!host.hasFiles()) {
const legacy = await readLegacyData(); try {
if (legacy) { const legacy = await readLegacy();
const encoder = new TextEncoder(); if (legacy) {
if (legacy.files.length > 0) { // what was changed since a previous try is more recent
await host.loadFiles( for (const [key, value] of Object.entries(legacy.settings)) {
legacy.files.map((file) => ({ if (stored[key] === undefined) {
data: encoder.encode(file.gpx), await backend.set_setting(key, value);
name: file.name, imported[key] = value;
})) }
); }
await host.deselect(); if (legacy.files.length > 0 && !(await importFiles(host, legacy))) {
} return imported;
for (const [key, value] of Object.entries(legacy.settings)) { }
await backend.set_setting(key, value);
imported[key] = value;
} }
} catch (error) {
console.error('The files of the previous version could not be imported', error);
return imported;
} }
} catch (error) {
// it is tried again next time
console.error('The files of the previous version could not be imported', error);
return imported;
} }
await backend.set_setting(LEGACY_IMPORTED_KEY, 'true'); await backend.set_setting(LEGACY_IMPORTED_KEY, 'true');
imported[LEGACY_IMPORTED_KEY] = 'true'; imported[LEGACY_IMPORTED_KEY] = 'true';
return imported; return imported;
} }
/** Loads the files of the former database, and tells whether they are written. */
async function importFiles(host: StorageHost, legacy: LegacyData): Promise<boolean> {
const encoder = new TextEncoder();
const loaded = await host.loadFiles(
legacy.files.map((file) => ({
data: encoder.encode(file.gpx),
name: file.name,
}))
);
if (!loaded) {
console.error('The files of the previous version could not be loaded');
return false;
}
await host.deselect();
if (!(await host.backend.flush_storage())) {
console.error('The files of the previous version could not be written');
return false;
}
return true;
}
@@ -3,52 +3,30 @@ import assert from 'node:assert/strict';
import { import {
categoryIntervals, categoryIntervals,
categoryValue, categoryValue,
categoryValues,
intervalAt, intervalAt,
type CategoryIntervals, type CategoryIntervals,
} from '$lib/engine/trackpoint-categories'; } from '$lib/engine/trackpoint-categories';
describe('categoryValues', () => { /** The value of each of the `length` trackpoints, to compare with what is expected. */
// asphalt, asphalt, unknown, gravel, gravel, gravel, unknown function categoryValues(
const intervals: CategoryIntervals = { intervals: CategoryIntervals | undefined,
starts: new Uint32Array([0, 2, 3, 6]), length: number
values: new Uint8Array([1, 0, 2, 0]), ): (string | undefined)[] {
names: ['asphalt', 'gravel'], const result: (string | undefined)[] = new Array(length).fill(undefined);
}; if (!intervals) {
return result;
it('is the name of the value of the interval of each trackpoint', () => { }
assert.deepEqual(categoryValues(intervals, 7), [ const { starts, values, names } = intervals;
'asphalt', for (let i = 0; i < starts.length; i++) {
'asphalt', if (!values[i]) {
undefined, continue; // unknown, already `undefined`
'gravel', }
'gravel', const name = names[values[i] - 1];
'gravel', const end = i + 1 < starts.length ? Math.min(starts[i + 1], length) : length;
undefined, result.fill(name, starts[i], end);
]); }
}); return result;
}
it('goes on with the last interval until the last trackpoint', () => {
const last = { ...intervals, values: new Uint8Array([1, 0, 2, 2]) };
assert.deepEqual(categoryValues(last, 9).slice(5), [
'gravel',
'gravel',
'gravel',
'gravel',
]);
});
it('does not go past the trackpoints', () => {
assert.deepEqual(categoryValues(intervals, 3), ['asphalt', 'asphalt', undefined]);
assert.deepEqual(categoryValues(intervals, 0), []);
});
it('is undefined without intervals, or for a code without name', () => {
assert.deepEqual(categoryValues(undefined, 2), [undefined, undefined]);
const unnamed = { starts: new Uint32Array([0]), values: new Uint8Array([3]), names: [] };
assert.deepEqual(categoryValues(unnamed, 2), [undefined, undefined]);
});
});
describe('categoryIntervals', () => { describe('categoryIntervals', () => {
it('groups the trackpoints that share a value', () => { it('groups the trackpoints that share a value', () => {
@@ -11,31 +11,6 @@ export type CategoryIntervals = {
names: string[]; names: string[];
}; };
/**
* The value of each of the `length` trackpoints for a category of the engine, `undefined` when it
* is unknown, or when the category is missing (it is unknown for every trackpoint). The intervals
* are scanned once, so it takes a time linear in `length`.
*/
export function categoryValues(
intervals: CategoryIntervals | undefined,
length: number
): (string | undefined)[] {
const result: (string | undefined)[] = new Array(length).fill(undefined);
if (!intervals) {
return result;
}
const { starts, values, names } = intervals;
for (let i = 0; i < starts.length; i++) {
if (!values[i]) {
continue; // unknown, already `undefined`
}
const name = names[values[i] - 1];
const end = i + 1 < starts.length ? Math.min(starts[i + 1], length) : length;
result.fill(name, starts[i], end);
}
return result;
}
/** The index of the interval of `starts` (sorted) that the trackpoint `index` is in, -1 if none. */ /** The index of the interval of `starts` (sorted) that the trackpoint `index` is in, -1 if none. */
export function intervalAt(starts: Uint32Array, index: number): number { export function intervalAt(starts: Uint32Array, index: number): number {
let low = 0; let low = 0;
@@ -66,7 +41,7 @@ export function categoryValue(
/** /**
* The intervals of the trackpoints that share a value, for a value per trackpoint (`undefined` * The intervals of the trackpoints that share a value, for a value per trackpoint (`undefined`
* when unknown): the inverse of `categoryValues`. The names are in the order of their first * when unknown). The names are in the order of their first
* appearance. A value that is unknown everywhere gives no intervals. * appearance. A value that is unknown everywhere gives no intervals.
*/ */
export function categoryIntervals(values: readonly (string | undefined)[]): CategoryIntervals { export function categoryIntervals(values: readonly (string | undefined)[]): CategoryIntervals {
+13 -2
View File
@@ -9,7 +9,6 @@ export type { FileStatus, FolderEntry, UnsavedChoice, WorkspaceState } from './b
export type { export type {
Clipboard, Clipboard,
WaypointDetails, WaypointDetails,
TrackpointDetails,
MoveTarget, MoveTarget,
GlobalStatistics, GlobalStatistics,
SelectionPoint, SelectionPoint,
@@ -155,7 +154,9 @@ export type StatisticsMetric =
/** `highway`, `sacScale` and `mtbScale`. */ /** `highway`, `sacScale` and `mtbScale`. */
| 'highway' | 'highway'
/** `anchors`. */ /** `anchors`. */
| 'anchors'; | 'anchors'
/** `segments`. */
| 'segments';
/** /**
* The anchors of the routing tool among the trackpoints of the selection: the points that can be * The anchors of the routing tool among the trackpoints of the selection: the points that can be
@@ -182,6 +183,15 @@ export type SelectionAnchors = {
revision: number; revision: number;
}; };
/**
* The segments of the selection that have trackpoints: segment `i` has the id `ids[i]`, and its
* trackpoints go from `starts[i]` until the next start (or the last trackpoint).
*/
export type SelectionSegments = {
starts: Uint32Array;
ids: string[];
};
/** /**
* The smoothed elevation segments of the selection, over which the slope is averaged, as the * The smoothed elevation segments of the selection, over which the slope is averaged, as the
* intervals of trackpoints that belong to the same one: segment `i` starts at the trackpoint * intervals of trackpoints that belong to the same one: segment `i` starts at the trackpoint
@@ -229,6 +239,7 @@ export type SelectionStatistics = {
/** Mountain biking scale. */ /** Mountain biking scale. */
mtbScale?: CategoryIntervals; mtbScale?: CategoryIntervals;
anchors?: SelectionAnchors; anchors?: SelectionAnchors;
segments?: SelectionSegments;
/** /**
* Global statistics of the trackpoints from `start` to `end` (both included), for example * Global statistics of the trackpoints from `start` to `end` (both included), for example
+17 -27
View File
@@ -1,32 +1,6 @@
import { describe, it } from 'node:test'; import { describe, it } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { closestPointIndex, closestPointIndexIn } from '$lib/geo/closest-point'; import { closestPointIndexIn } from '$lib/geo/closest-point';
describe('closestPointIndex', () => {
const line = [0, 0, 1, 0, 2, 0, 2, 1];
it('has no point on an empty polyline', () => {
assert.equal(closestPointIndex([], { lng: 0, lat: 0 }), undefined);
});
it('is the only point of a polyline of one point', () => {
assert.equal(closestPointIndex([3, 4], { lng: 0, lat: 0 }), 0);
});
it('is the nearest end of the nearest segment', () => {
assert.equal(closestPointIndex(line, { lng: 0.2, lat: 0.1 }), 0);
assert.equal(closestPointIndex(line, { lng: 0.8, lat: 0.1 }), 1);
assert.equal(closestPointIndex(line, { lng: 1.6, lat: -0.1 }), 2);
assert.equal(closestPointIndex(line, { lng: 2.1, lat: 0.9 }), 3);
});
it('uses the distance to the segment, not to its ends', () => {
// far from every point but right next to the long segment
const coordinates = [0, 0, 10, 0, 10, 5];
assert.equal(closestPointIndex(coordinates, { lng: 7, lat: 0.01 }), 1);
assert.equal(closestPointIndex(coordinates, { lng: 3, lat: 0.01 }), 0);
});
});
describe('closestPointIndexIn', () => { describe('closestPointIndexIn', () => {
const lng = [0, 1, 2, 2, 5, 6]; const lng = [0, 1, 2, 2, 5, 6];
@@ -39,6 +13,22 @@ describe('closestPointIndexIn', () => {
assert.equal(closestPointIndexIn(lng, lat, 4, 6, { lng: 5.9, lat: 5.1 }), 5); assert.equal(closestPointIndexIn(lng, lat, 4, 6, { lng: 5.9, lat: 5.1 }), 5);
}); });
it('is the nearest end of the nearest segment', () => {
assert.equal(closestPointIndexIn(lng, lat, 0, 4, { lng: 0.2, lat: 0.1 }), 0);
assert.equal(closestPointIndexIn(lng, lat, 0, 4, { lng: 1.6, lat: -0.1 }), 2);
assert.equal(closestPointIndexIn(lng, lat, 0, 4, { lng: 2.1, lat: 0.9 }), 3);
});
it('uses the distance to the segment, not to its ends', () => {
// far from every point but right next to the long segment
const [x, y] = [
[0, 10, 10],
[0, 0, 5],
];
assert.equal(closestPointIndexIn(x, y, 0, 3, { lng: 7, lat: 0.01 }), 1);
assert.equal(closestPointIndexIn(x, y, 0, 3, { lng: 3, lat: 0.01 }), 0);
});
it('has no point in an empty range', () => { it('has no point in an empty range', () => {
assert.equal(closestPointIndexIn(lng, lat, 3, 3, { lng: 0, lat: 0 }), undefined); assert.equal(closestPointIndexIn(lng, lat, 3, 3, { lng: 0, lat: 0 }), undefined);
assert.equal(closestPointIndexIn(lng, lat, 4, 2, { lng: 0, lat: 0 }), undefined); assert.equal(closestPointIndexIn(lng, lat, 4, 2, { lng: 0, lat: 0 }), undefined);
+4 -19
View File
@@ -1,25 +1,10 @@
const metersPerDegree = 111320; const metersPerDegree = 111320;
/** /**
* Index of the point of a polyline (flat `[lng, lat, ...]` coordinates) that is the closest to * Index of the point, among the points `from` (included) to `to` (excluded) of a polyline given as
* `point`, among the two ends of the segment of the polyline that passes the closest to it. * the arrays of its longitudes and latitudes, that is the closest to `point`: one of the two ends
* `undefined` for an empty polyline. Planar approximation, like the one used to simplify tracks. * of the segment of the polyline that passes the closest to it. The index is the one in the arrays,
*/ * `undefined` for an empty range. Planar approximation, like the one used to simplify tracks.
export function closestPointIndex(
coordinates: ArrayLike<number>,
point: { lng: number; lat: number }
): number | undefined {
return closestIndex(
Math.floor(coordinates.length / 2),
(i) => coordinates[2 * i],
(i) => coordinates[2 * i + 1],
point
);
}
/**
* Like `closestPointIndex`, for the points `from` (included) to `to` (excluded) of a polyline
* given as the arrays of its longitudes and latitudes. The index is the one in the arrays.
*/ */
export function closestPointIndexIn( export function closestPointIndexIn(
lng: ArrayLike<number>, lng: ArrayLike<number>,
@@ -18,3 +18,9 @@ engine.statistics.subscribe(() => slicedStatistics.set(undefined));
/** The point of the elevation profile that the pointer is over, shown on the map. */ /** The point of the elevation profile that the pointer is over, shown on the map. */
export const hoveredPoint: Writable<Coordinates | null> = writable(null); export const hoveredPoint: Writable<Coordinates | null> = writable(null);
/** The trackpoint of the selection that the pointer is over on the map, shown on the profile. */
export const hoveredTrackpoint: Writable<number | null> = writable(null);
// its index only makes sense for the selection it was found in
engine.statistics.subscribe(() => hoveredTrackpoint.set(null));
+7 -5
View File
@@ -141,11 +141,13 @@ describe('layer trees', () => {
assert.deepEqual(filterLayerTree({}, allowed), allowed); assert.deepEqual(filterLayerTree({}, allowed), allowed);
}); });
it('drops the layers that are not allowed anymore, but not the custom ones', () => { it('drops the layers that are not allowed anymore and those of extensions, not the custom ones', () => {
const filtered = filterLayerTree( const filtered = filterLayerTree(
{ {
world: { a: true, gone: true, 'custom-1': true, 'extension-x': false }, world: { a: true, gone: true, 'custom-1': true, 'extension-x': true },
old: { x: true }, old: { x: true },
extension: { 'extension-Name/y': true },
custom: { 'custom-2': false },
}, },
allowed allowed
); );
@@ -153,10 +155,10 @@ describe('layer trees', () => {
a: true, a: true,
b: false, b: false,
'custom-1': true, 'custom-1': true,
'extension-x': false,
}); });
assert.equal('old' in filtered, true); assert.equal('old' in filtered, false);
assert.deepEqual(filtered.old, {}); assert.equal('extension' in filtered, false);
assert.deepEqual(filtered.custom, { 'custom-2': false });
}); });
it('does not take a branch for a layer, or a layer for a branch', () => { it('does not take a branch for a layer, or a layer for a branch', () => {
+8 -3
View File
@@ -196,7 +196,9 @@ function isRecord(value: unknown): value is Record<string, unknown> {
/** /**
* The saved tree `t`, with the layers of `allowed`: the choices saved for them are kept, the new * The saved tree `t`, with the layers of `allowed`: the choices saved for them are kept, the new
* ones have their default, and of the others only the custom and extension layers are kept. * ones have their default, and of the others only the custom layers are kept. The layers of
* extensions are saved with the others, but not read back: they come again when an extension sets
* them, if it is still there.
*/ */
export function filterLayerTree(t: unknown, allowed: LayerTreeType | undefined): LayerTreeType { export function filterLayerTree(t: unknown, allowed: LayerTreeType | undefined): LayerTreeType {
const tree = isRecord(t) ? t : {}; const tree = isRecord(t) ? t : {};
@@ -214,11 +216,14 @@ export function filterLayerTree(t: unknown, allowed: LayerTreeType | undefined):
Object.entries(tree).forEach(([key, value]) => { Object.entries(tree).forEach(([key, value]) => {
if (!Object.hasOwn(filtered, key)) { if (!Object.hasOwn(filtered, key)) {
if (typeof value === 'boolean') { if (typeof value === 'boolean') {
if (key.startsWith('custom-') || key.startsWith('extension-')) { if (key.startsWith('custom-')) {
filtered[key] = value; filtered[key] = value;
} }
} else if (isRecord(value)) { } else if (isRecord(value)) {
filtered[key] = filterLayerTree(value, undefined); const group = filterLayerTree(value, undefined);
if (Object.keys(group).length > 0) {
filtered[key] = group;
}
} }
} }
}); });
@@ -95,6 +95,21 @@ describe('legacyFileToGPX', () => {
assert.ok(gpx.includes('href="https://example.com/?a=&quot;1&quot;&amp;b=2"')); assert.ok(gpx.includes('href="https://example.com/?a=&quot;1&quot;&amp;b=2"'));
}); });
it('writes all the links of an element', () => {
const links = [
{ attributes: { href: 'https://a.example/' }, text: 'first' },
{ attributes: { href: 'https://b.example/' } },
];
const gpx = legacyFileToGPX({
metadata: { name: 'x', link: links },
trk: [{ link: links, trkseg: [] }],
});
const written =
'<link href="https://a.example/"><text>first</text></link><link href="https://b.example/"></link>';
assert.ok(gpx.includes(`<name>x</name>${written}</metadata>`));
assert.ok(gpx.includes(`<trk>${written}</trk>`));
});
it('leaves out what is missing or empty', () => { it('leaves out what is missing or empty', () => {
const gpx = legacyFileToGPX({ const gpx = legacyFileToGPX({
metadata: { name: '', desc: null, time: 'not a date' }, metadata: { name: '', desc: null, time: 'not a date' },
+4
View File
@@ -84,7 +84,11 @@ function timeElement(value: unknown): string {
return isNaN(date.getTime()) ? '' : element('time', date.toISOString()); return isNaN(date.getTime()) ? '' : element('time', date.toISOString());
} }
/** The link of an element, or the links: the former parser gave a list when there were several. */
function link(value: Json): string { function link(value: Json): string {
if (Array.isArray(value)) {
return value.map(link).join('');
}
const href = value?.attributes?.href; const href = value?.attributes?.href;
return href return href
? `<link href="${escape(href).replaceAll('"', '&quot;')}">${element('text', value.text)}</link>` ? `<link href="${escape(href).replaceAll('"', '&quot;')}">${element('text', value.text)}</link>`
+7
View File
@@ -598,5 +598,12 @@
"title": "This page is out of date", "title": "This page is out of date",
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.", "description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
"reload": "Reload" "reload": "Reload"
},
"desktop": {
"update_available": "gpx.studio {version} is available",
"update_restart": "Update and restart",
"update_download": "Download",
"update_installing": "Installing the update...",
"update_failed": "The update could not be installed"
} }
} }
+7
View File
@@ -598,5 +598,12 @@
"title": "This page is out of date", "title": "This page is out of date",
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.", "description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
"reload": "Reload" "reload": "Reload"
},
"desktop": {
"update_available": "gpx.studio {version} is available",
"update_restart": "Update and restart",
"update_download": "Download",
"update_installing": "Installing the update...",
"update_failed": "The update could not be installed"
} }
} }
+7
View File
@@ -598,5 +598,12 @@
"title": "This page is out of date", "title": "This page is out of date",
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.", "description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
"reload": "Reload" "reload": "Reload"
},
"desktop": {
"update_available": "gpx.studio {version} is available",
"update_restart": "Update and restart",
"update_download": "Download",
"update_installing": "Installing the update...",
"update_failed": "The update could not be installed"
} }
} }
+7
View File
@@ -598,5 +598,12 @@
"title": "This page is out of date", "title": "This page is out of date",
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.", "description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
"reload": "Reload" "reload": "Reload"
},
"desktop": {
"update_available": "gpx.studio {version} is available",
"update_restart": "Update and restart",
"update_download": "Download",
"update_installing": "Installing the update...",
"update_failed": "The update could not be installed"
} }
} }
+7
View File
@@ -598,5 +598,12 @@
"title": "This page is out of date", "title": "This page is out of date",
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.", "description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
"reload": "Reload" "reload": "Reload"
},
"desktop": {
"update_available": "gpx.studio {version} is available",
"update_restart": "Update and restart",
"update_download": "Download",
"update_installing": "Installing the update...",
"update_failed": "The update could not be installed"
} }
} }
+7
View File
@@ -598,5 +598,12 @@
"title": "This page is out of date", "title": "This page is out of date",
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.", "description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
"reload": "Reload" "reload": "Reload"
},
"desktop": {
"update_available": "gpx.studio {version} is available",
"update_restart": "Update and restart",
"update_download": "Download",
"update_installing": "Installing the update...",
"update_failed": "The update could not be installed"
} }
} }
+7
View File
@@ -598,5 +598,12 @@
"title": "This page is out of date", "title": "This page is out of date",
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.", "description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
"reload": "Reload" "reload": "Reload"
},
"desktop": {
"update_available": "gpx.studio {version} is available",
"update_restart": "Update and restart",
"update_download": "Download",
"update_installing": "Installing the update...",
"update_failed": "The update could not be installed"
} }
} }
+44 -37
View File
@@ -9,42 +9,42 @@
}, },
"menu": { "menu": {
"new_file": "Nuevo archivo", "new_file": "Nuevo archivo",
"new_route_file": "New route file", "new_route_file": "Nuevo archivo de ruta",
"new_track": "Nuevo recorrido", "new_track": "Nuevo recorrido",
"new_route": "New route", "new_route": "Nueva ruta",
"convert_to_route": "Convert to route", "convert_to_route": "Convertir a ruta",
"convert_to_track": "Convert to track", "convert_to_track": "Convertir a recorrido",
"convert_to_routes": "Convert to route file", "convert_to_routes": "Convertir a archivo de ruta",
"convert_to_tracks": "Convert to track file", "convert_to_tracks": "Convertir a archivo de recorrido",
"new_segment": "Nuevo segmento", "new_segment": "Nuevo segmento",
"open": "Abrir...", "open": "Abrir...",
"open_folder": "Open folder...", "open_folder": "Abrir carpeta...",
"explorer": "Explorer", "explorer": "Explorador",
"explorer_menu": { "explorer_menu": {
"open": "Open", "open": "Abrir",
"new_file": "New file...", "new_file": "Nuevo archivo...",
"new_route_file": "New route file...", "new_route_file": "Nuevo archivo de ruta...",
"new_folder": "New folder...", "new_folder": "Nueva carpeta...",
"rename": "Rename...", "rename": "Renombrar...",
"reveal_finder": "Reveal in Finder", "reveal_finder": "Mostrar en el buscador",
"reveal": "Reveal in file explorer", "reveal": "Mostrar en explorador de archivos",
"delete_failed": "The files could not be moved to the trash." "delete_failed": "No se pudieron mover los archivos a la papelera."
}, },
"workspace": "Workspace", "workspace": "Espacio de trabajo",
"save": "Save", "save": "Guardar",
"save_all": "Save all", "save_all": "Guardar todo",
"save_as": "Save as...", "save_as": "Guardar como…",
"close": "Cerrar", "close": "Cerrar",
"close_all": "Cerrar todo", "close_all": "Cerrar todo",
"close_folder": "Close folder", "close_folder": "Cerrar carpeta",
"reload": "Reload from disk", "reload": "Recargar desde el disco",
"discard": "Discard", "discard": "Descartar",
"cancel": "Cancel", "cancel": "Cancelar",
"overwrite": "Overwrite", "overwrite": "Sobreescribir",
"unsaved_title": "Unsaved changes", "unsaved_title": "Cambios sin guardar",
"unsaved_description": "These files have changes that are not saved:", "unsaved_description": "Estos archivos tienen cambios que no están guardados:",
"overwrite_title": "Overwrite changes made elsewhere?", "overwrite_title": "¿Sobrescribir cambios realizados en otro lugar?",
"overwrite_description": "These files have changes, and were also changed on the disk. Overwrite what is on the disk, or reload it and lose your changes?", "overwrite_description": "Estos ficheros tienen cambios, y también fueron modificados en el disco. ¿Sobrescribir lo que hay en el disco, o volver a cargarlo y perder los cambios?",
"duplicate": "Duplicar track", "duplicate": "Duplicar track",
"copy": "Copiar", "copy": "Copiar",
"paste": "Pegar", "paste": "Pegar",
@@ -102,9 +102,9 @@
"name": "Nombre", "name": "Nombre",
"description": "Descripción", "description": "Descripción",
"save": "Guardar", "save": "Guardar",
"rename_file": "Rename the file on disk", "rename_file": "Renombrar el archivo en disco",
"name_taken": "A file with this name already exists in the folder.", "name_taken": "Ya existe un archivo con este nombre en la carpeta.",
"rename_failed": "The file could not be renamed." "rename_failed": "No se pudo renombrar el archivo."
}, },
"style": { "style": {
"button": "Apariencia...", "button": "Apariencia...",
@@ -468,8 +468,8 @@
"gpx": { "gpx": {
"file": "Archivo", "file": "Archivo",
"files": "Archivos", "files": "Archivos",
"route": "Route", "route": "Ruta",
"routes": "Routes", "routes": "Rutas",
"track": "Track", "track": "Track",
"tracks": "Tracks", "tracks": "Tracks",
"segment": "Segmento", "segment": "Segmento",
@@ -595,8 +595,15 @@
"enable_webgl2": "Aprenda cómo activar WebGL 2 en su navegador", "enable_webgl2": "Aprenda cómo activar WebGL 2 en su navegador",
"page_not_found": "página no encontrada", "page_not_found": "página no encontrada",
"outdated": { "outdated": {
"title": "This page is out of date", "title": "Esta página está desactualizada",
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.", "description": "Tus archivos fueron guardados por una versión más reciente de gpx.studio, que esta página no puede leer. Recarga la página para obtener la última versión.",
"reload": "Reload" "reload": "Recargar"
},
"desktop": {
"update_available": "gpx.studio {version} está disponible",
"update_restart": "Actualizar y reiniciar",
"update_download": "Descargar",
"update_installing": "Instalando la actualización...",
"update_failed": "No se pudo instalar la actualización"
} }
} }
+44 -37
View File
@@ -9,42 +9,42 @@
}, },
"menu": { "menu": {
"new_file": "Fitxategi berria", "new_file": "Fitxategi berria",
"new_route_file": "New route file", "new_route_file": "Ibilbide fitxategi berria",
"new_track": "Arrasto berria", "new_track": "Arrasto berria",
"new_route": "New route", "new_route": "Ibilbide berria",
"convert_to_route": "Convert to route", "convert_to_route": "Bihurtu ibilbide",
"convert_to_track": "Convert to track", "convert_to_track": "Bihurtu arrasto",
"convert_to_routes": "Convert to route file", "convert_to_routes": "Bihurtu ibilbide fitxategi",
"convert_to_tracks": "Convert to track file", "convert_to_tracks": "Bihurtu arrasto fitxategi",
"new_segment": "Segmentu berria", "new_segment": "Segmentu berria",
"open": "Ireki...", "open": "Ireki...",
"open_folder": "Open folder...", "open_folder": "Ireki karpeta...",
"explorer": "Explorer", "explorer": "Arakatzailea",
"explorer_menu": { "explorer_menu": {
"open": "Open", "open": "Ireki",
"new_file": "New file...", "new_file": "Fitxategi berria...",
"new_route_file": "New route file...", "new_route_file": "Ibilbide fitxategi berria...",
"new_folder": "New folder...", "new_folder": "Karpeta berria...",
"rename": "Rename...", "rename": "Aldatu izena...",
"reveal_finder": "Reveal in Finder", "reveal_finder": "Erakutsi Finder-en",
"reveal": "Reveal in file explorer", "reveal": "Erakutsi fitxategi-arakatzailean",
"delete_failed": "The files could not be moved to the trash." "delete_failed": "Fitxategiak ezin dira zakarrontzira mugitu."
}, },
"workspace": "Workspace", "workspace": "Laneko area",
"save": "Save", "save": "Gorde",
"save_all": "Save all", "save_all": "Gorde denak",
"save_as": "Save as...", "save_as": "Gorde honela...",
"close": "Itxi", "close": "Itxi",
"close_all": "Itxi denak", "close_all": "Itxi denak",
"close_folder": "Close folder", "close_folder": "Itxi karpeta",
"reload": "Reload from disk", "reload": "Birkargatu diskotik",
"discard": "Discard", "discard": "Baztertu",
"cancel": "Cancel", "cancel": "Utzi",
"overwrite": "Overwrite", "overwrite": "Gainidatzi",
"unsaved_title": "Unsaved changes", "unsaved_title": "Gorde gabeko aldaketak",
"unsaved_description": "These files have changes that are not saved:", "unsaved_description": "Fitxategi hauek gorde gabeko aldaketak dituzte:",
"overwrite_title": "Overwrite changes made elsewhere?", "overwrite_title": "Egindako aldaketak gainidatzi nahi dituzu?",
"overwrite_description": "These files have changes, and were also changed on the disk. Overwrite what is on the disk, or reload it and lose your changes?", "overwrite_description": "Fitxategi hauek aldaketak dituzte, eta diskoan ere aldatu dira. Gainidatzi diskoan dagoena edo berriro kargatu eta zure aldaketak galdu?",
"duplicate": "Kopia egin", "duplicate": "Kopia egin",
"copy": "Kopiatu", "copy": "Kopiatu",
"paste": "Itsatsi", "paste": "Itsatsi",
@@ -102,9 +102,9 @@
"name": "Izena", "name": "Izena",
"description": "Deskribapena", "description": "Deskribapena",
"save": "Gorde", "save": "Gorde",
"rename_file": "Rename the file on disk", "rename_file": "Berrizendatu diskoko fitxategia",
"name_taken": "A file with this name already exists in the folder.", "name_taken": "Izen hau duen fitxategi bat dagoeneko badago karpetan.",
"rename_failed": "The file could not be renamed." "rename_failed": "Fitxategia ezin da berrizendatu."
}, },
"style": { "style": {
"button": "Itxura...", "button": "Itxura...",
@@ -468,8 +468,8 @@
"gpx": { "gpx": {
"file": "Fitxategia", "file": "Fitxategia",
"files": "Fitxategiak", "files": "Fitxategiak",
"route": "Route", "route": "Arrastoa",
"routes": "Routes", "routes": "Ibilbideak",
"track": "Arrastoa", "track": "Arrastoa",
"tracks": "Arrastoak", "tracks": "Arrastoak",
"segment": "Sementua", "segment": "Sementua",
@@ -595,8 +595,15 @@
"enable_webgl2": "Ikasi zure nabigatzailean WebGL 2 aktibatzen", "enable_webgl2": "Ikasi zure nabigatzailean WebGL 2 aktibatzen",
"page_not_found": "orrialdea ez da aurkitu", "page_not_found": "orrialdea ez da aurkitu",
"outdated": { "outdated": {
"title": "This page is out of date", "title": "Orrialde hau zaharkituta dago",
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.", "description": "Zure fitxategiak gpx.studio-ren bertsio berriago batek gorde ditu, orrialde honek irakurri ezin duena. Birkargatu orria azken bertsioa lortzeko.",
"reload": "Reload" "reload": "Birkargatu"
},
"desktop": {
"update_available": "gpx.studio {version} eskuragarri dago",
"update_restart": "Eguneratu eta berrabiarazi",
"update_download": "Deskargatu",
"update_installing": "Eguneraketa instalatzen...",
"update_failed": "Eguneraketa ezin izan da instalatu"
} }
} }
+7
View File
@@ -598,5 +598,12 @@
"title": "This page is out of date", "title": "This page is out of date",
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.", "description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
"reload": "Reload" "reload": "Reload"
},
"desktop": {
"update_available": "gpx.studio {version} is available",
"update_restart": "Update and restart",
"update_download": "Download",
"update_installing": "Installing the update...",
"update_failed": "The update could not be installed"
} }
} }
+7
View File
@@ -598,5 +598,12 @@
"title": "Cette page n'est pas à jour", "title": "Cette page n'est pas à jour",
"description": "Vos fichiers ont été enregistrés par une nouvelle version de gpx.studio, que cette page ne peut pas lire. Rechargez la page pour obtenir la dernière version.", "description": "Vos fichiers ont été enregistrés par une nouvelle version de gpx.studio, que cette page ne peut pas lire. Rechargez la page pour obtenir la dernière version.",
"reload": "Recharger" "reload": "Recharger"
},
"desktop": {
"update_available": "gpx.studio {version} est disponible",
"update_restart": "Mettre à jour et redémarrer",
"update_download": "Télécharger",
"update_installing": "Installation de la mise à jour...",
"update_failed": "La mise à jour n'a pas pu être installée"
} }
} }
+7
View File
@@ -598,5 +598,12 @@
"title": "This page is out of date", "title": "This page is out of date",
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.", "description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
"reload": "Reload" "reload": "Reload"
},
"desktop": {
"update_available": "gpx.studio {version} is available",
"update_restart": "Update and restart",
"update_download": "Download",
"update_installing": "Installing the update...",
"update_failed": "The update could not be installed"
} }
} }
+7
View File
@@ -598,5 +598,12 @@
"title": "This page is out of date", "title": "This page is out of date",
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.", "description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
"reload": "Reload" "reload": "Reload"
},
"desktop": {
"update_available": "gpx.studio {version} is available",
"update_restart": "Update and restart",
"update_download": "Download",
"update_installing": "Installing the update...",
"update_failed": "The update could not be installed"
} }
} }
+7
View File
@@ -598,5 +598,12 @@
"title": "This page is out of date", "title": "This page is out of date",
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.", "description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
"reload": "Reload" "reload": "Reload"
},
"desktop": {
"update_available": "gpx.studio {version} is available",
"update_restart": "Update and restart",
"update_download": "Download",
"update_installing": "Installing the update...",
"update_failed": "The update could not be installed"
} }
} }
+7
View File
@@ -598,5 +598,12 @@
"title": "This page is out of date", "title": "This page is out of date",
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.", "description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
"reload": "Reload" "reload": "Reload"
},
"desktop": {
"update_available": "gpx.studio {version} is available",
"update_restart": "Update and restart",
"update_download": "Download",
"update_installing": "Installing the update...",
"update_failed": "The update could not be installed"
} }
} }
+7
View File
@@ -598,5 +598,12 @@
"title": "This page is out of date", "title": "This page is out of date",
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.", "description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
"reload": "Reload" "reload": "Reload"
},
"desktop": {
"update_available": "gpx.studio {version} is available",
"update_restart": "Update and restart",
"update_download": "Download",
"update_installing": "Installing the update...",
"update_failed": "The update could not be installed"
} }
} }
+11 -4
View File
@@ -9,7 +9,7 @@
}, },
"menu": { "menu": {
"new_file": "Naujas failas", "new_file": "Naujas failas",
"new_route_file": "New route file", "new_route_file": "Naujas maršruto failas",
"new_track": "Nauja trasa", "new_track": "Nauja trasa",
"new_route": "New route", "new_route": "New route",
"convert_to_route": "Convert to route", "convert_to_route": "Convert to route",
@@ -146,7 +146,7 @@
"racing_bike": "Kelių dviračiu", "racing_bike": "Kelių dviračiu",
"gravel_bike": "Bekelės dviračiu", "gravel_bike": "Bekelės dviračiu",
"mountain_bike": "Kalnų dviračiu", "mountain_bike": "Kalnų dviračiu",
"foot": "Bėgimas / ėjimas", "foot": "Bėgimas / žygiavimas",
"motorcycle": "Motociklu", "motorcycle": "Motociklu",
"water": "Vandeniu", "water": "Vandeniu",
"railway": "Geležinkeliu" "railway": "Geležinkeliu"
@@ -181,7 +181,7 @@
"unknown": "Unknown", "unknown": "Unknown",
"motorway": "Highway", "motorway": "Highway",
"motorway_link": "Highway link", "motorway_link": "Highway link",
"trunk": "Primary road", "trunk": "Pagrindinis kelias",
"trunk_link": "Primary road link", "trunk_link": "Primary road link",
"primary": "Primary road", "primary": "Primary road",
"primary_link": "Primary road link", "primary_link": "Primary road link",
@@ -552,7 +552,7 @@
}, },
"docs": { "docs": {
"translate": "Improve the translation on Crowdin", "translate": "Improve the translation on Crowdin",
"answer_not_found": "Did not find what you were looking for?", "answer_not_found": "Neradote, ko ieškojote?",
"ask_on_reddit": "Ask the community on Reddit", "ask_on_reddit": "Ask the community on Reddit",
"search": { "search": {
"search": "Search", "search": "Search",
@@ -598,5 +598,12 @@
"title": "This page is out of date", "title": "This page is out of date",
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.", "description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
"reload": "Reload" "reload": "Reload"
},
"desktop": {
"update_available": "gpx.studio {version} is available",
"update_restart": "Update and restart",
"update_download": "Download",
"update_installing": "Installing the update...",
"update_failed": "Nepavyko įdiegti atnaujinimo"
} }
} }
+7
View File
@@ -598,5 +598,12 @@
"title": "This page is out of date", "title": "This page is out of date",
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.", "description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
"reload": "Reload" "reload": "Reload"
},
"desktop": {
"update_available": "gpx.studio {version} is available",
"update_restart": "Update and restart",
"update_download": "Download",
"update_installing": "Installing the update...",
"update_failed": "The update could not be installed"
} }
} }
+7
View File
@@ -598,5 +598,12 @@
"title": "Deze pagina is verouderd", "title": "Deze pagina is verouderd",
"description": "Uw bestanden zijn opgeslagen door een nieuwere versie van gpx.studio, die deze pagina niet kan lezen. Herlaad de pagina om de nieuwste versie te krijgen.", "description": "Uw bestanden zijn opgeslagen door een nieuwere versie van gpx.studio, die deze pagina niet kan lezen. Herlaad de pagina om de nieuwste versie te krijgen.",
"reload": "Vernieuwen" "reload": "Vernieuwen"
},
"desktop": {
"update_available": "gpx.studio {version} is beschikbaar",
"update_restart": "Bijwerken en opnieuw opstarten",
"update_download": "Download",
"update_installing": "De update wordt geïnstalleerd...",
"update_failed": "De update kon niet worden geïnstalleerd"
} }
} }
+7
View File
@@ -598,5 +598,12 @@
"title": "This page is out of date", "title": "This page is out of date",
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.", "description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
"reload": "Reload" "reload": "Reload"
},
"desktop": {
"update_available": "gpx.studio {version} is available",
"update_restart": "Update and restart",
"update_download": "Download",
"update_installing": "Installing the update...",
"update_failed": "The update could not be installed"
} }
} }
+7
View File
@@ -598,5 +598,12 @@
"title": "This page is out of date", "title": "This page is out of date",
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.", "description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
"reload": "Reload" "reload": "Reload"
},
"desktop": {
"update_available": "gpx.studio {version} is available",
"update_restart": "Update and restart",
"update_download": "Download",
"update_installing": "Installing the update...",
"update_failed": "The update could not be installed"
} }
} }
+7
View File
@@ -598,5 +598,12 @@
"title": "This page is out of date", "title": "This page is out of date",
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.", "description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
"reload": "Reload" "reload": "Reload"
},
"desktop": {
"update_available": "gpx.studio {version} is available",
"update_restart": "Update and restart",
"update_download": "Download",
"update_installing": "Installing the update...",
"update_failed": "The update could not be installed"
} }
} }
+7
View File
@@ -598,5 +598,12 @@
"title": "This page is out of date", "title": "This page is out of date",
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.", "description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
"reload": "Reload" "reload": "Reload"
},
"desktop": {
"update_available": "gpx.studio {version} is available",
"update_restart": "Update and restart",
"update_download": "Download",
"update_installing": "Installing the update...",
"update_failed": "The update could not be installed"
} }
} }
+7
View File
@@ -598,5 +598,12 @@
"title": "This page is out of date", "title": "This page is out of date",
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.", "description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
"reload": "Reload" "reload": "Reload"
},
"desktop": {
"update_available": "gpx.studio {version} is available",
"update_restart": "Update and restart",
"update_download": "Download",
"update_installing": "Installing the update...",
"update_failed": "The update could not be installed"
} }
} }
+7
View File
@@ -598,5 +598,12 @@
"title": "This page is out of date", "title": "This page is out of date",
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.", "description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
"reload": "Reload" "reload": "Reload"
},
"desktop": {
"update_available": "gpx.studio {version} is available",
"update_restart": "Update and restart",
"update_download": "Download",
"update_installing": "Installing the update...",
"update_failed": "The update could not be installed"
} }
} }
+7
View File
@@ -598,5 +598,12 @@
"title": "This page is out of date", "title": "This page is out of date",
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.", "description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
"reload": "Reload" "reload": "Reload"
},
"desktop": {
"update_available": "gpx.studio {version} is available",
"update_restart": "Update and restart",
"update_download": "Download",
"update_installing": "Installing the update...",
"update_failed": "The update could not be installed"
} }
} }
+7
View File
@@ -598,5 +598,12 @@
"title": "This page is out of date", "title": "This page is out of date",
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.", "description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
"reload": "Reload" "reload": "Reload"
},
"desktop": {
"update_available": "gpx.studio {version} is available",
"update_restart": "Update and restart",
"update_download": "Download",
"update_installing": "Installing the update...",
"update_failed": "The update could not be installed"
} }
} }
+7
View File
@@ -598,5 +598,12 @@
"title": "This page is out of date", "title": "This page is out of date",
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.", "description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
"reload": "Reload" "reload": "Reload"
},
"desktop": {
"update_available": "gpx.studio {version} is available",
"update_restart": "Update and restart",
"update_download": "Download",
"update_installing": "Installing the update...",
"update_failed": "The update could not be installed"
} }
} }
+7
View File
@@ -598,5 +598,12 @@
"title": "This page is out of date", "title": "This page is out of date",
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.", "description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
"reload": "Reload" "reload": "Reload"
},
"desktop": {
"update_available": "gpx.studio {version} is available",
"update_restart": "Update and restart",
"update_download": "Download",
"update_installing": "Installing the update...",
"update_failed": "The update could not be installed"
} }
} }
+7
View File
@@ -598,5 +598,12 @@
"title": "This page is out of date", "title": "This page is out of date",
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.", "description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
"reload": "Reload" "reload": "Reload"
},
"desktop": {
"update_available": "gpx.studio {version} is available",
"update_restart": "Update and restart",
"update_download": "Download",
"update_installing": "Installing the update...",
"update_failed": "The update could not be installed"
} }
} }
+7
View File
@@ -598,5 +598,12 @@
"title": "This page is out of date", "title": "This page is out of date",
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.", "description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
"reload": "Reload" "reload": "Reload"
},
"desktop": {
"update_available": "gpx.studio {version} is available",
"update_restart": "Update and restart",
"update_download": "Download",
"update_installing": "Installing the update...",
"update_failed": "The update could not be installed"
} }
} }
+7
View File
@@ -598,5 +598,12 @@
"title": "This page is out of date", "title": "This page is out of date",
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.", "description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
"reload": "Reload" "reload": "Reload"
},
"desktop": {
"update_available": "gpx.studio {version} is available",
"update_restart": "Update and restart",
"update_download": "Download",
"update_installing": "Installing the update...",
"update_failed": "The update could not be installed"
} }
} }
+7
View File
@@ -598,5 +598,12 @@
"title": "This page is out of date", "title": "This page is out of date",
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.", "description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
"reload": "Reload" "reload": "Reload"
},
"desktop": {
"update_available": "gpx.studio {version} is available",
"update_restart": "Update and restart",
"update_download": "Download",
"update_installing": "Installing the update...",
"update_failed": "The update could not be installed"
} }
} }
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff. Show more