mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-11-15 07:07:07 +00:00
Compare commits
20
Commits
desktop-beta
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
761cfcae42 | ||
|
|
b719fe908f | ||
|
|
b6a7df3cf2 | ||
|
|
8ef3eca819 | ||
|
|
0e2a5a39df | ||
|
|
59c9427778 | ||
|
|
57c34b388b | ||
|
|
4050420b3c | ||
|
|
7474f892ce | ||
|
|
002fd259aa | ||
|
|
21e27a4416 | ||
|
|
b80fa7d942 | ||
|
|
2608870505 | ||
|
|
9e289c33be | ||
|
|
419489bcfe | ||
|
|
7a7749a9d3 | ||
|
|
eee76f05d4 | ||
|
|
a67b982b1c | ||
|
|
c0afd1fea9 | ||
|
|
9a23762af4 |
No files matched your search
@@ -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 }}
|
||||
@@ -1,11 +1,21 @@
|
||||
name: Deploy to GitHub Pages
|
||||
name: Deploy to Cloudflare Pages
|
||||
on:
|
||||
push:
|
||||
branches: 'main'
|
||||
branches: ['main', 'dev']
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: cloudflare-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build_site:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
@@ -41,35 +51,17 @@ jobs:
|
||||
run: npm test --prefix website
|
||||
|
||||
- name: Create env file
|
||||
run: |
|
||||
touch website/.env
|
||||
echo PUBLIC_MAPTILER_KEY=${{ secrets.PUBLIC_MAPTILER_KEY }} >> website/.env
|
||||
cat website/.env
|
||||
run: echo PUBLIC_MAPTILER_KEY=${{ secrets.PUBLIC_MAPTILER_KEY }} >> website/.env
|
||||
|
||||
- name: Build website
|
||||
env:
|
||||
BASE_PATH: ''
|
||||
run: |
|
||||
npm run build --prefix website
|
||||
run: npm run build --prefix website
|
||||
|
||||
- name: Upload Artifacts
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
- name: Deploy to Cloudflare Pages
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
path: 'website/build/'
|
||||
|
||||
deploy:
|
||||
needs: build_site
|
||||
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
|
||||
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 }}
|
||||
@@ -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."
|
||||
@@ -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
@@ -475,6 +475,12 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
@@ -537,6 +543,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
@@ -560,7 +576,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics-types",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
@@ -573,7 +589,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -1020,6 +1036,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.12"
|
||||
@@ -1287,6 +1313,17 @@ dependencies = [
|
||||
"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]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
@@ -1306,8 +1343,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1431,7 +1470,9 @@ dependencies = [
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-updater",
|
||||
"trash",
|
||||
"uuid",
|
||||
]
|
||||
@@ -1631,6 +1672,21 @@ dependencies = [
|
||||
"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]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.21"
|
||||
@@ -1650,9 +1706,11 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"windows-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1990,6 +2048,36 @@ dependencies = [
|
||||
"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]]
|
||||
name = "jni-sys"
|
||||
version = "0.3.1"
|
||||
@@ -2214,6 +2302,12 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -2303,6 +2397,18 @@ version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "notify"
|
||||
version = "8.2.0"
|
||||
@@ -2529,6 +2635,18 @@ dependencies = [
|
||||
"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]]
|
||||
name = "objc2-quartz-core"
|
||||
version = "0.3.2"
|
||||
@@ -2609,6 +2727,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1"
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
@@ -2625,6 +2749,20 @@ dependencies = [
|
||||
"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]]
|
||||
name = "pango"
|
||||
version = "0.18.3"
|
||||
@@ -3049,15 +3187,20 @@ dependencies = [
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
@@ -3093,6 +3236,20 @@ dependencies = [
|
||||
"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]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.3"
|
||||
@@ -3121,6 +3278,79 @@ dependencies = [
|
||||
"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]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
@@ -3136,6 +3366,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "schemars"
|
||||
version = "0.8.22"
|
||||
@@ -3193,6 +3432,29 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "selectors"
|
||||
version = "0.38.0"
|
||||
@@ -3425,6 +3687,22 @@ version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "siphasher"
|
||||
version = "1.0.4"
|
||||
@@ -3546,6 +3824,12 @@ version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "swift-rs"
|
||||
version = "1.0.9"
|
||||
@@ -3609,6 +3893,27 @@ dependencies = [
|
||||
"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]]
|
||||
name = "system-deps"
|
||||
version = "6.2.2"
|
||||
@@ -3630,7 +3935,7 @@ checksum = "f37f381f4e048e6cdf038b5705f8cf14ad108279d46eb968140a7b291aba9400"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"block2",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics",
|
||||
"crossbeam-channel",
|
||||
"dbus",
|
||||
@@ -3640,7 +3945,7 @@ dependencies = [
|
||||
"gdkwayland-sys",
|
||||
"gdkx11-sys",
|
||||
"gtk",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"log",
|
||||
"ndk",
|
||||
@@ -3673,6 +3978,17 @@ dependencies = [
|
||||
"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]]
|
||||
name = "target-lexicon"
|
||||
version = "0.12.16"
|
||||
@@ -3696,7 +4012,7 @@ dependencies = [
|
||||
"gtk",
|
||||
"heck 0.5.0",
|
||||
"http",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"log",
|
||||
"mime",
|
||||
@@ -3872,6 +4188,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "tauri-plugin-single-instance"
|
||||
version = "2.5.3"
|
||||
@@ -3888,6 +4214,40 @@ dependencies = [
|
||||
"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]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.12.1"
|
||||
@@ -3898,7 +4258,7 @@ dependencies = [
|
||||
"dpi",
|
||||
"gtk",
|
||||
"http",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"objc2",
|
||||
"objc2-ui-kit",
|
||||
"objc2-web-kit",
|
||||
@@ -3921,7 +4281,7 @@ checksum = "7f9e7e0c9f80a5130773cfabb6df4dc924518fb1b2c923726966804634b57d22"
|
||||
dependencies = [
|
||||
"gtk",
|
||||
"http",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
@@ -4110,6 +4470,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.20"
|
||||
@@ -4341,6 +4711,12 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "typed-path"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e"
|
||||
|
||||
[[package]]
|
||||
name = "typeid"
|
||||
version = "1.0.3"
|
||||
@@ -4376,6 +4752,12 @@ version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@@ -4675,6 +5057,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "webview2-com"
|
||||
version = "0.39.1"
|
||||
@@ -4839,6 +5230,17 @@ dependencies = [
|
||||
"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]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
@@ -4866,6 +5268,15 @@ dependencies = [
|
||||
"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]]
|
||||
name = "windows-sys"
|
||||
version = "0.60.2"
|
||||
@@ -4899,6 +5310,22 @@ dependencies = [
|
||||
"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]]
|
||||
name = "windows-targets"
|
||||
version = "0.53.5"
|
||||
@@ -4909,7 +5336,7 @@ dependencies = [
|
||||
"windows_aarch64_gnullvm 0.53.1",
|
||||
"windows_aarch64_msvc 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_x86_64_gnu 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"
|
||||
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.53.1"
|
||||
@@ -4952,6 +5385,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.53.1"
|
||||
@@ -4964,12 +5403,24 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.53.1"
|
||||
@@ -4982,6 +5433,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.53.1"
|
||||
@@ -4994,6 +5451,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.53.1"
|
||||
@@ -5006,6 +5469,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.53.1"
|
||||
@@ -5018,6 +5487,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.53.1"
|
||||
@@ -5082,7 +5557,7 @@ dependencies = [
|
||||
"gtk",
|
||||
"http",
|
||||
"javascriptcore-rs",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"ndk",
|
||||
"objc2",
|
||||
@@ -5129,6 +5604,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "yoke"
|
||||
version = "0.8.3"
|
||||
@@ -5243,6 +5728,12 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.5"
|
||||
@@ -5276,6 +5767,22 @@ dependencies = [
|
||||
"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]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.8"
|
||||
|
||||
@@ -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.
|
||||
/// Absent fields are unknown.
|
||||
#[derive(Serialize)]
|
||||
|
||||
@@ -63,18 +63,6 @@ pub fn waypoint<H: History>(
|
||||
.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
|
||||
/// trackpoints to keep.
|
||||
pub fn export_file<H: History>(
|
||||
|
||||
@@ -44,14 +44,24 @@ where
|
||||
let last = trkseg.last_index().unwrap();
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
start: TrackSegmentIndex,
|
||||
end: TrackSegmentIndex,
|
||||
@@ -61,6 +71,16 @@ fn ramer_douglas_peucker_helper<M>(
|
||||
) where
|
||||
M: Fn(TrackSegmentIndex, TrackSegmentIndex, TrackSegmentIndex) -> f64,
|
||||
{
|
||||
let mut steps = vec![Step::Range(start, end)];
|
||||
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 max_idx = None;
|
||||
let mut max_dist = 0.0;
|
||||
|
||||
@@ -80,9 +100,11 @@ fn ramer_douglas_peucker_helper<M>(
|
||||
}
|
||||
|
||||
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);
|
||||
// the last one pushed is done first
|
||||
steps.push(Step::Range(idx, end));
|
||||
steps.push(Step::Keep(idx, max_dist));
|
||||
steps.push(Step::Range(start, idx));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,6 +217,16 @@ mod tests {
|
||||
.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]
|
||||
fn test_empty_and_single_point() {
|
||||
assert!(simplify(&[], 1.0).is_empty());
|
||||
|
||||
+183
-6
@@ -3,10 +3,10 @@ use crate::{
|
||||
TrackpointCategories, TrackpointChunk, Waypoint, WaypointChunk, distance,
|
||||
};
|
||||
use chrono::DateTime;
|
||||
use quick_xml::Error;
|
||||
use quick_xml::{Error, XmlVersion};
|
||||
use quick_xml::escape::resolve_predefined_entity;
|
||||
use quick_xml::events::Event;
|
||||
use quick_xml::events::attributes::Attributes;
|
||||
use quick_xml::events::attributes::{Attribute, Attributes};
|
||||
use quick_xml::reader::Reader;
|
||||
|
||||
enum GPXElement {
|
||||
@@ -37,6 +37,8 @@ enum GPXElement {
|
||||
Symbol,
|
||||
Type,
|
||||
Color,
|
||||
/// The color of a track or a route in the Garmin extensions (`gpxx:DisplayColor`).
|
||||
DisplayColor,
|
||||
Opacity,
|
||||
Width,
|
||||
}
|
||||
@@ -58,6 +60,50 @@ fn is_route_point_extension(name: &str) -> bool {
|
||||
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.
|
||||
fn holds_text(element: Option<&GPXElement>) -> bool {
|
||||
matches!(
|
||||
@@ -81,6 +127,7 @@ fn holds_text(element: Option<&GPXElement>) -> bool {
|
||||
| GPXElement::Symbol
|
||||
| GPXElement::Type
|
||||
| GPXElement::Color
|
||||
| GPXElement::DisplayColor
|
||||
| GPXElement::Opacity
|
||||
| GPXElement::Width
|
||||
)
|
||||
@@ -241,7 +288,21 @@ fn apply_text(
|
||||
Some(GPXElement::Color) => {
|
||||
stack.pop();
|
||||
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) => {
|
||||
@@ -260,6 +321,13 @@ fn apply_text(
|
||||
}
|
||||
}
|
||||
|
||||
/// The text of an attribute, without its escaping (`&` 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.
|
||||
fn set_email(stack: &mut [GPXElement], attributes: Attributes<'_>) {
|
||||
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);
|
||||
for attr in attributes.flatten() {
|
||||
match attr.key.as_ref() {
|
||||
"id" => id = Some(attr.value.to_string()),
|
||||
"domain" => domain = Some(attr.value.to_string()),
|
||||
"id" => id = Some(attribute_text(&attr)),
|
||||
"domain" => domain = Some(attribute_text(&attr)),
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
@@ -283,7 +351,7 @@ fn parse_link(attributes: Attributes<'_>) -> Link {
|
||||
let mut link = Link::default();
|
||||
for attr in attributes.flatten() {
|
||||
if attr.key.as_ref() == "href" {
|
||||
link.href = attr.value.to_string();
|
||||
link.href = attribute_text(&attr);
|
||||
}
|
||||
}
|
||||
link
|
||||
@@ -448,6 +516,7 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File,
|
||||
"sym" => stack.push(GPXElement::Symbol),
|
||||
"type" => stack.push(GPXElement::Type),
|
||||
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("width") => stack.push(GPXElement::Width),
|
||||
_ => (),
|
||||
@@ -626,6 +695,14 @@ mod tests {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_link_href_is_unescaped_and_written_back_once() {
|
||||
let data =
|
||||
r#"<gpx><metadata><link href="https://a.example/?x=1&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]
|
||||
fn test_parse_entities_and_cdata_in_text() {
|
||||
let data = r#"<gpx><metadata><name>Tom & Jerry <3 éA "x" 'y'</name>
|
||||
@@ -1259,4 +1336,104 @@ mod tests {
|
||||
assert!(trk.info.opacity.is_some_and(|o| o == 0.5));
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -24,14 +24,6 @@ pub struct 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 {
|
||||
let mut stats = Self::default();
|
||||
if trkseg.is_empty() {
|
||||
|
||||
@@ -36,6 +36,7 @@ impl StatisticsCache {
|
||||
}
|
||||
|
||||
/// Statistics of the segments of the selection.
|
||||
#[cfg(test)]
|
||||
pub fn get<'a>(
|
||||
&'a self,
|
||||
state: Option<&'a Files>,
|
||||
|
||||
@@ -11,7 +11,7 @@ pub use outcome::*;
|
||||
pub use state::*;
|
||||
|
||||
use crate::{
|
||||
ExportOptions, FileId, GlobalStatistics, Restored, Snapshot, TrackSegmentId, Trackpoint,
|
||||
ExportOptions, FileId, GlobalStatistics, Restored, Snapshot, TrackSegmentId,
|
||||
TrackpointCategories, Waypoint, WaypointId, reduction_distances, write,
|
||||
};
|
||||
|
||||
@@ -82,22 +82,6 @@ impl<H: History> Engine<H> {
|
||||
.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
|
||||
/// which `Reduce` keeps it, see [`reduction_distances`].
|
||||
pub fn reduction_distances(&self) -> Vec<f64> {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn test_export_writes_the_file_as_it_is() {
|
||||
let mut engine = TestEngine::default();
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::{
|
||||
Batch, ChunkKey, ChunkKind, CommitError, FileId, Revision, Storage, StorageError, StoredData,
|
||||
};
|
||||
#[cfg(test)]
|
||||
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,
|
||||
/// and what the tests use.
|
||||
@@ -22,6 +22,7 @@ struct State {
|
||||
}
|
||||
|
||||
/// How much there is in a storage.
|
||||
#[cfg(test)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct StorageCounts {
|
||||
pub files: usize,
|
||||
@@ -29,6 +30,7 @@ pub struct StorageCounts {
|
||||
pub waypoint_chunks: usize,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl MemoryStorage {
|
||||
/// How many files and chunks are stored.
|
||||
pub fn counts(&self) -> StorageCounts {
|
||||
|
||||
@@ -37,3 +37,7 @@ tauri-plugin-single-instance = "2.5.3"
|
||||
tauri-plugin-opener = "2"
|
||||
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"
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
"identifier": "default",
|
||||
"description": "Permissions for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": ["core:default"]
|
||||
"permissions": ["core:default", "updater:default", "process:allow-restart"]
|
||||
}
|
||||
@@ -57,16 +57,6 @@ pub fn waypoint(
|
||||
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.
|
||||
#[tauri::command(async)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
|
||||
+6
-1
@@ -55,6 +55,12 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.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 emit: state::Emit = std::sync::Arc::new(move |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_paste,
|
||||
queries::waypoint,
|
||||
queries::trackpoint,
|
||||
queries::export_file,
|
||||
queries::exportable_data,
|
||||
queries::reduction_distances,
|
||||
|
||||
+10
-1
@@ -27,7 +27,8 @@
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"targets": ["app", "dmg", "appimage", "deb", "rpm", "nsis"],
|
||||
"macOS": { "signingIdentity": "-" },
|
||||
"fileAssociations": [
|
||||
{
|
||||
"ext": ["gpx"],
|
||||
@@ -44,5 +45,13 @@
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDE5NTFDQzVDOUYwNEMyNEMKUldSTXdnU2ZYTXhSR1ZXRDV6aVErc3dUWXBaK0VGSitYRnFFR21hZ2s5UEJTT01aYkE5Unp5c0cKdW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEFFNTU3Q0Y1NzhFODkyMTEKUldRUmt1aDQ5WHhWcmdXQmx0UG83V29TenRxSVJjdkc4ckN1Zi93ajc3ZUhVUVp3eFlCOXphb1IK",
|
||||
"endpoints": [
|
||||
"https://github.com/gpxstudio/gpx.studio/releases/latest/download/latest.json"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
-6
@@ -26,6 +26,9 @@ thread_local! {
|
||||
static PERSISTER: RefCell<Option<Persister>> = const { RefCell::new(None) };
|
||||
static SAVING: 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`.
|
||||
/// Called after the files were written, and when a write was refused because they were
|
||||
/// written by someone else since: see `open_storage`.
|
||||
@@ -73,20 +76,28 @@ pub(crate) fn schedule_save() {
|
||||
let result = persister.save(&*storage, snapshot).await;
|
||||
PERSISTER.with(|slot| *slot.borrow_mut() = Some(persister));
|
||||
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
|
||||
// tab has to read them, which replaces what it did
|
||||
Err(engine::CommitError::Stale { .. }) => {
|
||||
NOT_WRITTEN.set(false);
|
||||
DIRTY.set(false);
|
||||
behind = true;
|
||||
}
|
||||
// nothing is written, so that the newer version finds its data again
|
||||
Err(engine::CommitError::Failed(engine::StorageError::Newer)) => {
|
||||
NOT_WRITTEN.set(true);
|
||||
DIRTY.set(false);
|
||||
newer = true;
|
||||
}
|
||||
// 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);
|
||||
@@ -188,17 +199,18 @@ pub(crate) fn to_js_error(error: engine::StorageError) -> JsValue {
|
||||
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]
|
||||
pub async fn flush_storage() -> Result<(), JsValue> {
|
||||
pub async fn flush_storage() -> Result<bool, JsValue> {
|
||||
if !SAVING.get() {
|
||||
return Ok(());
|
||||
return Ok(!NOT_WRITTEN.get());
|
||||
}
|
||||
let promise = js_sys::Promise::new(&mut |resolve, _| {
|
||||
WAITERS.with(|waiters| waiters.borrow_mut().push(resolve));
|
||||
});
|
||||
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
|
||||
|
||||
@@ -101,18 +101,6 @@ pub fn waypoint(file_id: &str, waypoint_id: &str) -> WaypointDetails {
|
||||
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
|
||||
/// elements and their ids (UUID strings).
|
||||
#[wasm_bindgen]
|
||||
|
||||
@@ -59,14 +59,6 @@ export interface WaypointDetails {
|
||||
links?: { href: string; text?: 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.
|
||||
* Absent fields are unknown.
|
||||
@@ -189,8 +181,6 @@ extern "C" {
|
||||
pub type Selection;
|
||||
#[wasm_bindgen(typescript_type = "WaypointDetails | undefined")]
|
||||
pub type WaypointDetails;
|
||||
#[wasm_bindgen(typescript_type = "TrackpointDetails | undefined")]
|
||||
pub type TrackpointDetails;
|
||||
#[wasm_bindgen(typescript_type = "SelectionPoint | undefined")]
|
||||
pub type SelectionPoint;
|
||||
#[wasm_bindgen(typescript_type = "MoveTarget")]
|
||||
|
||||
Generated
+22
@@ -32,6 +32,8 @@
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/vite": "^4.1.8",
|
||||
"@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/events": "^3.0.3",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
@@ -2667,6 +2669,26 @@
|
||||
"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": {
|
||||
"version": "0.6.0",
|
||||
"dev": true,
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/vite": "^4.1.8",
|
||||
"@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/events": "^3.0.3",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
statistics,
|
||||
slicedStatistics,
|
||||
hoveredPoint,
|
||||
hoveredTrackpoint,
|
||||
additionalDatasets,
|
||||
elevationFill,
|
||||
showControls = true,
|
||||
@@ -38,6 +39,7 @@
|
||||
statistics: Readable<SelectionStatistics>;
|
||||
slicedStatistics: Writable<SlicedStatistics | undefined>;
|
||||
hoveredPoint: Writable<Coordinates | null>;
|
||||
hoveredTrackpoint?: Readable<number | null>;
|
||||
additionalDatasets: Writable<string[]>;
|
||||
elevationFill: Writable<'slope' | 'surface' | 'highway' | undefined>;
|
||||
showControls?: boolean;
|
||||
@@ -55,7 +57,8 @@
|
||||
additionalDatasets,
|
||||
elevationFill,
|
||||
canvas,
|
||||
overlay
|
||||
overlay,
|
||||
hoveredTrackpoint
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -15,10 +15,15 @@ import {
|
||||
getVelocityWithUnits,
|
||||
} from '$lib/units/units';
|
||||
import Chart, {
|
||||
Tooltip,
|
||||
type ActiveDataPoint,
|
||||
type ChartEvent,
|
||||
type ChartOptions,
|
||||
type ChartType,
|
||||
type Point,
|
||||
type ScriptableLineSegmentContext,
|
||||
type TooltipItem,
|
||||
type TooltipPositionerFunction,
|
||||
} from 'chart.js/auto';
|
||||
import { get, type Readable, type Writable } from 'svelte/store';
|
||||
import type { Coordinates } from '$lib/geo/coordinates';
|
||||
@@ -40,6 +45,25 @@ const { distanceUnits, velocityUnits, temperatureUnits } = settings;
|
||||
Chart.defaults.font.family =
|
||||
'ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"'; // Tailwind CSS font
|
||||
|
||||
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. */
|
||||
function optional(value: number, convert: (value: number) => number = (v) => v) {
|
||||
return Number.isNaN(value) ? undefined : convert(value);
|
||||
@@ -55,6 +79,21 @@ interface ElevationProfilePoint {
|
||||
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 {
|
||||
private _chart: Chart | null = null;
|
||||
private _canvas: HTMLCanvasElement;
|
||||
@@ -71,10 +110,14 @@ export class ElevationProfile {
|
||||
*/
|
||||
private _hovered: { index: number; loaded: boolean; point?: SelectionPoint } | null = null;
|
||||
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 _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 _elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>;
|
||||
/** The statistics that are only read from the engine while the profile shows them. */
|
||||
@@ -87,7 +130,8 @@ export class ElevationProfile {
|
||||
additionalDatasets: Readable<string[]>,
|
||||
elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>,
|
||||
canvas: HTMLCanvasElement,
|
||||
overlay: HTMLCanvasElement
|
||||
overlay: HTMLCanvasElement,
|
||||
hoveredTrackpoint?: Readable<number | null>
|
||||
) {
|
||||
this._statistics = statistics;
|
||||
this._slicedStatistics = slicedStatistics;
|
||||
@@ -96,6 +140,7 @@ export class ElevationProfile {
|
||||
this._elevationFill = elevationFill;
|
||||
this._canvas = canvas;
|
||||
this._overlay = overlay;
|
||||
this._hoveredTrackpoint = hoveredTrackpoint;
|
||||
this._request = engine.requestStatistics();
|
||||
this.updateRequest();
|
||||
|
||||
@@ -127,6 +172,9 @@ export class ElevationProfile {
|
||||
this.updateRequest();
|
||||
this.updateFill();
|
||||
});
|
||||
this._unsubscribeHoveredTrackpoint = this._hoveredTrackpoint?.subscribe((index) => {
|
||||
this.showTrackpoint(index);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -195,20 +243,16 @@ export class ElevationProfile {
|
||||
},
|
||||
tooltip: {
|
||||
enabled: () => !this._dragging && !this._panning,
|
||||
position: 'trackpoint',
|
||||
callbacks: {
|
||||
title: () => {
|
||||
return '';
|
||||
},
|
||||
label: (context: TooltipItem<'line'>) => {
|
||||
const point = context.raw as ElevationProfilePoint;
|
||||
const point = this.pointOf(context);
|
||||
if (context.datasetIndex === 0) {
|
||||
this._hoveredPoint.set(
|
||||
this._dragging
|
||||
? null
|
||||
: {
|
||||
lat: this._data.lat[point.index],
|
||||
lng: this._data.lng[point.index],
|
||||
}
|
||||
this._dragging ? null : this.coordinatesOf(point.index)
|
||||
);
|
||||
return `${i18n._('quantities.elevation')}: ${getElevationWithUnits(point.y, false)}`;
|
||||
} else if (context.datasetIndex === 1) {
|
||||
@@ -228,7 +272,7 @@ export class ElevationProfile {
|
||||
(context) => context.datasetIndex === 0
|
||||
);
|
||||
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`)
|
||||
const hovered = this._hovered;
|
||||
const details =
|
||||
@@ -365,29 +409,21 @@ export class ElevationProfile {
|
||||
afterEvent: (chart: Chart, args: { event: ChartEvent }) => {
|
||||
if (args.event.type === 'mouseout') {
|
||||
this._hovered = null;
|
||||
this._heldMove = null;
|
||||
this._hoveredPoint.set(null);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
// The tooltip only shows a trackpoint once all its data is read: a move to
|
||||
// another one is held until then, and replayed (see `loadHovered`).
|
||||
id: 'waitForTrackpoint',
|
||||
// The tooltip follows the pointer at once with what the chart knows of the
|
||||
// trackpoint, and shows the rest of its data once it is read (see
|
||||
// `loadHovered`).
|
||||
id: 'loadTrackpoint',
|
||||
events: ['mousemove'],
|
||||
beforeEvent: (chart: Chart, args: { event: ChartEvent }) => {
|
||||
const index = this.indexAt(args.event);
|
||||
if (index === undefined) {
|
||||
return;
|
||||
}
|
||||
const hovered = this._hovered;
|
||||
if (hovered?.index === index && hovered.loaded) {
|
||||
this._heldMove = null;
|
||||
return;
|
||||
}
|
||||
this._heldMove = args.event.native as MouseEvent;
|
||||
if (index !== undefined) {
|
||||
this.hover(index);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -478,6 +514,7 @@ export class ElevationProfile {
|
||||
}
|
||||
const data = get(this._statistics);
|
||||
this._data = data;
|
||||
const shownTrackpoint = this._shownTrackpoint;
|
||||
// the indices of the trackpoints are the ones of other statistics
|
||||
this._hovered = null;
|
||||
const units = {
|
||||
@@ -524,6 +561,7 @@ export class ElevationProfile {
|
||||
}
|
||||
}
|
||||
|
||||
this._points = datasets;
|
||||
this._chart.data.datasets[0] = {
|
||||
label: i18n._('quantities.elevation'),
|
||||
data: datasets[0],
|
||||
@@ -570,6 +608,10 @@ export class ElevationProfile {
|
||||
this.setFill();
|
||||
|
||||
this._chart.update();
|
||||
|
||||
if (shownTrackpoint !== null) {
|
||||
this.showTrackpoint(shownTrackpoint);
|
||||
}
|
||||
}
|
||||
|
||||
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. */
|
||||
private indexAt(event: ChartEvent): number | undefined {
|
||||
const elements = this._chart?.getElementsAtEventForMode(
|
||||
@@ -718,11 +828,10 @@ export class ElevationProfile {
|
||||
}
|
||||
hovered.loaded = true;
|
||||
hovered.point = point;
|
||||
// the move that was held can now show the tooltip
|
||||
const move = this._heldMove;
|
||||
this._heldMove = null;
|
||||
if (move) {
|
||||
this._canvas.dispatchEvent(new MouseEvent(move.type, move));
|
||||
if (this._shownTrackpoint === hovered.index) {
|
||||
this.activateTrackpoint(hovered.index);
|
||||
} else {
|
||||
this.refreshTooltip();
|
||||
}
|
||||
}
|
||||
} 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. */
|
||||
private static segmentStart(context: ScriptableLineSegmentContext & { p0: { raw: any } }) {
|
||||
return (context.p0.raw as ElevationProfilePoint).index;
|
||||
@@ -758,6 +879,7 @@ export class ElevationProfile {
|
||||
|
||||
destroy() {
|
||||
this._request.release();
|
||||
this._unsubscribeHoveredTrackpoint?.();
|
||||
if (this._chart) {
|
||||
this._chart.destroy();
|
||||
this._chart = null;
|
||||
|
||||
@@ -18,7 +18,11 @@
|
||||
import { untrack } from 'svelte';
|
||||
import { isSelected, toggle } from '$lib/components/map/layer-control/utils';
|
||||
import { engine } from '$lib/engine';
|
||||
import { hoveredPoint, slicedStatistics } from '$lib/state/selection-statistics';
|
||||
import {
|
||||
hoveredPoint,
|
||||
hoveredTrackpoint,
|
||||
slicedStatistics,
|
||||
} from '$lib/state/selection-statistics';
|
||||
|
||||
let {
|
||||
useHash = true,
|
||||
@@ -119,6 +123,7 @@
|
||||
{statistics}
|
||||
{slicedStatistics}
|
||||
{hoveredPoint}
|
||||
{hoveredTrackpoint}
|
||||
{additionalDatasets}
|
||||
{elevationFill}
|
||||
showControls={options.elevation.controls}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { StartEndMarkers } from '$lib/components/map/gpx-layer/start-end-markers';
|
||||
import { createPopups, removePopups } from '$lib/components/map/gpx-layer/gpx-layer-popup';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import { hoveredTrackpoint } from '$lib/state/selection-statistics';
|
||||
|
||||
let distanceMarkers: DistanceMarkers;
|
||||
let startEndMarkers: StartEndMarkers;
|
||||
@@ -14,6 +15,11 @@
|
||||
startEndMarkers = new StartEndMarkers();
|
||||
distanceMarkers = new DistanceMarkers();
|
||||
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(() => {
|
||||
|
||||
@@ -2,17 +2,15 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import CopyCoordinates from '$lib/components/map/gpx-layer/CopyCoordinates.svelte';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import WithUnits from '$lib/components/WithUnits.svelte';
|
||||
import { Compass, Earth, Mountain, Timer } from '@lucide/svelte';
|
||||
import { Compass, Earth } from '@lucide/svelte';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import type { PopupItem } from '$lib/components/map/map-popup';
|
||||
import type { TrackpointDetails } from '$lib/engine';
|
||||
import { map } from '$lib/components/map/map';
|
||||
|
||||
let {
|
||||
trackpoint,
|
||||
}: {
|
||||
trackpoint: PopupItem<Pick<TrackpointDetails, 'lng' | 'lat'> & Partial<TrackpointDetails>>;
|
||||
trackpoint: PopupItem<{ lng: number; lat: number }>;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
@@ -22,24 +20,11 @@
|
||||
<Compass size="14" />
|
||||
{trackpoint.item.lat.toFixed(6)}° {trackpoint.item.lng.toFixed(6)}°
|
||||
</div>
|
||||
{#if trackpoint.item.ele !== undefined}
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<Mountain size="14" />
|
||||
<WithUnits value={trackpoint.item.ele} type="elevation" />
|
||||
</div>
|
||||
{/if}
|
||||
{#if trackpoint.item.time !== undefined}
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<Timer size="14" />
|
||||
{i18n.df.format(new Date(trackpoint.item.time))}
|
||||
</div>
|
||||
{/if}
|
||||
<CopyCoordinates
|
||||
coordinates={trackpoint.item}
|
||||
onCopy={() => trackpoint.hide?.()}
|
||||
class="mt-0.5"
|
||||
/>
|
||||
{#if trackpoint.fileId === undefined}
|
||||
<Button
|
||||
variant="outline"
|
||||
class="justify-start"
|
||||
@@ -49,6 +34,5 @@
|
||||
<Earth size="14" />
|
||||
{i18n._('menu.edit_osm')}
|
||||
</Button>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
Point,
|
||||
} from 'maplibre-gl';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import { waypointPopup, trackpointPopup } from './gpx-layer-popup';
|
||||
import { closestPointIndex } from '$lib/geo/closest-point';
|
||||
import { waypointPopup } from './gpx-layer-popup';
|
||||
import { closestPointIndexIn } from '$lib/geo/closest-point';
|
||||
import { getElevation, loadSVGIcon } from '$lib/utils';
|
||||
import { selectedWaypoint } from '$lib/components/toolbar/tools/waypoint/waypoint';
|
||||
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 { mapCursor, MapCursorState } from '$lib/state/map-cursor';
|
||||
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) {
|
||||
const symbolSvg = symbol ? symbols[symbol]?.iconSvg : undefined;
|
||||
@@ -63,6 +64,7 @@ export class GPXLayer {
|
||||
currentWaypointData: GeoJSON.FeatureCollection | null = null;
|
||||
draggedWaypointIndex: number | null = null;
|
||||
draggingStartingPosition: Point = new Point(0, 0);
|
||||
hoversTrackpoint: boolean = false;
|
||||
unsubscribe: (() => void)[] = [];
|
||||
|
||||
updateBinded: () => void = this.update.bind(this);
|
||||
@@ -400,25 +402,55 @@ export class GPXLayer {
|
||||
layerOnMouseLeave() {
|
||||
mapCursor.notify(MapCursorState.SCISSORS, false);
|
||||
mapCursor.notify(MapCursorState.LAYER_HOVER, false);
|
||||
this.stopHoveringTrackpoint();
|
||||
}
|
||||
|
||||
async layerOnMouseMove(e: MapLayerMouseEvent) {
|
||||
if (e.originalEvent.shiftKey) {
|
||||
const { segmentId } = e.features![0].properties;
|
||||
const lngLat = { lng: e.lngLat.lng, lat: e.lngLat.lat };
|
||||
const index = closestPointIndex(await engine.segmentCoordinates(segmentId), lngLat);
|
||||
const trackpoint =
|
||||
index === undefined
|
||||
layerOnMouseMove(e: MapLayerMouseEvent) {
|
||||
// these tools act on the track under the pointer, and show it themselves
|
||||
const tool = get(currentTool);
|
||||
const index =
|
||||
tool === Tool.ROUTING || tool === Tool.SCISSORS
|
||||
? undefined
|
||||
: await engine.trackpoint(this.fileId, segmentId, index);
|
||||
if (trackpoint) {
|
||||
trackpointPopup?.setItem({
|
||||
item: trackpoint,
|
||||
kind: 'trackpoint',
|
||||
fileId: this.fileId,
|
||||
});
|
||||
: this.selectedTrackpointAt(e);
|
||||
if (index === undefined) {
|
||||
this.stopHoveringTrackpoint();
|
||||
} else {
|
||||
this.hoversTrackpoint = true;
|
||||
hoveredTrackpoint.set(index);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FileStateCollectionObserver } from '$lib/engine';
|
||||
import { engine, FileStateCollectionObserver } from '$lib/engine';
|
||||
import { writable } from 'svelte/store';
|
||||
import { GPXLayer } from './gpx-layer';
|
||||
|
||||
@@ -14,6 +14,7 @@ export class GPXLayerCollection {
|
||||
if (this._fileStateCollectionObserver) {
|
||||
return;
|
||||
}
|
||||
engine.requestStatistics().set(['segments']);
|
||||
this._fileStateCollectionObserver = new FileStateCollectionObserver(
|
||||
(newFiles) => {
|
||||
newFiles.forEach((fileStore, fileId) => {
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
} from '$lib/data/layers';
|
||||
import { getLayers, isSelected, toggle } from '$lib/components/map/layer-control/utils';
|
||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||
import { map } from '$lib/components/map/map';
|
||||
import CustomLayers from './CustomLayers.svelte';
|
||||
import { settings } from '$lib/state/settings';
|
||||
import { untrack } from 'svelte';
|
||||
@@ -209,19 +208,6 @@
|
||||
disabled={selectedOverlay === undefined}
|
||||
onValueChange={(value) => {
|
||||
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;
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
let {
|
||||
name,
|
||||
node,
|
||||
parent = '',
|
||||
selected = '',
|
||||
onselect = () => {},
|
||||
multiple = false,
|
||||
@@ -19,6 +20,7 @@
|
||||
}: {
|
||||
name: string;
|
||||
node: LayerTreeType;
|
||||
parent?: string;
|
||||
selected?: string;
|
||||
onselect?: (value: string) => void;
|
||||
multiple: boolean;
|
||||
@@ -26,7 +28,21 @@
|
||||
} = $props();
|
||||
|
||||
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(() => {
|
||||
if (checked !== undefined) {
|
||||
@@ -57,7 +73,7 @@
|
||||
() => checked[id] === true, (value) => (checked[id] = value)
|
||||
}
|
||||
class="scale-90"
|
||||
aria-label={i18n._(`layers.label.${id}`)}
|
||||
aria-label={getLabel(id)}
|
||||
/>
|
||||
{:else}
|
||||
<input
|
||||
@@ -74,25 +90,20 @@
|
||||
/>
|
||||
{/if}
|
||||
<Label for="{name}-{id}" class="flex flex-row items-center gap-1">
|
||||
{#if Object.hasOwn($customLayers, id)}
|
||||
{$customLayers[id].name}
|
||||
{:else if $isLayerFromExtension(id)}
|
||||
{$getLayerName(id)}
|
||||
{:else}
|
||||
{i18n._(`layers.label.${id}`)}
|
||||
{/if}
|
||||
{getLabel(id)}
|
||||
</Label>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if anySelectedLayer(node[id])}
|
||||
<CollapsibleTreeNode {id}>
|
||||
{#snippet trigger()}
|
||||
<span>{i18n._(`layers.label.${id}`, id)}</span>
|
||||
<span>{getGroupLabel(id)}</span>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<div class="ml-2">
|
||||
<Self
|
||||
node={node[id] as LayerTreeType}
|
||||
parent={id}
|
||||
{name}
|
||||
{selected}
|
||||
{onselect}
|
||||
|
||||
@@ -5,8 +5,10 @@ import { get } from 'svelte/store';
|
||||
import { overlays, overlayTree, type LayerTreeType } from '$lib/data/layers';
|
||||
import { engine } from '$lib/engine';
|
||||
import { settings } from '$lib/state/settings';
|
||||
import { ExtensionAPI, type CustomOverlay } from './extension-api';
|
||||
import { isSelected } from './utils';
|
||||
import { map } from '$lib/components/map/map';
|
||||
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 };
|
||||
|
||||
@@ -41,6 +43,8 @@ beforeEach(() => {
|
||||
afterEach(() => {
|
||||
// the layers are shared by the whole app: leave them as they were
|
||||
api.filterOverlays([]);
|
||||
api.setOverlays('Heatmaps', []);
|
||||
api.setOverlays('Other', []);
|
||||
for (const extension of ['My extension', 'Other extension']) {
|
||||
delete (overlayTree.overlays as LayerTreeType)[extension];
|
||||
}
|
||||
@@ -151,3 +155,346 @@ describe('ExtensionAPI', () => {
|
||||
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 type { StyleSpecification } from 'maplibre-gl';
|
||||
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 { browser } from '$app/environment';
|
||||
import { map } from '$lib/components/map/map';
|
||||
|
||||
const { currentOverlays, previousOverlays, selectedOverlayTree } = settings;
|
||||
|
||||
/** An overlay of the v1 API. */
|
||||
export type CustomOverlay = {
|
||||
extensionName: string;
|
||||
id: string;
|
||||
@@ -15,20 +17,97 @@ export type CustomOverlay = {
|
||||
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 {
|
||||
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() {
|
||||
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', {
|
||||
value: this,
|
||||
});
|
||||
addEventListener('beforeunload', () => {
|
||||
this.destroy();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated v1: use `ready`. */
|
||||
ensureLoaded(): Promise<void> {
|
||||
let unsubscribe: () => void;
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
@@ -46,6 +125,168 @@ export class ExtensionAPI {
|
||||
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) {
|
||||
if (
|
||||
!overlay.extensionName ||
|
||||
@@ -58,7 +299,7 @@ export class ExtensionAPI {
|
||||
'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) => {
|
||||
$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[]) {
|
||||
ids = ids.map((id) => this.getOverlayId(id));
|
||||
const idsToRemove = Array.from(get(this._overlays).keys()).filter(
|
||||
(id) => !ids.includes(id)
|
||||
);
|
||||
ids = ids.map((id) => getOverlayId(id));
|
||||
const idsToRemove = Array.from(get(this._overlays))
|
||||
.filter(([id, overlay]) => overlay.signature === undefined && !ids.includes(id))
|
||||
.map(([id]) => id);
|
||||
|
||||
currentOverlays.update((current) => {
|
||||
removeAll(current, idsToRemove);
|
||||
@@ -153,8 +395,9 @@ export class ExtensionAPI {
|
||||
});
|
||||
}
|
||||
|
||||
/** @deprecated v1: use `setOverlays`. */
|
||||
updateOverlaysOrder(ids: string[]) {
|
||||
ids = ids.map((id) => this.getOverlayId(id));
|
||||
ids = ids.map((id) => getOverlayId(id));
|
||||
selectedOverlayTree.update((selected) => {
|
||||
const isSelected: Record<string, boolean> = {};
|
||||
ids.forEach((id) => {
|
||||
@@ -184,31 +427,11 @@ export class ExtensionAPI {
|
||||
return (id: string) => $overlays.get(id)?.name || '';
|
||||
});
|
||||
|
||||
private getOverlayId(id: string): string {
|
||||
return `extension-${id}`;
|
||||
}
|
||||
|
||||
private destroy() {
|
||||
const ids = Array.from(get(this._overlays).keys());
|
||||
currentOverlays.update((current) => {
|
||||
ids.forEach((id) => {
|
||||
remove(current, id);
|
||||
/** Whether a group of the layer trees is an extension, whose name is shown as it was given. */
|
||||
isExtensionGroup = derived(this._overlays, ($overlays) => {
|
||||
const names = new Set(Array.from($overlays.values(), (overlay) => overlay.extensionName));
|
||||
return (id: string) => names.has(id);
|
||||
});
|
||||
return current;
|
||||
});
|
||||
previousOverlays.update((previous) => {
|
||||
ids.forEach((id) => {
|
||||
remove(previous, id);
|
||||
});
|
||||
return previous;
|
||||
});
|
||||
selectedOverlayTree.update((selected) => {
|
||||
ids.forEach((id) => {
|
||||
remove(selected, id);
|
||||
});
|
||||
return selected;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const extensionAPI = new ExtensionAPI();
|
||||
@@ -1,7 +1,16 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
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
|
||||
// satellite: esri, ign: fr (selected)
|
||||
@@ -72,3 +81,39 @@ describe('layer trees', () => {
|
||||
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 { 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) {
|
||||
return (
|
||||
|
||||
@@ -6,7 +6,7 @@ import MapPopupComponent from '$lib/components/map/MapPopup.svelte';
|
||||
export type PopupItem<T = any> = {
|
||||
/** Has a position (`lng` and `lat`). */
|
||||
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';
|
||||
fileId?: string;
|
||||
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'));
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
overlays,
|
||||
terrainSources,
|
||||
} 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';
|
||||
|
||||
const { currentBasemap, currentOverlays, customLayers, opacities, terrainSource, distanceUnits } =
|
||||
@@ -147,13 +147,18 @@ export class StyleManager {
|
||||
const custom = get(customLayers);
|
||||
const overlayOpacities = get(opacities);
|
||||
try {
|
||||
// an overlay on the map can be gone from the tree, which has been replaced as a whole
|
||||
const layers = getLayers(get(currentOverlays) ?? {});
|
||||
this._pastOverlays.forEach((id) => (layers[id] ??= false));
|
||||
for (const overlay in layers) {
|
||||
if (!layers[overlay]) {
|
||||
if (this._pastOverlays.has(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)) {
|
||||
try {
|
||||
const overlayStyle = await this.get(overlayInfo);
|
||||
const overlayStyle = await this.getOverlayStyle(overlay, overlayInfo);
|
||||
for (const layer of overlayStyle.layers ?? []) {
|
||||
if (map_.getLayer(layer.id)) {
|
||||
map_.removeLayer(layer.id);
|
||||
@@ -165,9 +170,10 @@ export class StyleManager {
|
||||
this._pastOverlays.delete(overlay);
|
||||
}
|
||||
} else {
|
||||
const overlayInfo = custom[overlay]?.value ?? overlays[overlay];
|
||||
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];
|
||||
|
||||
for (const sourceId in overlayStyle.sources) {
|
||||
@@ -177,21 +183,24 @@ export class StyleManager {
|
||||
}
|
||||
|
||||
for (const layer of overlayStyle.layers ?? []) {
|
||||
if (!map_.getLayer(layer.id)) {
|
||||
if (opacity !== undefined) {
|
||||
if (layer.type === 'raster') {
|
||||
if (!layer.paint) {
|
||||
layer.paint = {};
|
||||
const paint =
|
||||
opacity === undefined ? {} : getOpacityPaint(layer, opacity);
|
||||
if (map_.getLayer(layer.id)) {
|
||||
for (const property in paint) {
|
||||
map_.setPaintProperty(
|
||||
layer.id,
|
||||
property as never,
|
||||
paint[property] as never
|
||||
);
|
||||
}
|
||||
layer.paint['raster-opacity'] = opacity;
|
||||
} else if (layer.type === 'hillshade') {
|
||||
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);
|
||||
@@ -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> {
|
||||
if (typeof styleInfo === 'string') {
|
||||
const styleUrl = styleInfo as string;
|
||||
|
||||
@@ -218,6 +218,7 @@
|
||||
</span>
|
||||
{:else}
|
||||
<TimePicker
|
||||
id="speed"
|
||||
bind:value={speed}
|
||||
showHours={false}
|
||||
disabled={!canUpdate}
|
||||
@@ -243,6 +244,7 @@
|
||||
{i18n._('toolbar.time.total_time')}
|
||||
</Label>
|
||||
<TimePicker
|
||||
id="duration"
|
||||
bind:value={movingTime}
|
||||
disabled={!canUpdate}
|
||||
onChange={() => {
|
||||
|
||||
@@ -633,6 +633,11 @@ export class RoutingControls {
|
||||
|
||||
const anchor = this.anchors[e.features![0].properties.anchorIndex];
|
||||
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;
|
||||
}
|
||||
if (e.originalEvent.shiftKey) {
|
||||
|
||||
@@ -45,6 +45,19 @@
|
||||
|
||||
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() {
|
||||
if ($selectedWaypoint) {
|
||||
selectedWaypoint.reset();
|
||||
@@ -78,6 +91,9 @@
|
||||
});
|
||||
|
||||
async function createOrUpdateWaypoint() {
|
||||
if (!coordinatesValid) {
|
||||
return;
|
||||
}
|
||||
if (typeof latitude === 'string') {
|
||||
latitude = parseFloat(latitude);
|
||||
}
|
||||
@@ -128,7 +144,8 @@
|
||||
marker = null;
|
||||
}
|
||||
} 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) {
|
||||
marker.setLngLat([longitude, latitude]).getElement().innerHTML =
|
||||
getSvgForSymbol(symbolKey);
|
||||
@@ -248,6 +265,7 @@
|
||||
min={-90}
|
||||
max={90}
|
||||
class="text-xs h-8"
|
||||
aria-invalid={!latitudeValid}
|
||||
disabled={!canCreate && !$selectedWaypoint}
|
||||
/>
|
||||
</div>
|
||||
@@ -261,6 +279,7 @@
|
||||
min={-180}
|
||||
max={180}
|
||||
class="text-xs h-8"
|
||||
aria-invalid={!longitudeValid}
|
||||
disabled={!canCreate && !$selectedWaypoint}
|
||||
/>
|
||||
</div>
|
||||
@@ -269,7 +288,7 @@
|
||||
<div class="flex flex-row gap-1.5 items-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!canCreate && !$selectedWaypoint}
|
||||
disabled={(!canCreate && !$selectedWaypoint) || !coordinatesValid}
|
||||
class="grow shrink h-fit min-h-8 whitespace-normal py-1"
|
||||
onclick={createOrUpdateWaypoint}
|
||||
>
|
||||
|
||||
@@ -1,63 +1,40 @@
|
||||
<script lang="ts">
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
|
||||
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 lang="ts" module>
|
||||
/** Shown by an empty field: real text rather than a placeholder, so that it can be selected. */
|
||||
export const EMPTY_FIELD = '--';
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<Input
|
||||
{id}
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
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"
|
||||
step={1}
|
||||
bind:value
|
||||
{disabled}
|
||||
{oninput}
|
||||
{onchange}
|
||||
{onkeypress}
|
||||
onfocusin={(e) => {
|
||||
let input = document.activeElement;
|
||||
if (input instanceof HTMLInputElement) {
|
||||
input.select();
|
||||
}
|
||||
onfocusin(e);
|
||||
}}
|
||||
class="w-[22px] {className ?? ''}"
|
||||
inputmode="numeric"
|
||||
autocomplete="off"
|
||||
value={value || EMPTY_FIELD}
|
||||
class={cn(
|
||||
'absolute inset-0 w-full bg-transparent text-center outline-none',
|
||||
padding,
|
||||
className
|
||||
)}
|
||||
onfocus={(e) => e.currentTarget.select()}
|
||||
{...restProps}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</span>
|
||||
@@ -1,174 +1,176 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import TimeComponentInput from './TimeComponentInput.svelte';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import TimeComponentInput, { EMPTY_FIELD } from './TimeComponentInput.svelte';
|
||||
|
||||
let {
|
||||
id,
|
||||
showHours = true,
|
||||
value = $bindable(),
|
||||
disabled = false,
|
||||
onChange = () => {},
|
||||
}: {
|
||||
/** Set on the first field, so that a label can target the picker. */
|
||||
id?: string;
|
||||
showHours?: boolean;
|
||||
value?: number;
|
||||
disabled?: boolean;
|
||||
onChange?: () => void;
|
||||
} = $props();
|
||||
|
||||
let hours: string | number = $state('--');
|
||||
let minutes: string | number = $state('--');
|
||||
let seconds: string | number = $state('--');
|
||||
let hours = $state('');
|
||||
let minutes = $state('');
|
||||
let seconds = $state('');
|
||||
|
||||
function maybeParseInt(value: string | number): number {
|
||||
if (value === '--' || value === '') {
|
||||
return 0;
|
||||
}
|
||||
return typeof value === 'string' ? parseInt(value) : value;
|
||||
let hoursInput: HTMLInputElement | null = $state(null);
|
||||
let minutesInput: HTMLInputElement | null = $state(null);
|
||||
let secondsInput: HTMLInputElement | null = $state(null);
|
||||
|
||||
function parseField(field: string): number {
|
||||
return field === '' ? 0 : parseInt(field, 10);
|
||||
}
|
||||
|
||||
function computeValue(): number {
|
||||
return Math.max(
|
||||
maybeParseInt(hours) * 3600 + maybeParseInt(minutes) * 60 + maybeParseInt(seconds),
|
||||
1
|
||||
);
|
||||
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);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const val = computeValue();
|
||||
untrack(() => {
|
||||
value = val;
|
||||
});
|
||||
});
|
||||
function pad(field: number): string {
|
||||
return field.toString().padStart(2, '0');
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (value === undefined) {
|
||||
untrack(() => {
|
||||
hours = '--';
|
||||
minutes = '--';
|
||||
seconds = '--';
|
||||
});
|
||||
} else {
|
||||
untrack(() => {
|
||||
if (value != computeValue()) {
|
||||
let rounded = Math.max(Math.round(value!), 1);
|
||||
function showValue(newValue: number | undefined) {
|
||||
if (!Number.isFinite(newValue)) {
|
||||
hours = minutes = seconds = '';
|
||||
return;
|
||||
}
|
||||
const total = Math.max(Math.round(newValue!), 1);
|
||||
const totalMinutes = Math.floor(total / 60);
|
||||
if (showHours) {
|
||||
hours = Math.floor(rounded / 3600);
|
||||
minutes = Math.floor((rounded % 3600) / 60)
|
||||
.toString()
|
||||
.padStart(2, '0');
|
||||
hours = Math.floor(total / 3600).toString();
|
||||
minutes = pad(totalMinutes % 60);
|
||||
} else {
|
||||
minutes = Math.floor(rounded / 60).toString();
|
||||
minutes = totalMinutes.toString();
|
||||
}
|
||||
seconds = (rounded % 60).toString().padStart(2, '0');
|
||||
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(() => {
|
||||
const newValue = value;
|
||||
void showHours;
|
||||
untrack(() => {
|
||||
if (newValue !== fieldsToSeconds()) {
|
||||
showValue(newValue);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let countKeyPress = 0;
|
||||
function onKeyPress(e: KeyboardEvent) {
|
||||
const target = e.target as HTMLInputElement | null;
|
||||
if (target && ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'].includes(e.key)) {
|
||||
countKeyPress++;
|
||||
if (countKeyPress === 2) {
|
||||
const nextInput =
|
||||
target.id === 'hours'
|
||||
? (container.querySelector('#minutes') as HTMLInputElement)
|
||||
: target.id === 'minutes'
|
||||
? (container.querySelector('#seconds') as HTMLInputElement)
|
||||
: null;
|
||||
if (nextInput) {
|
||||
nextInput.focus();
|
||||
/** A field without an upper bound: hours, or minutes when there are no hours. */
|
||||
function normalizeUnbounded(input: string): string {
|
||||
const digits = input.replace(/\D/g, '').slice(-6);
|
||||
return digits === '' ? '' : parseInt(digits, 10).toString();
|
||||
}
|
||||
|
||||
/** Minutes or seconds: new digits shift in from the right, so "12" then "3" gives "23". */
|
||||
function normalizeSexagesimal(input: string): string {
|
||||
const digits = input.replace(/\D/g, '').slice(-2);
|
||||
return digits === '' ? '' : pad(Math.min(parseInt(digits, 10), 59));
|
||||
}
|
||||
|
||||
let digitsTyped = 0;
|
||||
|
||||
function handleInput(
|
||||
e: Event & { currentTarget: HTMLInputElement },
|
||||
normalize: (input: string) => string,
|
||||
setField: (field: string) => void,
|
||||
nextInput: HTMLInputElement | null
|
||||
) {
|
||||
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>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
bind:this={container}
|
||||
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
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: ''}"
|
||||
data-slot="time-picker"
|
||||
class={cn(
|
||||
'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}
|
||||
<TimeComponentInput
|
||||
id="hours"
|
||||
bind:value={hours}
|
||||
{id}
|
||||
bind:ref={hoursInput}
|
||||
value={hours}
|
||||
{disabled}
|
||||
class="w-[30px]"
|
||||
oninput={() => {
|
||||
if (typeof hours === 'string') {
|
||||
hours = parseInt(hours);
|
||||
}
|
||||
if (hours < 0) {
|
||||
hours = 0;
|
||||
}
|
||||
onChange();
|
||||
}}
|
||||
onkeypress={onKeyPress}
|
||||
onfocusin={() => {
|
||||
countKeyPress = 0;
|
||||
}}
|
||||
oninput={(e) =>
|
||||
handleInput(e, normalizeUnbounded, (field) => (hours = field), minutesInput)}
|
||||
/>
|
||||
<span class="text-sm">:</span>
|
||||
<span>:</span>
|
||||
{/if}
|
||||
<TimeComponentInput
|
||||
id="minutes"
|
||||
bind:value={minutes}
|
||||
id={showHours ? undefined : id}
|
||||
bind:ref={minutesInput}
|
||||
value={minutes}
|
||||
{disabled}
|
||||
oninput={() => {
|
||||
if (typeof minutes === 'string') {
|
||||
minutes = parseInt(minutes);
|
||||
}
|
||||
if (showHours && minutes > 59) {
|
||||
minutes = 59;
|
||||
} else if (minutes < 0) {
|
||||
minutes = 0;
|
||||
}
|
||||
minutes = minutes.toString().padStart(showHours ? 2 : 1, '0');
|
||||
onChange();
|
||||
}}
|
||||
onkeypress={onKeyPress}
|
||||
onfocusin={() => {
|
||||
countKeyPress = 0;
|
||||
}}
|
||||
oninput={(e) =>
|
||||
handleInput(
|
||||
e,
|
||||
showHours ? normalizeSexagesimal : normalizeUnbounded,
|
||||
(field) => (minutes = field),
|
||||
secondsInput
|
||||
)}
|
||||
/>
|
||||
<span class="text-sm">:</span>
|
||||
<span>:</span>
|
||||
<TimeComponentInput
|
||||
id="seconds"
|
||||
bind:value={seconds}
|
||||
bind:ref={secondsInput}
|
||||
value={seconds}
|
||||
{disabled}
|
||||
oninput={() => {
|
||||
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;
|
||||
}}
|
||||
oninput={(e) => handleInput(e, normalizeSexagesimal, (field) => (seconds = field), null)}
|
||||
/>
|
||||
</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>
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Create a new route in the selected file.
|
||||
Crear una nueva ruta en el archivo seleccionado.
|
||||
|
||||
<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
|
||||
|
||||
Convert the selected routes to tracks, or the selected tracks or segments to routes.
|
||||
Since a route has a single segment, a track with multiple segments is converted into one route per segment.
|
||||
Convertir las rutas seleccionadas a recorridos o los recorridos o segmentos seleccionados a rutas.
|
||||
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
|
||||
|
||||
|
||||
@@ -159,8 +159,6 @@ export async function createTauriBackend(): Promise<Backend> {
|
||||
(await invoke('file_statistics', { fileId })) ?? undefined,
|
||||
waypoint: async (fileId, waypointId) =>
|
||||
(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) => {
|
||||
const file = await optionalArray('export_file', Uint8Array, {
|
||||
fileId,
|
||||
@@ -245,7 +243,7 @@ export async function createTauriBackend(): Promise<Backend> {
|
||||
|
||||
importsLegacyData: false,
|
||||
open_storage: () => invoke<StorageOpened>('open_storage'),
|
||||
flush_storage: () => invoke('flush_storage'),
|
||||
flush_storage: () => invoke('flush_storage').then(() => true),
|
||||
sync_storage: () => invoke('sync_storage'),
|
||||
set_setting: (key, value) => invoke('set_setting', { key, value }),
|
||||
delete_setting: (key) => invoke('delete_setting', { key }),
|
||||
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
Selection,
|
||||
SelectionPoint,
|
||||
StorageOpened,
|
||||
TrackpointDetails,
|
||||
WaypointDetails,
|
||||
} from 'gpx-wasm';
|
||||
import type { ExportOptions, MergeType, SelectMode, SplitType } from '../types';
|
||||
@@ -266,11 +265,6 @@ export interface Backend {
|
||||
file_structure(fileId: string): Promise<FileStructure | undefined>;
|
||||
file_statistics(fileId: string): Promise<GlobalStatistics | 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. */
|
||||
export_file(
|
||||
fileId: string,
|
||||
@@ -340,8 +334,8 @@ export interface Backend {
|
||||
onBehind: () => void,
|
||||
onNewer: () => void
|
||||
): Promise<StorageOpened>;
|
||||
/** Resolves when what was changed has been written to the storage. */
|
||||
flush_storage(): Promise<void>;
|
||||
/** Resolves when the writes of what was changed are done, to whether they went well. */
|
||||
flush_storage(): Promise<boolean>;
|
||||
/** Reads the files again if another window wrote them since, and tells what changed. */
|
||||
sync_storage(): Promise<Outcome>;
|
||||
set_setting(key: string, json: string): Promise<void>;
|
||||
|
||||
@@ -123,7 +123,6 @@ export async function createWasmBackend(): Promise<Backend> {
|
||||
file_structure: lift(w.file_structure),
|
||||
file_statistics: lift(w.file_statistics),
|
||||
waypoint: lift(w.waypoint),
|
||||
trackpoint: lift(w.trackpoint),
|
||||
// a copy out of the memory of the engine, so a plain ArrayBuffer
|
||||
export_file: lift(
|
||||
(...args: Parameters<Backend['export_file']>) =>
|
||||
|
||||
@@ -259,7 +259,7 @@ describe('statistics', () => {
|
||||
assert.equal(get(engine.statistics).hr, undefined);
|
||||
|
||||
const request = engine.requestStatistics();
|
||||
await request.set(['hr', 'anchors', 'slopeSegment']);
|
||||
await request.set(['hr', 'anchors', 'slopeSegment', 'segments']);
|
||||
const statistics = get(engine.statistics);
|
||||
assert.equal(statistics.slopeSegments!.starts[0], 0);
|
||||
assert.equal(
|
||||
@@ -270,6 +270,8 @@ describe('statistics', () => {
|
||||
assert.ok(statistics.hr!.some((hr) => !Number.isNaN(hr)));
|
||||
assert.equal(statistics.cad, undefined);
|
||||
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);
|
||||
|
||||
// they are read again after a change, without being asked again
|
||||
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
type FolderEntry,
|
||||
type UnsavedChoice,
|
||||
type WorkspaceState,
|
||||
type TrackpointDetails,
|
||||
type WaypointDetails,
|
||||
} from './types';
|
||||
|
||||
@@ -881,15 +880,6 @@ class Engine {
|
||||
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
|
||||
* trackpoints to keep.
|
||||
@@ -935,17 +925,6 @@ class Engine {
|
||||
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
|
||||
* them by default (see `StatisticsMetric`). The metrics asked for by all the consumers are
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
hasSelectionWithin,
|
||||
isCovered,
|
||||
isInClipboard,
|
||||
isEmpty,
|
||||
isSelected,
|
||||
isSelectionHidden,
|
||||
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', () => {
|
||||
assert.deepEqual(selectedFileIds(empty), []);
|
||||
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). */
|
||||
export function selectedFileIds(selection: Selection): string[] {
|
||||
switch (selection.type) {
|
||||
|
||||
@@ -143,6 +143,14 @@ export class StatisticsReader {
|
||||
};
|
||||
break;
|
||||
}
|
||||
case 'segments': {
|
||||
const [starts, ids] = await Promise.all([
|
||||
backend.segment_starts(),
|
||||
backend.segment_ids(),
|
||||
]);
|
||||
statistics.segments = { starts, ids };
|
||||
break;
|
||||
}
|
||||
case 'highway':
|
||||
[statistics.highway, statistics.sacScale, statistics.mtbScale] =
|
||||
await Promise.all([
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
@@ -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 type { Outcome, StorageOpened } from 'gpx-wasm';
|
||||
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. */
|
||||
visibility: Map<string, Visibility>;
|
||||
/** 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. */
|
||||
outdated(): void;
|
||||
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.
|
||||
*/
|
||||
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;
|
||||
let opened: StorageOpened;
|
||||
const tabs = followOtherTabs(host);
|
||||
@@ -81,47 +84,68 @@ export async function openStoredFiles(host: StorageHost): Promise<Record<string,
|
||||
parseVisibility(settings[VISIBILITY_KEY]).forEach((visibility, fileId) =>
|
||||
host.visibility.set(fileId, visibility)
|
||||
);
|
||||
host.sync(opened.outcome);
|
||||
await host.sync(opened.outcome);
|
||||
|
||||
if (
|
||||
backend.importsLegacyData &&
|
||||
settings[LEGACY_IMPORTED_KEY] === undefined &&
|
||||
!host.hasFiles()
|
||||
) {
|
||||
settings = { ...settings, ...(await importLegacyData(host)) };
|
||||
await backend.flush_storage();
|
||||
if (backend.importsLegacyData && settings[LEGACY_IMPORTED_KEY] === undefined) {
|
||||
settings = { ...settings, ...(await importLegacyData(host, settings, readLegacy)) };
|
||||
}
|
||||
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 imported: Record<string, string> = {};
|
||||
if (!host.hasFiles()) {
|
||||
try {
|
||||
const legacy = await readLegacyData();
|
||||
const legacy = await readLegacy();
|
||||
if (legacy) {
|
||||
const encoder = new TextEncoder();
|
||||
if (legacy.files.length > 0) {
|
||||
await host.loadFiles(
|
||||
legacy.files.map((file) => ({
|
||||
data: encoder.encode(file.gpx),
|
||||
name: file.name,
|
||||
}))
|
||||
);
|
||||
await host.deselect();
|
||||
}
|
||||
// what was changed since a previous try is more recent
|
||||
for (const [key, value] of Object.entries(legacy.settings)) {
|
||||
if (stored[key] === undefined) {
|
||||
await backend.set_setting(key, value);
|
||||
imported[key] = value;
|
||||
}
|
||||
}
|
||||
if (legacy.files.length > 0 && !(await importFiles(host, legacy))) {
|
||||
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');
|
||||
imported[LEGACY_IMPORTED_KEY] = 'true';
|
||||
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 {
|
||||
categoryIntervals,
|
||||
categoryValue,
|
||||
categoryValues,
|
||||
intervalAt,
|
||||
type CategoryIntervals,
|
||||
} from '$lib/engine/trackpoint-categories';
|
||||
|
||||
describe('categoryValues', () => {
|
||||
// asphalt, asphalt, unknown, gravel, gravel, gravel, unknown
|
||||
const intervals: CategoryIntervals = {
|
||||
starts: new Uint32Array([0, 2, 3, 6]),
|
||||
values: new Uint8Array([1, 0, 2, 0]),
|
||||
names: ['asphalt', 'gravel'],
|
||||
};
|
||||
|
||||
it('is the name of the value of the interval of each trackpoint', () => {
|
||||
assert.deepEqual(categoryValues(intervals, 7), [
|
||||
'asphalt',
|
||||
'asphalt',
|
||||
undefined,
|
||||
'gravel',
|
||||
'gravel',
|
||||
'gravel',
|
||||
undefined,
|
||||
]);
|
||||
});
|
||||
|
||||
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]);
|
||||
});
|
||||
});
|
||||
/** The value of each of the `length` trackpoints, to compare with what is expected. */
|
||||
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;
|
||||
}
|
||||
|
||||
describe('categoryIntervals', () => {
|
||||
it('groups the trackpoints that share a value', () => {
|
||||
|
||||
@@ -11,31 +11,6 @@ export type CategoryIntervals = {
|
||||
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. */
|
||||
export function intervalAt(starts: Uint32Array, index: number): number {
|
||||
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`
|
||||
* 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.
|
||||
*/
|
||||
export function categoryIntervals(values: readonly (string | undefined)[]): CategoryIntervals {
|
||||
|
||||
@@ -9,7 +9,6 @@ export type { FileStatus, FolderEntry, UnsavedChoice, WorkspaceState } from './b
|
||||
export type {
|
||||
Clipboard,
|
||||
WaypointDetails,
|
||||
TrackpointDetails,
|
||||
MoveTarget,
|
||||
GlobalStatistics,
|
||||
SelectionPoint,
|
||||
@@ -155,7 +154,9 @@ export type StatisticsMetric =
|
||||
/** `highway`, `sacScale` and `mtbScale`. */
|
||||
| 'highway'
|
||||
/** `anchors`. */
|
||||
| 'anchors';
|
||||
| 'anchors'
|
||||
/** `segments`. */
|
||||
| 'segments';
|
||||
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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. */
|
||||
mtbScale?: CategoryIntervals;
|
||||
anchors?: SelectionAnchors;
|
||||
segments?: SelectionSegments;
|
||||
|
||||
/**
|
||||
* Global statistics of the trackpoints from `start` to `end` (both included), for example
|
||||
|
||||
@@ -1,32 +1,6 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { closestPointIndex, 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);
|
||||
});
|
||||
});
|
||||
import { closestPointIndexIn } from '$lib/geo/closest-point';
|
||||
|
||||
describe('closestPointIndexIn', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
assert.equal(closestPointIndexIn(lng, lat, 3, 3, { lng: 0, lat: 0 }), undefined);
|
||||
assert.equal(closestPointIndexIn(lng, lat, 4, 2, { lng: 0, lat: 0 }), undefined);
|
||||
|
||||
@@ -1,25 +1,10 @@
|
||||
const metersPerDegree = 111320;
|
||||
|
||||
/**
|
||||
* Index of the point of a polyline (flat `[lng, lat, ...]` coordinates) that is the closest to
|
||||
* `point`, among the two ends of the segment of the polyline that passes the closest to it.
|
||||
* `undefined` for an empty polyline. 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.
|
||||
* Index of the point, among the points `from` (included) to `to` (excluded) of a polyline given as
|
||||
* the arrays of its longitudes and latitudes, that is the closest to `point`: one of the two ends
|
||||
* 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 closestPointIndexIn(
|
||||
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. */
|
||||
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));
|
||||
@@ -141,11 +141,13 @@ describe('layer trees', () => {
|
||||
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(
|
||||
{
|
||||
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 },
|
||||
extension: { 'extension-Name/y': true },
|
||||
custom: { 'custom-2': false },
|
||||
},
|
||||
allowed
|
||||
);
|
||||
@@ -153,10 +155,10 @@ describe('layer trees', () => {
|
||||
a: true,
|
||||
b: false,
|
||||
'custom-1': true,
|
||||
'extension-x': false,
|
||||
});
|
||||
assert.equal('old' in filtered, true);
|
||||
assert.deepEqual(filtered.old, {});
|
||||
assert.equal('old' in filtered, false);
|
||||
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', () => {
|
||||
|
||||
@@ -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
|
||||
* 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 {
|
||||
const tree = isRecord(t) ? t : {};
|
||||
@@ -214,11 +216,14 @@ export function filterLayerTree(t: unknown, allowed: LayerTreeType | undefined):
|
||||
Object.entries(tree).forEach(([key, value]) => {
|
||||
if (!Object.hasOwn(filtered, key)) {
|
||||
if (typeof value === 'boolean') {
|
||||
if (key.startsWith('custom-') || key.startsWith('extension-')) {
|
||||
if (key.startsWith('custom-')) {
|
||||
filtered[key] = 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="1"&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', () => {
|
||||
const gpx = legacyFileToGPX({
|
||||
metadata: { name: '', desc: null, time: 'not a date' },
|
||||
|
||||
@@ -84,7 +84,11 @@ function timeElement(value: unknown): string {
|
||||
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 {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(link).join('');
|
||||
}
|
||||
const href = value?.attributes?.href;
|
||||
return href
|
||||
? `<link href="${escape(href).replaceAll('"', '"')}">${element('text', value.text)}</link>`
|
||||
|
||||
@@ -598,5 +598,12 @@
|
||||
"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.",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -598,5 +598,12 @@
|
||||
"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.",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -598,5 +598,12 @@
|
||||
"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.",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -598,5 +598,12 @@
|
||||
"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.",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -598,5 +598,12 @@
|
||||
"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.",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -598,5 +598,12 @@
|
||||
"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.",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -598,5 +598,12 @@
|
||||
"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.",
|
||||
"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
@@ -9,42 +9,42 @@
|
||||
},
|
||||
"menu": {
|
||||
"new_file": "Nuevo archivo",
|
||||
"new_route_file": "New route file",
|
||||
"new_route_file": "Nuevo archivo de ruta",
|
||||
"new_track": "Nuevo recorrido",
|
||||
"new_route": "New route",
|
||||
"convert_to_route": "Convert to route",
|
||||
"convert_to_track": "Convert to track",
|
||||
"convert_to_routes": "Convert to route file",
|
||||
"convert_to_tracks": "Convert to track file",
|
||||
"new_route": "Nueva ruta",
|
||||
"convert_to_route": "Convertir a ruta",
|
||||
"convert_to_track": "Convertir a recorrido",
|
||||
"convert_to_routes": "Convertir a archivo de ruta",
|
||||
"convert_to_tracks": "Convertir a archivo de recorrido",
|
||||
"new_segment": "Nuevo segmento",
|
||||
"open": "Abrir...",
|
||||
"open_folder": "Open folder...",
|
||||
"explorer": "Explorer",
|
||||
"open_folder": "Abrir carpeta...",
|
||||
"explorer": "Explorador",
|
||||
"explorer_menu": {
|
||||
"open": "Open",
|
||||
"new_file": "New file...",
|
||||
"new_route_file": "New route file...",
|
||||
"new_folder": "New folder...",
|
||||
"rename": "Rename...",
|
||||
"reveal_finder": "Reveal in Finder",
|
||||
"reveal": "Reveal in file explorer",
|
||||
"delete_failed": "The files could not be moved to the trash."
|
||||
"open": "Abrir",
|
||||
"new_file": "Nuevo archivo...",
|
||||
"new_route_file": "Nuevo archivo de ruta...",
|
||||
"new_folder": "Nueva carpeta...",
|
||||
"rename": "Renombrar...",
|
||||
"reveal_finder": "Mostrar en el buscador",
|
||||
"reveal": "Mostrar en explorador de archivos",
|
||||
"delete_failed": "No se pudieron mover los archivos a la papelera."
|
||||
},
|
||||
"workspace": "Workspace",
|
||||
"save": "Save",
|
||||
"save_all": "Save all",
|
||||
"save_as": "Save as...",
|
||||
"workspace": "Espacio de trabajo",
|
||||
"save": "Guardar",
|
||||
"save_all": "Guardar todo",
|
||||
"save_as": "Guardar como…",
|
||||
"close": "Cerrar",
|
||||
"close_all": "Cerrar todo",
|
||||
"close_folder": "Close folder",
|
||||
"reload": "Reload from disk",
|
||||
"discard": "Discard",
|
||||
"cancel": "Cancel",
|
||||
"overwrite": "Overwrite",
|
||||
"unsaved_title": "Unsaved changes",
|
||||
"unsaved_description": "These files have changes that are not saved:",
|
||||
"overwrite_title": "Overwrite changes made elsewhere?",
|
||||
"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?",
|
||||
"close_folder": "Cerrar carpeta",
|
||||
"reload": "Recargar desde el disco",
|
||||
"discard": "Descartar",
|
||||
"cancel": "Cancelar",
|
||||
"overwrite": "Sobreescribir",
|
||||
"unsaved_title": "Cambios sin guardar",
|
||||
"unsaved_description": "Estos archivos tienen cambios que no están guardados:",
|
||||
"overwrite_title": "¿Sobrescribir cambios realizados en otro lugar?",
|
||||
"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",
|
||||
"copy": "Copiar",
|
||||
"paste": "Pegar",
|
||||
@@ -102,9 +102,9 @@
|
||||
"name": "Nombre",
|
||||
"description": "Descripción",
|
||||
"save": "Guardar",
|
||||
"rename_file": "Rename the file on disk",
|
||||
"name_taken": "A file with this name already exists in the folder.",
|
||||
"rename_failed": "The file could not be renamed."
|
||||
"rename_file": "Renombrar el archivo en disco",
|
||||
"name_taken": "Ya existe un archivo con este nombre en la carpeta.",
|
||||
"rename_failed": "No se pudo renombrar el archivo."
|
||||
},
|
||||
"style": {
|
||||
"button": "Apariencia...",
|
||||
@@ -468,8 +468,8 @@
|
||||
"gpx": {
|
||||
"file": "Archivo",
|
||||
"files": "Archivos",
|
||||
"route": "Route",
|
||||
"routes": "Routes",
|
||||
"route": "Ruta",
|
||||
"routes": "Rutas",
|
||||
"track": "Track",
|
||||
"tracks": "Tracks",
|
||||
"segment": "Segmento",
|
||||
@@ -595,8 +595,15 @@
|
||||
"enable_webgl2": "Aprenda cómo activar WebGL 2 en su navegador",
|
||||
"page_not_found": "página no encontrada",
|
||||
"outdated": {
|
||||
"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.",
|
||||
"reload": "Reload"
|
||||
"title": "Esta página está desactualizada",
|
||||
"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": "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
@@ -9,42 +9,42 @@
|
||||
},
|
||||
"menu": {
|
||||
"new_file": "Fitxategi berria",
|
||||
"new_route_file": "New route file",
|
||||
"new_route_file": "Ibilbide fitxategi berria",
|
||||
"new_track": "Arrasto berria",
|
||||
"new_route": "New route",
|
||||
"convert_to_route": "Convert to route",
|
||||
"convert_to_track": "Convert to track",
|
||||
"convert_to_routes": "Convert to route file",
|
||||
"convert_to_tracks": "Convert to track file",
|
||||
"new_route": "Ibilbide berria",
|
||||
"convert_to_route": "Bihurtu ibilbide",
|
||||
"convert_to_track": "Bihurtu arrasto",
|
||||
"convert_to_routes": "Bihurtu ibilbide fitxategi",
|
||||
"convert_to_tracks": "Bihurtu arrasto fitxategi",
|
||||
"new_segment": "Segmentu berria",
|
||||
"open": "Ireki...",
|
||||
"open_folder": "Open folder...",
|
||||
"explorer": "Explorer",
|
||||
"open_folder": "Ireki karpeta...",
|
||||
"explorer": "Arakatzailea",
|
||||
"explorer_menu": {
|
||||
"open": "Open",
|
||||
"new_file": "New file...",
|
||||
"new_route_file": "New route file...",
|
||||
"new_folder": "New folder...",
|
||||
"rename": "Rename...",
|
||||
"reveal_finder": "Reveal in Finder",
|
||||
"reveal": "Reveal in file explorer",
|
||||
"delete_failed": "The files could not be moved to the trash."
|
||||
"open": "Ireki",
|
||||
"new_file": "Fitxategi berria...",
|
||||
"new_route_file": "Ibilbide fitxategi berria...",
|
||||
"new_folder": "Karpeta berria...",
|
||||
"rename": "Aldatu izena...",
|
||||
"reveal_finder": "Erakutsi Finder-en",
|
||||
"reveal": "Erakutsi fitxategi-arakatzailean",
|
||||
"delete_failed": "Fitxategiak ezin dira zakarrontzira mugitu."
|
||||
},
|
||||
"workspace": "Workspace",
|
||||
"save": "Save",
|
||||
"save_all": "Save all",
|
||||
"save_as": "Save as...",
|
||||
"workspace": "Laneko area",
|
||||
"save": "Gorde",
|
||||
"save_all": "Gorde denak",
|
||||
"save_as": "Gorde honela...",
|
||||
"close": "Itxi",
|
||||
"close_all": "Itxi denak",
|
||||
"close_folder": "Close folder",
|
||||
"reload": "Reload from disk",
|
||||
"discard": "Discard",
|
||||
"cancel": "Cancel",
|
||||
"overwrite": "Overwrite",
|
||||
"unsaved_title": "Unsaved changes",
|
||||
"unsaved_description": "These files have changes that are not saved:",
|
||||
"overwrite_title": "Overwrite changes made elsewhere?",
|
||||
"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?",
|
||||
"close_folder": "Itxi karpeta",
|
||||
"reload": "Birkargatu diskotik",
|
||||
"discard": "Baztertu",
|
||||
"cancel": "Utzi",
|
||||
"overwrite": "Gainidatzi",
|
||||
"unsaved_title": "Gorde gabeko aldaketak",
|
||||
"unsaved_description": "Fitxategi hauek gorde gabeko aldaketak dituzte:",
|
||||
"overwrite_title": "Egindako aldaketak gainidatzi nahi dituzu?",
|
||||
"overwrite_description": "Fitxategi hauek aldaketak dituzte, eta diskoan ere aldatu dira. Gainidatzi diskoan dagoena edo berriro kargatu eta zure aldaketak galdu?",
|
||||
"duplicate": "Kopia egin",
|
||||
"copy": "Kopiatu",
|
||||
"paste": "Itsatsi",
|
||||
@@ -102,9 +102,9 @@
|
||||
"name": "Izena",
|
||||
"description": "Deskribapena",
|
||||
"save": "Gorde",
|
||||
"rename_file": "Rename the file on disk",
|
||||
"name_taken": "A file with this name already exists in the folder.",
|
||||
"rename_failed": "The file could not be renamed."
|
||||
"rename_file": "Berrizendatu diskoko fitxategia",
|
||||
"name_taken": "Izen hau duen fitxategi bat dagoeneko badago karpetan.",
|
||||
"rename_failed": "Fitxategia ezin da berrizendatu."
|
||||
},
|
||||
"style": {
|
||||
"button": "Itxura...",
|
||||
@@ -468,8 +468,8 @@
|
||||
"gpx": {
|
||||
"file": "Fitxategia",
|
||||
"files": "Fitxategiak",
|
||||
"route": "Route",
|
||||
"routes": "Routes",
|
||||
"route": "Arrastoa",
|
||||
"routes": "Ibilbideak",
|
||||
"track": "Arrastoa",
|
||||
"tracks": "Arrastoak",
|
||||
"segment": "Sementua",
|
||||
@@ -595,8 +595,15 @@
|
||||
"enable_webgl2": "Ikasi zure nabigatzailean WebGL 2 aktibatzen",
|
||||
"page_not_found": "orrialdea ez da aurkitu",
|
||||
"outdated": {
|
||||
"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.",
|
||||
"reload": "Reload"
|
||||
"title": "Orrialde hau zaharkituta dago",
|
||||
"description": "Zure fitxategiak gpx.studio-ren bertsio berriago batek gorde ditu, orrialde honek irakurri ezin duena. Birkargatu orria azken bertsioa lortzeko.",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -598,5 +598,12 @@
|
||||
"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.",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -598,5 +598,12 @@
|
||||
"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.",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -598,5 +598,12 @@
|
||||
"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.",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -598,5 +598,12 @@
|
||||
"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.",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -598,5 +598,12 @@
|
||||
"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.",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -598,5 +598,12 @@
|
||||
"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.",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -598,5 +598,12 @@
|
||||
"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.",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
},
|
||||
"menu": {
|
||||
"new_file": "Naujas failas",
|
||||
"new_route_file": "New route file",
|
||||
"new_route_file": "Naujas maršruto failas",
|
||||
"new_track": "Nauja trasa",
|
||||
"new_route": "New route",
|
||||
"convert_to_route": "Convert to route",
|
||||
@@ -146,7 +146,7 @@
|
||||
"racing_bike": "Kelių dviračiu",
|
||||
"gravel_bike": "Bekelės dviračiu",
|
||||
"mountain_bike": "Kalnų dviračiu",
|
||||
"foot": "Bėgimas / ėjimas",
|
||||
"foot": "Bėgimas / žygiavimas",
|
||||
"motorcycle": "Motociklu",
|
||||
"water": "Vandeniu",
|
||||
"railway": "Geležinkeliu"
|
||||
@@ -181,7 +181,7 @@
|
||||
"unknown": "Unknown",
|
||||
"motorway": "Highway",
|
||||
"motorway_link": "Highway link",
|
||||
"trunk": "Primary road",
|
||||
"trunk": "Pagrindinis kelias",
|
||||
"trunk_link": "Primary road link",
|
||||
"primary": "Primary road",
|
||||
"primary_link": "Primary road link",
|
||||
@@ -552,7 +552,7 @@
|
||||
},
|
||||
"docs": {
|
||||
"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",
|
||||
"search": {
|
||||
"search": "Search",
|
||||
@@ -598,5 +598,12 @@
|
||||
"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.",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -598,5 +598,12 @@
|
||||
"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.",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -598,5 +598,12 @@
|
||||
"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.",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -598,5 +598,12 @@
|
||||
"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.",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -598,5 +598,12 @@
|
||||
"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.",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -598,5 +598,12 @@
|
||||
"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.",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -598,5 +598,12 @@
|
||||
"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.",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -598,5 +598,12 @@
|
||||
"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.",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -598,5 +598,12 @@
|
||||
"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.",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -598,5 +598,12 @@
|
||||
"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.",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -598,5 +598,12 @@
|
||||
"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.",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -598,5 +598,12 @@
|
||||
"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.",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -598,5 +598,12 @@
|
||||
"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.",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -598,5 +598,12 @@
|
||||
"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.",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -598,5 +598,12 @@
|
||||
"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.",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -598,5 +598,12 @@
|
||||
"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.",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -598,5 +598,12 @@
|
||||
"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.",
|
||||
"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
Reference in new issue
Block a user