mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-11-15 07:07:07 +00:00
Compare commits
17
Commits
desktop-beta
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b719fe908f | ||
|
|
8ef3eca819 | ||
|
|
0e2a5a39df | ||
|
|
59c9427778 | ||
|
|
57c34b388b | ||
|
|
4050420b3c | ||
|
|
7474f892ce | ||
|
|
002fd259aa | ||
|
|
21e27a4416 | ||
|
|
b80fa7d942 | ||
|
|
2608870505 | ||
|
|
9e289c33be | ||
|
|
419489bcfe | ||
|
|
7a7749a9d3 | ||
|
|
eee76f05d4 | ||
|
|
a67b982b1c | ||
|
|
c0afd1fea9 |
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:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: 'main'
|
branches: ['main', 'dev']
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: cloudflare-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build_site:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
deployments: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
@@ -41,35 +51,17 @@ jobs:
|
|||||||
run: npm test --prefix website
|
run: npm test --prefix website
|
||||||
|
|
||||||
- name: Create env file
|
- name: Create env file
|
||||||
run: |
|
run: echo PUBLIC_MAPTILER_KEY=${{ secrets.PUBLIC_MAPTILER_KEY }} >> website/.env
|
||||||
touch website/.env
|
|
||||||
echo PUBLIC_MAPTILER_KEY=${{ secrets.PUBLIC_MAPTILER_KEY }} >> website/.env
|
|
||||||
cat website/.env
|
|
||||||
|
|
||||||
- name: Build website
|
- name: Build website
|
||||||
env:
|
env:
|
||||||
BASE_PATH: ''
|
BASE_PATH: ''
|
||||||
run: |
|
run: npm run build --prefix website
|
||||||
npm run build --prefix website
|
|
||||||
|
|
||||||
- name: Upload Artifacts
|
- name: Deploy to Cloudflare Pages
|
||||||
uses: actions/upload-pages-artifact@v4
|
uses: cloudflare/wrangler-action@v3
|
||||||
with:
|
with:
|
||||||
path: 'website/build/'
|
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
deploy:
|
command: pages deploy website/build --project-name=${{ vars.CLOUDFLARE_PROJECT_NAME || 'gpxstudio' }} --branch=${{ github.ref_name }} --commit-dirty=true
|
||||||
needs: build_site
|
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
pages: write
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
environment:
|
|
||||||
name: github-pages
|
|
||||||
url: ${{ steps.deployment.outputs.page_url }}
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Deploy
|
|
||||||
id: deployment
|
|
||||||
uses: actions/deploy-pages@v4
|
|
||||||
@@ -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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg_aliases"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.45"
|
version = "0.4.45"
|
||||||
@@ -537,6 +543,16 @@ dependencies = [
|
|||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core-foundation"
|
||||||
|
version = "0.9.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.10.1"
|
version = "0.10.1"
|
||||||
@@ -560,7 +576,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.2",
|
"bitflags 2.13.2",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics-types",
|
"core-graphics-types",
|
||||||
"foreign-types",
|
"foreign-types",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -573,7 +589,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.2",
|
"bitflags 2.13.2",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1020,6 +1036,16 @@ dependencies = [
|
|||||||
"rustc_version",
|
"rustc_version",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "filetime"
|
||||||
|
version = "0.2.29"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.12"
|
version = "0.1.12"
|
||||||
@@ -1287,6 +1313,17 @@ dependencies = [
|
|||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"wasi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.3.4"
|
version = "0.3.4"
|
||||||
@@ -1306,8 +1343,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi 6.0.0",
|
"r-efi 6.0.0",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1431,7 +1470,9 @@ dependencies = [
|
|||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
|
"tauri-plugin-process",
|
||||||
"tauri-plugin-single-instance",
|
"tauri-plugin-single-instance",
|
||||||
|
"tauri-plugin-updater",
|
||||||
"trash",
|
"trash",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
@@ -1631,6 +1672,21 @@ dependencies = [
|
|||||||
"want",
|
"want",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper-rustls"
|
||||||
|
version = "0.27.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
|
||||||
|
dependencies = [
|
||||||
|
"http",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"rustls",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper-util"
|
name = "hyper-util"
|
||||||
version = "0.1.21"
|
version = "0.1.21"
|
||||||
@@ -1650,9 +1706,11 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2",
|
"socket2",
|
||||||
|
"system-configuration",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"windows-registry",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1990,6 +2048,36 @@ dependencies = [
|
|||||||
"windows-sys 0.45.0",
|
"windows-sys 0.45.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jni"
|
||||||
|
version = "0.22.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"combine",
|
||||||
|
"jni-macros",
|
||||||
|
"jni-sys 0.4.1",
|
||||||
|
"log",
|
||||||
|
"simd_cesu8",
|
||||||
|
"thiserror 2.0.21",
|
||||||
|
"walkdir",
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jni-macros"
|
||||||
|
version = "0.22.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"rustc_version",
|
||||||
|
"simd_cesu8",
|
||||||
|
"syn 2.0.118",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jni-sys"
|
name = "jni-sys"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -2214,6 +2302,12 @@ dependencies = [
|
|||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "minisign-verify"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "miniz_oxide"
|
name = "miniz_oxide"
|
||||||
version = "0.8.9"
|
version = "0.8.9"
|
||||||
@@ -2303,6 +2397,18 @@ version = "1.0.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nix"
|
||||||
|
version = "0.31.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.2",
|
||||||
|
"cfg-if",
|
||||||
|
"cfg_aliases",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "notify"
|
name = "notify"
|
||||||
version = "8.2.0"
|
version = "8.2.0"
|
||||||
@@ -2529,6 +2635,18 @@ dependencies = [
|
|||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-osa-kit"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.2",
|
||||||
|
"objc2",
|
||||||
|
"objc2-app-kit",
|
||||||
|
"objc2-foundation",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-quartz-core"
|
name = "objc2-quartz-core"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -2609,6 +2727,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-probe"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "option-ext"
|
name = "option-ext"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -2625,6 +2749,20 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "osakit"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
|
||||||
|
dependencies = [
|
||||||
|
"objc2",
|
||||||
|
"objc2-foundation",
|
||||||
|
"objc2-osa-kit",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.21",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pango"
|
name = "pango"
|
||||||
version = "0.18.3"
|
version = "0.18.3"
|
||||||
@@ -3049,15 +3187,20 @@ dependencies = [
|
|||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
|
"hyper-rustls",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"rustls-platform-verifier",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
@@ -3093,6 +3236,20 @@ dependencies = [
|
|||||||
"windows-sys 0.60.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ring"
|
||||||
|
version = "0.17.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"cfg-if",
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
"libc",
|
||||||
|
"untrusted",
|
||||||
|
"windows-sys 0.52.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "2.1.3"
|
version = "2.1.3"
|
||||||
@@ -3121,6 +3278,79 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls"
|
||||||
|
version = "0.23.46"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
|
||||||
|
dependencies = [
|
||||||
|
"once_cell",
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"rustls-webpki",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-native-certs"
|
||||||
|
version = "0.8.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4"
|
||||||
|
dependencies = [
|
||||||
|
"openssl-probe",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"schannel",
|
||||||
|
"security-framework",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-pki-types"
|
||||||
|
version = "1.15.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||||
|
dependencies = [
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-platform-verifier"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation 0.10.1",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"jni 0.22.4",
|
||||||
|
"log",
|
||||||
|
"once_cell",
|
||||||
|
"rustls",
|
||||||
|
"rustls-native-certs",
|
||||||
|
"rustls-platform-verifier-android",
|
||||||
|
"rustls-webpki",
|
||||||
|
"security-framework",
|
||||||
|
"security-framework-sys",
|
||||||
|
"webpki-root-certs",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-platform-verifier-android"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-webpki"
|
||||||
|
version = "0.103.16"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
|
||||||
|
dependencies = [
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"untrusted",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustversion"
|
name = "rustversion"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
@@ -3136,6 +3366,15 @@ dependencies = [
|
|||||||
"winapi-util",
|
"winapi-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "schannel"
|
||||||
|
version = "0.1.30"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "schemars"
|
name = "schemars"
|
||||||
version = "0.8.22"
|
version = "0.8.22"
|
||||||
@@ -3193,6 +3432,29 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "security-framework"
|
||||||
|
version = "3.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.2",
|
||||||
|
"core-foundation 0.10.1",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
"security-framework-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "security-framework-sys"
|
||||||
|
version = "2.17.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "selectors"
|
name = "selectors"
|
||||||
version = "0.38.0"
|
version = "0.38.0"
|
||||||
@@ -3425,6 +3687,22 @@ version = "0.3.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simd_cesu8"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
|
||||||
|
dependencies = [
|
||||||
|
"rustc_version",
|
||||||
|
"simdutf8",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simdutf8"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "siphasher"
|
name = "siphasher"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@@ -3546,6 +3824,12 @@ version = "0.11.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "swift-rs"
|
name = "swift-rs"
|
||||||
version = "1.0.9"
|
version = "1.0.9"
|
||||||
@@ -3609,6 +3893,27 @@ dependencies = [
|
|||||||
"syn 3.0.6",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "system-configuration"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.2",
|
||||||
|
"core-foundation 0.9.4",
|
||||||
|
"system-configuration-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "system-configuration-sys"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "system-deps"
|
name = "system-deps"
|
||||||
version = "6.2.2"
|
version = "6.2.2"
|
||||||
@@ -3630,7 +3935,7 @@ checksum = "f37f381f4e048e6cdf038b5705f8cf14ad108279d46eb968140a7b291aba9400"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.2",
|
"bitflags 2.13.2",
|
||||||
"block2",
|
"block2",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics",
|
"core-graphics",
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dbus",
|
"dbus",
|
||||||
@@ -3640,7 +3945,7 @@ dependencies = [
|
|||||||
"gdkwayland-sys",
|
"gdkwayland-sys",
|
||||||
"gdkx11-sys",
|
"gdkx11-sys",
|
||||||
"gtk",
|
"gtk",
|
||||||
"jni",
|
"jni 0.21.1",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"ndk",
|
"ndk",
|
||||||
@@ -3673,6 +3978,17 @@ dependencies = [
|
|||||||
"syn 2.0.118",
|
"syn 2.0.118",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tar"
|
||||||
|
version = "0.4.46"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||||
|
dependencies = [
|
||||||
|
"filetime",
|
||||||
|
"libc",
|
||||||
|
"xattr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "target-lexicon"
|
name = "target-lexicon"
|
||||||
version = "0.12.16"
|
version = "0.12.16"
|
||||||
@@ -3696,7 +4012,7 @@ dependencies = [
|
|||||||
"gtk",
|
"gtk",
|
||||||
"heck 0.5.0",
|
"heck 0.5.0",
|
||||||
"http",
|
"http",
|
||||||
"jni",
|
"jni 0.21.1",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"mime",
|
"mime",
|
||||||
@@ -3872,6 +4188,16 @@ dependencies = [
|
|||||||
"zbus",
|
"zbus",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-process"
|
||||||
|
version = "2.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "74ae3d8549ae564c0397377373e262f340908eb23a5b9fbe03f2a00007dc0038"
|
||||||
|
dependencies = [
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-single-instance"
|
name = "tauri-plugin-single-instance"
|
||||||
version = "2.5.3"
|
version = "2.5.3"
|
||||||
@@ -3888,6 +4214,40 @@ dependencies = [
|
|||||||
"zbus",
|
"zbus",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-updater"
|
||||||
|
version = "2.14.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d957abbcb3f9f6c76d22a3a0f012f30880640d0bccb5634d2b12a9937e4c872b"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.23.1",
|
||||||
|
"dirs",
|
||||||
|
"flate2",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"infer",
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"minisign-verify",
|
||||||
|
"osakit",
|
||||||
|
"percent-encoding",
|
||||||
|
"reqwest",
|
||||||
|
"rustls",
|
||||||
|
"semver",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tar",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"tempfile",
|
||||||
|
"thiserror 2.0.21",
|
||||||
|
"time",
|
||||||
|
"tokio",
|
||||||
|
"url",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
"zip",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-runtime"
|
name = "tauri-runtime"
|
||||||
version = "2.12.1"
|
version = "2.12.1"
|
||||||
@@ -3898,7 +4258,7 @@ dependencies = [
|
|||||||
"dpi",
|
"dpi",
|
||||||
"gtk",
|
"gtk",
|
||||||
"http",
|
"http",
|
||||||
"jni",
|
"jni 0.21.1",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-ui-kit",
|
"objc2-ui-kit",
|
||||||
"objc2-web-kit",
|
"objc2-web-kit",
|
||||||
@@ -3921,7 +4281,7 @@ checksum = "7f9e7e0c9f80a5130773cfabb6df4dc924518fb1b2c923726966804634b57d22"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"gtk",
|
"gtk",
|
||||||
"http",
|
"http",
|
||||||
"jni",
|
"jni 0.21.1",
|
||||||
"log",
|
"log",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
@@ -4110,6 +4470,16 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-rustls"
|
||||||
|
version = "0.26.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
|
||||||
|
dependencies = [
|
||||||
|
"rustls",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.20"
|
version = "0.7.20"
|
||||||
@@ -4341,6 +4711,12 @@ version = "0.2.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typed-path"
|
||||||
|
version = "0.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typeid"
|
name = "typeid"
|
||||||
version = "1.0.3"
|
version = "1.0.3"
|
||||||
@@ -4376,6 +4752,12 @@ version = "1.13.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "untrusted"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "url"
|
name = "url"
|
||||||
version = "2.5.8"
|
version = "2.5.8"
|
||||||
@@ -4675,6 +5057,15 @@ dependencies = [
|
|||||||
"system-deps",
|
"system-deps",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webpki-root-certs"
|
||||||
|
version = "1.0.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a"
|
||||||
|
dependencies = [
|
||||||
|
"rustls-pki-types",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webview2-com"
|
name = "webview2-com"
|
||||||
version = "0.39.1"
|
version = "0.39.1"
|
||||||
@@ -4839,6 +5230,17 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-registry"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
"windows-result",
|
||||||
|
"windows-strings",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-result"
|
name = "windows-result"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -4866,6 +5268,15 @@ dependencies = [
|
|||||||
"windows-targets 0.42.2",
|
"windows-targets 0.42.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.52.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.60.2"
|
version = "0.60.2"
|
||||||
@@ -4899,6 +5310,22 @@ dependencies = [
|
|||||||
"windows_x86_64_msvc 0.42.2",
|
"windows_x86_64_msvc 0.42.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-targets"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||||
|
dependencies = [
|
||||||
|
"windows_aarch64_gnullvm 0.52.6",
|
||||||
|
"windows_aarch64_msvc 0.52.6",
|
||||||
|
"windows_i686_gnu 0.52.6",
|
||||||
|
"windows_i686_gnullvm 0.52.6",
|
||||||
|
"windows_i686_msvc 0.52.6",
|
||||||
|
"windows_x86_64_gnu 0.52.6",
|
||||||
|
"windows_x86_64_gnullvm 0.52.6",
|
||||||
|
"windows_x86_64_msvc 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-targets"
|
name = "windows-targets"
|
||||||
version = "0.53.5"
|
version = "0.53.5"
|
||||||
@@ -4909,7 +5336,7 @@ dependencies = [
|
|||||||
"windows_aarch64_gnullvm 0.53.1",
|
"windows_aarch64_gnullvm 0.53.1",
|
||||||
"windows_aarch64_msvc 0.53.1",
|
"windows_aarch64_msvc 0.53.1",
|
||||||
"windows_i686_gnu 0.53.1",
|
"windows_i686_gnu 0.53.1",
|
||||||
"windows_i686_gnullvm",
|
"windows_i686_gnullvm 0.53.1",
|
||||||
"windows_i686_msvc 0.53.1",
|
"windows_i686_msvc 0.53.1",
|
||||||
"windows_x86_64_gnu 0.53.1",
|
"windows_x86_64_gnu 0.53.1",
|
||||||
"windows_x86_64_gnullvm 0.53.1",
|
"windows_x86_64_gnullvm 0.53.1",
|
||||||
@@ -4940,6 +5367,12 @@ version = "0.42.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_gnullvm"
|
name = "windows_aarch64_gnullvm"
|
||||||
version = "0.53.1"
|
version = "0.53.1"
|
||||||
@@ -4952,6 +5385,12 @@ version = "0.42.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_msvc"
|
name = "windows_aarch64_msvc"
|
||||||
version = "0.53.1"
|
version = "0.53.1"
|
||||||
@@ -4964,12 +5403,24 @@ version = "0.42.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnu"
|
name = "windows_i686_gnu"
|
||||||
version = "0.53.1"
|
version = "0.53.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnullvm"
|
name = "windows_i686_gnullvm"
|
||||||
version = "0.53.1"
|
version = "0.53.1"
|
||||||
@@ -4982,6 +5433,12 @@ version = "0.42.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_msvc"
|
name = "windows_i686_msvc"
|
||||||
version = "0.53.1"
|
version = "0.53.1"
|
||||||
@@ -4994,6 +5451,12 @@ version = "0.42.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnu"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnu"
|
name = "windows_x86_64_gnu"
|
||||||
version = "0.53.1"
|
version = "0.53.1"
|
||||||
@@ -5006,6 +5469,12 @@ version = "0.42.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnullvm"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnullvm"
|
name = "windows_x86_64_gnullvm"
|
||||||
version = "0.53.1"
|
version = "0.53.1"
|
||||||
@@ -5018,6 +5487,12 @@ version = "0.42.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_msvc"
|
||||||
|
version = "0.52.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_msvc"
|
name = "windows_x86_64_msvc"
|
||||||
version = "0.53.1"
|
version = "0.53.1"
|
||||||
@@ -5082,7 +5557,7 @@ dependencies = [
|
|||||||
"gtk",
|
"gtk",
|
||||||
"http",
|
"http",
|
||||||
"javascriptcore-rs",
|
"javascriptcore-rs",
|
||||||
"jni",
|
"jni 0.21.1",
|
||||||
"libc",
|
"libc",
|
||||||
"ndk",
|
"ndk",
|
||||||
"objc2",
|
"objc2",
|
||||||
@@ -5129,6 +5604,16 @@ dependencies = [
|
|||||||
"pkg-config",
|
"pkg-config",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "xattr"
|
||||||
|
version = "1.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"rustix",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
@@ -5243,6 +5728,12 @@ dependencies = [
|
|||||||
"synstructure",
|
"synstructure",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize"
|
||||||
|
version = "1.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerotrie"
|
name = "zerotrie"
|
||||||
version = "0.2.5"
|
version = "0.2.5"
|
||||||
@@ -5276,6 +5767,22 @@ dependencies = [
|
|||||||
"syn 3.0.6",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zip"
|
||||||
|
version = "9.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eaeb7f16d2605b49aef586996ded29e65642fab7259a3077e537351df6d80426"
|
||||||
|
dependencies = [
|
||||||
|
"crc32fast",
|
||||||
|
"getrandom 0.4.3",
|
||||||
|
"indexmap 2.14.2",
|
||||||
|
"libc",
|
||||||
|
"memchr",
|
||||||
|
"nix",
|
||||||
|
"typed-path",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zlib-rs"
|
name = "zlib-rs"
|
||||||
version = "0.6.8"
|
version = "0.6.8"
|
||||||
|
|||||||
@@ -560,26 +560,6 @@ impl WaypointDetails {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct TrackpointDetails {
|
|
||||||
lng: f64,
|
|
||||||
lat: f64,
|
|
||||||
ele: f64,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
time: Option<f64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TrackpointDetails {
|
|
||||||
pub fn new(pt: &engine::Trackpoint) -> Self {
|
|
||||||
Self {
|
|
||||||
lng: pt.coordinates.lng,
|
|
||||||
lat: pt.coordinates.lat,
|
|
||||||
ele: pt.ele,
|
|
||||||
time: pt.time.map(|t| t as f64),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A trackpoint of the selection: what the statistics buffers do not hold for each trackpoint.
|
/// A trackpoint of the selection: what the statistics buffers do not hold for each trackpoint.
|
||||||
/// Absent fields are unknown.
|
/// Absent fields are unknown.
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
|||||||
@@ -63,18 +63,6 @@ pub fn waypoint<H: History>(
|
|||||||
.map(WaypointDetails::new)
|
.map(WaypointDetails::new)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A trackpoint of a segment of a file, `None` if it does not exist.
|
|
||||||
pub fn trackpoint<H: History>(
|
|
||||||
engine: &Engine<H>,
|
|
||||||
file_id: Uuid,
|
|
||||||
segment_id: Uuid,
|
|
||||||
index: usize,
|
|
||||||
) -> Option<TrackpointDetails> {
|
|
||||||
engine
|
|
||||||
.trackpoint(&FileId(file_id), &engine::TrackSegmentId(segment_id), index)
|
|
||||||
.map(TrackpointDetails::new)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A file as GPX (UTF-8 bytes), `None` if it does not exist. The options say which data of the
|
/// A file as GPX (UTF-8 bytes), `None` if it does not exist. The options say which data of the
|
||||||
/// trackpoints to keep.
|
/// trackpoints to keep.
|
||||||
pub fn export_file<H: History>(
|
pub fn export_file<H: History>(
|
||||||
|
|||||||
@@ -44,14 +44,24 @@ where
|
|||||||
let last = trkseg.last_index().unwrap();
|
let last = trkseg.last_index().unwrap();
|
||||||
|
|
||||||
let mut kept = vec![(first, None)];
|
let mut kept = vec![(first, None)];
|
||||||
ramer_douglas_peucker_helper(trkseg, first, last, measure, epsilon, &mut kept);
|
ramer_douglas_peucker_ranges(trkseg, first, last, measure, epsilon, &mut kept);
|
||||||
kept.push((last, None));
|
kept.push((last, None));
|
||||||
kept
|
kept
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ramer_douglas_peucker_helper<M>(
|
/// What is left to do for the points between two ends, in the order of the points.
|
||||||
|
enum Step {
|
||||||
|
/// Looks for the point to keep between these two.
|
||||||
|
Range(TrackSegmentIndex, TrackSegmentIndex),
|
||||||
|
/// Keeps a point, selected at this distance.
|
||||||
|
Keep(TrackSegmentIndex, f64),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds the points to keep between `start` and `end` to `kept`, in order. It does not recurse: a
|
||||||
|
/// track can make it go as deep as it has points, which would overflow the stack.
|
||||||
|
fn ramer_douglas_peucker_ranges<M>(
|
||||||
trkseg: &TrackSegment,
|
trkseg: &TrackSegment,
|
||||||
start: TrackSegmentIndex,
|
start: TrackSegmentIndex,
|
||||||
end: TrackSegmentIndex,
|
end: TrackSegmentIndex,
|
||||||
@@ -61,28 +71,40 @@ fn ramer_douglas_peucker_helper<M>(
|
|||||||
) where
|
) where
|
||||||
M: Fn(TrackSegmentIndex, TrackSegmentIndex, TrackSegmentIndex) -> f64,
|
M: Fn(TrackSegmentIndex, TrackSegmentIndex, TrackSegmentIndex) -> f64,
|
||||||
{
|
{
|
||||||
let mut max_idx = None;
|
let mut steps = vec![Step::Range(start, end)];
|
||||||
let mut max_dist = 0.0;
|
while let Some(step) = steps.pop() {
|
||||||
|
let (start, end) = match step {
|
||||||
|
Step::Keep(idx, dist) => {
|
||||||
|
kept.push((idx, Some(dist)));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Step::Range(start, end) => (start, end),
|
||||||
|
};
|
||||||
|
|
||||||
let mut cur = trkseg.next_index(Some(start));
|
let mut max_idx = None;
|
||||||
while let Some(idx) = cur {
|
let mut max_dist = 0.0;
|
||||||
if idx == end {
|
|
||||||
break;
|
let mut cur = trkseg.next_index(Some(start));
|
||||||
|
while let Some(idx) = cur {
|
||||||
|
if idx == end {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dist = measure(start, end, idx);
|
||||||
|
if dist > max_dist {
|
||||||
|
max_idx = Some(idx);
|
||||||
|
max_dist = dist;
|
||||||
|
}
|
||||||
|
|
||||||
|
cur = trkseg.next_index(cur);
|
||||||
}
|
}
|
||||||
|
|
||||||
let dist = measure(start, end, idx);
|
if let Some(idx) = max_idx.filter(|_| max_dist > epsilon) {
|
||||||
if dist > max_dist {
|
// the last one pushed is done first
|
||||||
max_idx = Some(idx);
|
steps.push(Step::Range(idx, end));
|
||||||
max_dist = dist;
|
steps.push(Step::Keep(idx, max_dist));
|
||||||
|
steps.push(Step::Range(start, idx));
|
||||||
}
|
}
|
||||||
|
|
||||||
cur = trkseg.next_index(cur);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(idx) = max_idx.filter(|_| max_dist > epsilon) {
|
|
||||||
ramer_douglas_peucker_helper(trkseg, start, idx, measure, epsilon, kept);
|
|
||||||
kept.push((idx, Some(max_dist)));
|
|
||||||
ramer_douglas_peucker_helper(trkseg, idx, end, measure, epsilon, kept);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,6 +217,16 @@ mod tests {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_goes_as_deep_as_the_segment_is_long_in_order() {
|
||||||
|
let n = 12_000;
|
||||||
|
let trkseg = segment(&vec![0.0; n]);
|
||||||
|
// the point before the end is always the farthest one, so each range only loses one point
|
||||||
|
let kept = ramer_douglas_peucker_by(&trkseg, &|_, _, idx| idx.flat as f64 + 1.0, 0.5);
|
||||||
|
let kept: Vec<usize> = kept.iter().map(|(idx, _)| idx.flat).collect();
|
||||||
|
assert_eq!(kept, (0..n).collect::<Vec<_>>());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_empty_and_single_point() {
|
fn test_empty_and_single_point() {
|
||||||
assert!(simplify(&[], 1.0).is_empty());
|
assert!(simplify(&[], 1.0).is_empty());
|
||||||
|
|||||||
+183
-6
@@ -3,10 +3,10 @@ use crate::{
|
|||||||
TrackpointCategories, TrackpointChunk, Waypoint, WaypointChunk, distance,
|
TrackpointCategories, TrackpointChunk, Waypoint, WaypointChunk, distance,
|
||||||
};
|
};
|
||||||
use chrono::DateTime;
|
use chrono::DateTime;
|
||||||
use quick_xml::Error;
|
use quick_xml::{Error, XmlVersion};
|
||||||
use quick_xml::escape::resolve_predefined_entity;
|
use quick_xml::escape::resolve_predefined_entity;
|
||||||
use quick_xml::events::Event;
|
use quick_xml::events::Event;
|
||||||
use quick_xml::events::attributes::Attributes;
|
use quick_xml::events::attributes::{Attribute, Attributes};
|
||||||
use quick_xml::reader::Reader;
|
use quick_xml::reader::Reader;
|
||||||
|
|
||||||
enum GPXElement {
|
enum GPXElement {
|
||||||
@@ -37,6 +37,8 @@ enum GPXElement {
|
|||||||
Symbol,
|
Symbol,
|
||||||
Type,
|
Type,
|
||||||
Color,
|
Color,
|
||||||
|
/// The color of a track or a route in the Garmin extensions (`gpxx:DisplayColor`).
|
||||||
|
DisplayColor,
|
||||||
Opacity,
|
Opacity,
|
||||||
Width,
|
Width,
|
||||||
}
|
}
|
||||||
@@ -58,6 +60,50 @@ fn is_route_point_extension(name: &str) -> bool {
|
|||||||
name == "rpt" || name.ends_with(":rpt")
|
name == "rpt" || name.ends_with(":rpt")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The color of a track (`RRGGBB`, the format of the style schema) and its opacity, from a color
|
||||||
|
/// that may start with `#` and may have an alpha channel first (`AARRGGBB`), as some applications
|
||||||
|
/// write it. A color in any other format is kept as it is, without opacity.
|
||||||
|
fn parse_color(value: &str) -> (String, Option<f64>) {
|
||||||
|
let value = value.trim();
|
||||||
|
let hex = value.strip_prefix('#').unwrap_or(value);
|
||||||
|
if !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||||
|
return (value.to_owned(), None);
|
||||||
|
}
|
||||||
|
match hex.len() {
|
||||||
|
6 => (hex.to_owned(), None),
|
||||||
|
8 => {
|
||||||
|
let alpha = u8::from_str_radix(&hex[..2], 16).unwrap_or(u8::MAX);
|
||||||
|
let opacity = (f64::from(alpha) / 255.0 * 100.0).round() / 100.0;
|
||||||
|
(hex[2..].to_owned(), Some(opacity))
|
||||||
|
}
|
||||||
|
_ => (value.to_owned(), None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The hexadecimal color of a value of `DisplayColor_t` in the Garmin extensions. None for
|
||||||
|
/// `Transparent` and for what is not part of the schema.
|
||||||
|
fn garmin_display_color(name: &str) -> Option<&'static str> {
|
||||||
|
match name.trim() {
|
||||||
|
"Black" => Some("000000"),
|
||||||
|
"DarkRed" => Some("800000"),
|
||||||
|
"DarkGreen" => Some("008000"),
|
||||||
|
"DarkYellow" => Some("808000"),
|
||||||
|
"DarkBlue" => Some("000080"),
|
||||||
|
"DarkMagenta" => Some("800080"),
|
||||||
|
"DarkCyan" => Some("008080"),
|
||||||
|
"LightGray" => Some("c0c0c0"),
|
||||||
|
"DarkGray" => Some("808080"),
|
||||||
|
"Red" => Some("ff0000"),
|
||||||
|
"Green" => Some("00ff00"),
|
||||||
|
"Yellow" => Some("ffff00"),
|
||||||
|
"Blue" => Some("0000ff"),
|
||||||
|
"Magenta" => Some("ff00ff"),
|
||||||
|
"Cyan" => Some("00ffff"),
|
||||||
|
"White" => Some("ffffff"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether the element holds a value, which is its text.
|
/// Whether the element holds a value, which is its text.
|
||||||
fn holds_text(element: Option<&GPXElement>) -> bool {
|
fn holds_text(element: Option<&GPXElement>) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
@@ -81,6 +127,7 @@ fn holds_text(element: Option<&GPXElement>) -> bool {
|
|||||||
| GPXElement::Symbol
|
| GPXElement::Symbol
|
||||||
| GPXElement::Type
|
| GPXElement::Type
|
||||||
| GPXElement::Color
|
| GPXElement::Color
|
||||||
|
| GPXElement::DisplayColor
|
||||||
| GPXElement::Opacity
|
| GPXElement::Opacity
|
||||||
| GPXElement::Width
|
| GPXElement::Width
|
||||||
)
|
)
|
||||||
@@ -241,7 +288,21 @@ fn apply_text(
|
|||||||
Some(GPXElement::Color) => {
|
Some(GPXElement::Color) => {
|
||||||
stack.pop();
|
stack.pop();
|
||||||
if let Some(GPXElement::Track(trk)) = stack.last_mut() {
|
if let Some(GPXElement::Track(trk)) = stack.last_mut() {
|
||||||
trk.info.color = Some(e.to_owned());
|
let (color, opacity) = parse_color(e);
|
||||||
|
trk.info.color = Some(color);
|
||||||
|
// The opacity of the style schema has priority, wherever it is in the extensions
|
||||||
|
if trk.info.opacity.is_none() {
|
||||||
|
trk.info.opacity = opacity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The color of the style schema has priority, wherever it is in the extensions
|
||||||
|
Some(GPXElement::DisplayColor) => {
|
||||||
|
stack.pop();
|
||||||
|
if let Some(GPXElement::Track(trk)) = stack.last_mut()
|
||||||
|
&& trk.info.color.is_none()
|
||||||
|
{
|
||||||
|
trk.info.color = garmin_display_color(e).map(str::to_owned);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(GPXElement::Opacity) => {
|
Some(GPXElement::Opacity) => {
|
||||||
@@ -260,6 +321,13 @@ fn apply_text(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The text of an attribute, without its escaping (`&` is `&`), which is written back when the
|
||||||
|
/// file is. As it is in the file if it cannot be read.
|
||||||
|
fn attribute_text(attr: &Attribute<'_>) -> String {
|
||||||
|
attr.normalized_value(XmlVersion::Implicit1_0)
|
||||||
|
.map_or_else(|_| attr.value.to_string(), |text| text.into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
/// The email of the author, given as the `id` and `domain` attributes of the element.
|
/// The email of the author, given as the `id` and `domain` attributes of the element.
|
||||||
fn set_email(stack: &mut [GPXElement], attributes: Attributes<'_>) {
|
fn set_email(stack: &mut [GPXElement], attributes: Attributes<'_>) {
|
||||||
let Some(GPXElement::Author(author)) = stack.last_mut() else {
|
let Some(GPXElement::Author(author)) = stack.last_mut() else {
|
||||||
@@ -268,8 +336,8 @@ fn set_email(stack: &mut [GPXElement], attributes: Attributes<'_>) {
|
|||||||
let (mut id, mut domain) = (None, None);
|
let (mut id, mut domain) = (None, None);
|
||||||
for attr in attributes.flatten() {
|
for attr in attributes.flatten() {
|
||||||
match attr.key.as_ref() {
|
match attr.key.as_ref() {
|
||||||
"id" => id = Some(attr.value.to_string()),
|
"id" => id = Some(attribute_text(&attr)),
|
||||||
"domain" => domain = Some(attr.value.to_string()),
|
"domain" => domain = Some(attribute_text(&attr)),
|
||||||
_ => (),
|
_ => (),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -283,7 +351,7 @@ fn parse_link(attributes: Attributes<'_>) -> Link {
|
|||||||
let mut link = Link::default();
|
let mut link = Link::default();
|
||||||
for attr in attributes.flatten() {
|
for attr in attributes.flatten() {
|
||||||
if attr.key.as_ref() == "href" {
|
if attr.key.as_ref() == "href" {
|
||||||
link.href = attr.value.to_string();
|
link.href = attribute_text(&attr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
link
|
link
|
||||||
@@ -448,6 +516,7 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File,
|
|||||||
"sym" => stack.push(GPXElement::Symbol),
|
"sym" => stack.push(GPXElement::Symbol),
|
||||||
"type" => stack.push(GPXElement::Type),
|
"type" => stack.push(GPXElement::Type),
|
||||||
e if e.ends_with("color") => stack.push(GPXElement::Color),
|
e if e.ends_with("color") => stack.push(GPXElement::Color),
|
||||||
|
e if e.ends_with("DisplayColor") => stack.push(GPXElement::DisplayColor),
|
||||||
e if e.ends_with("opacity") => stack.push(GPXElement::Opacity),
|
e if e.ends_with("opacity") => stack.push(GPXElement::Opacity),
|
||||||
e if e.ends_with("width") => stack.push(GPXElement::Width),
|
e if e.ends_with("width") => stack.push(GPXElement::Width),
|
||||||
_ => (),
|
_ => (),
|
||||||
@@ -626,6 +695,14 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_link_href_is_unescaped_and_written_back_once() {
|
||||||
|
let data =
|
||||||
|
r#"<gpx><metadata><link href="https://a.example/?x=1&y=2"/></metadata></gpx>"#;
|
||||||
|
let gpx = parse(data.as_bytes(), &mut Default::default()).unwrap();
|
||||||
|
assert_eq!(gpx.info.links[0].href, "https://a.example/?x=1&y=2");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_parse_entities_and_cdata_in_text() {
|
fn test_parse_entities_and_cdata_in_text() {
|
||||||
let data = r#"<gpx><metadata><name>Tom & Jerry <3 éA "x" 'y'</name>
|
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.opacity.is_some_and(|o| o == 0.5));
|
||||||
assert!(trk.info.width.is_some_and(|w| w == 6.0));
|
assert!(trk.info.width.is_some_and(|w| w == 6.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_text(text: &str) -> File {
|
||||||
|
parse(text.as_bytes(), &mut Default::default()).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_garmin_display_color_of_tracks_and_routes() {
|
||||||
|
let gpx = parse_text(
|
||||||
|
r#"<gpx xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3"
|
||||||
|
xmlns:gpxtrx="http://www.garmin.com/xmlschemas/GpxExtensions/v3">
|
||||||
|
<trk><extensions><gpxx:TrackExtension>
|
||||||
|
<gpxx:DisplayColor>Blue</gpxx:DisplayColor>
|
||||||
|
</gpxx:TrackExtension></extensions></trk>
|
||||||
|
<trk><extensions><gpxtrx:TrackExtension>
|
||||||
|
<gpxtrx:DisplayColor>DarkRed</gpxtrx:DisplayColor>
|
||||||
|
</gpxtrx:TrackExtension></extensions></trk>
|
||||||
|
<rte><extensions><gpxx:RouteExtension>
|
||||||
|
<gpxx:DisplayColor>Green</gpxx:DisplayColor>
|
||||||
|
</gpxx:RouteExtension></extensions></rte>
|
||||||
|
</gpx>"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(gpx.trk[0].info.color.as_deref(), Some("0000ff"));
|
||||||
|
assert_eq!(gpx.trk[1].info.color.as_deref(), Some("800000"));
|
||||||
|
assert_eq!(gpx.rte[0].info.color.as_deref(), Some("00ff00"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_garmin_display_color_without_a_color_leaves_the_track_uncolored() {
|
||||||
|
let gpx = parse_text(
|
||||||
|
r#"<gpx>
|
||||||
|
<trk><extensions><gpxx:TrackExtension>
|
||||||
|
<gpxx:DisplayColor>Transparent</gpxx:DisplayColor>
|
||||||
|
</gpxx:TrackExtension></extensions></trk>
|
||||||
|
<trk><extensions><gpxx:TrackExtension>
|
||||||
|
<gpxx:DisplayColor>Mauve</gpxx:DisplayColor>
|
||||||
|
</gpxx:TrackExtension></extensions></trk>
|
||||||
|
</gpx>"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(gpx.trk[0].info.color, None);
|
||||||
|
assert_eq!(gpx.trk[1].info.color, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_style_color_has_priority_over_garmin_display_color() {
|
||||||
|
let gpx = parse_text(
|
||||||
|
r#"<gpx>
|
||||||
|
<trk><extensions>
|
||||||
|
<gpxx:TrackExtension><gpxx:DisplayColor>Red</gpxx:DisplayColor></gpxx:TrackExtension>
|
||||||
|
<gpx_style:line><gpx_style:color>2d3ee9</gpx_style:color></gpx_style:line>
|
||||||
|
</extensions></trk>
|
||||||
|
<trk><extensions>
|
||||||
|
<gpx_style:line><gpx_style:color>2d3ee9</gpx_style:color></gpx_style:line>
|
||||||
|
<gpxx:TrackExtension><gpxx:DisplayColor>Red</gpxx:DisplayColor></gpxx:TrackExtension>
|
||||||
|
</extensions></trk>
|
||||||
|
</gpx>"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(gpx.trk[0].info.color.as_deref(), Some("2d3ee9"));
|
||||||
|
assert_eq!(gpx.trk[1].info.color.as_deref(), Some("2d3ee9"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_color_with_hash_and_alpha() {
|
||||||
|
assert_eq!(parse_color("2d3ee9"), ("2d3ee9".into(), None));
|
||||||
|
assert_eq!(parse_color("#0000FF"), ("0000FF".into(), None));
|
||||||
|
assert_eq!(parse_color("#800000FF"), ("0000FF".into(), Some(0.5)));
|
||||||
|
assert_eq!(parse_color("FF0000FF"), ("0000FF".into(), Some(1.0)));
|
||||||
|
assert_eq!(parse_color("#00ff0000"), ("ff0000".into(), Some(0.0)));
|
||||||
|
assert_eq!(parse_color("red"), ("red".into(), None));
|
||||||
|
assert_eq!(parse_color("#abc"), ("#abc".into(), None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_track_color_with_alpha() {
|
||||||
|
let gpx = parse_text(
|
||||||
|
r#"<gpx>
|
||||||
|
<trk><extensions><xsi:gpx>
|
||||||
|
<physicalColor>#400000FF</physicalColor>
|
||||||
|
<width>4</width>
|
||||||
|
<color>#800000FF</color>
|
||||||
|
</xsi:gpx></extensions></trk>
|
||||||
|
<trk><extensions><gpx_style:line>
|
||||||
|
<gpx_style:opacity>0.3</gpx_style:opacity>
|
||||||
|
<gpx_style:color>#800000FF</gpx_style:color>
|
||||||
|
</gpx_style:line></extensions></trk>
|
||||||
|
<trk><extensions><gpx_style:line>
|
||||||
|
<gpx_style:color>#800000FF</gpx_style:color>
|
||||||
|
<gpx_style:opacity>0.3</gpx_style:opacity>
|
||||||
|
</gpx_style:line></extensions></trk>
|
||||||
|
</gpx>"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(gpx.trk[0].info.color.as_deref(), Some("0000FF"));
|
||||||
|
assert_eq!(gpx.trk[0].info.opacity, Some(0.5));
|
||||||
|
assert_eq!(gpx.trk[0].info.width, Some(4.0));
|
||||||
|
assert_eq!(gpx.trk[1].info.opacity, Some(0.3));
|
||||||
|
assert_eq!(gpx.trk[2].info.opacity, Some(0.3));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -24,14 +24,6 @@ pub struct Statistics {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Statistics {
|
impl Statistics {
|
||||||
pub fn total_speed(&self) -> Option<f64> {
|
|
||||||
self.global.total_speed()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn moving_speed(&self) -> Option<f64> {
|
|
||||||
self.global.moving_speed()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn compute(trkseg: &TrackSegment) -> Self {
|
pub fn compute(trkseg: &TrackSegment) -> Self {
|
||||||
let mut stats = Self::default();
|
let mut stats = Self::default();
|
||||||
if trkseg.is_empty() {
|
if trkseg.is_empty() {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ impl StatisticsCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Statistics of the segments of the selection.
|
/// Statistics of the segments of the selection.
|
||||||
|
#[cfg(test)]
|
||||||
pub fn get<'a>(
|
pub fn get<'a>(
|
||||||
&'a self,
|
&'a self,
|
||||||
state: Option<&'a Files>,
|
state: Option<&'a Files>,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ pub use outcome::*;
|
|||||||
pub use state::*;
|
pub use state::*;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
ExportOptions, FileId, GlobalStatistics, Restored, Snapshot, TrackSegmentId, Trackpoint,
|
ExportOptions, FileId, GlobalStatistics, Restored, Snapshot, TrackSegmentId,
|
||||||
TrackpointCategories, Waypoint, WaypointId, reduction_distances, write,
|
TrackpointCategories, Waypoint, WaypointId, reduction_distances, write,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -82,22 +82,6 @@ impl<H: History> Engine<H> {
|
|||||||
.find(|wpt| wpt.id == *id)
|
.find(|wpt| wpt.id == *id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A trackpoint of a segment (of a route or a track) of a file. `None` if it does not exist.
|
|
||||||
pub fn trackpoint(
|
|
||||||
&self,
|
|
||||||
file_id: &FileId,
|
|
||||||
segment_id: &TrackSegmentId,
|
|
||||||
index: usize,
|
|
||||||
) -> Option<&Trackpoint> {
|
|
||||||
let segment = self
|
|
||||||
.history
|
|
||||||
.current()?
|
|
||||||
.get(file_id)?
|
|
||||||
.segments()
|
|
||||||
.find(|seg| seg.id == *segment_id)?;
|
|
||||||
(index < segment.len()).then(|| &segment[index])
|
|
||||||
}
|
|
||||||
|
|
||||||
/// For each trackpoint of the selection (numbered as in the statistics), the tolerance up to
|
/// For each trackpoint of the selection (numbered as in the statistics), the tolerance up to
|
||||||
/// which `Reduce` keeps it, see [`reduction_distances`].
|
/// which `Reduce` keeps it, see [`reduction_distances`].
|
||||||
pub fn reduction_distances(&self) -> Vec<f64> {
|
pub fn reduction_distances(&self) -> Vec<f64> {
|
||||||
|
|||||||
@@ -1169,24 +1169,6 @@ fn test_categories_are_shared_and_kept_by_the_history() {
|
|||||||
assert_eq!(expand(&engine.statistics().surface, 5), [1, 1, 0, 2, 2]);
|
assert_eq!(expand(&engine.statistics().surface, 5), [1, 1, 0, 2, 2]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_trackpoint() {
|
|
||||||
let mut engine = TestEngine::default();
|
|
||||||
load(&mut engine, "data/simple.gpx");
|
|
||||||
let file = engine.order()[0];
|
|
||||||
let structure = engine.file_structure(&file).unwrap();
|
|
||||||
let seg = &structure.tracks[0].segments[0];
|
|
||||||
let (seg_id, len) = (seg.id, seg.len);
|
|
||||||
let coordinates = engine.segment_coordinates(&seg_id).to_vec();
|
|
||||||
let last = engine.trackpoint(&file, &seg_id, len - 1).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
[last.coordinates.lng, last.coordinates.lat],
|
|
||||||
coordinates[(len - 1) * 2..]
|
|
||||||
);
|
|
||||||
assert!(engine.trackpoint(&file, &seg_id, len).is_none());
|
|
||||||
assert!(engine.trackpoint(&Default::default(), &seg_id, 0).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_export_writes_the_file_as_it_is() {
|
fn test_export_writes_the_file_as_it_is() {
|
||||||
let mut engine = TestEngine::default();
|
let mut engine = TestEngine::default();
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use crate::{
|
#[cfg(test)]
|
||||||
Batch, ChunkKey, ChunkKind, CommitError, FileId, Revision, Storage, StorageError, StoredData,
|
use crate::ChunkKind;
|
||||||
};
|
use crate::{Batch, ChunkKey, CommitError, FileId, Revision, Storage, StorageError, StoredData};
|
||||||
|
|
||||||
/// A storage that lives as long as the session. It is what an engine without a host storage uses,
|
/// A storage that lives as long as the session. It is what an engine without a host storage uses,
|
||||||
/// and what the tests use.
|
/// and what the tests use.
|
||||||
@@ -22,6 +22,7 @@ struct State {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// How much there is in a storage.
|
/// How much there is in a storage.
|
||||||
|
#[cfg(test)]
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct StorageCounts {
|
pub struct StorageCounts {
|
||||||
pub files: usize,
|
pub files: usize,
|
||||||
@@ -29,6 +30,7 @@ pub struct StorageCounts {
|
|||||||
pub waypoint_chunks: usize,
|
pub waypoint_chunks: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
impl MemoryStorage {
|
impl MemoryStorage {
|
||||||
/// How many files and chunks are stored.
|
/// How many files and chunks are stored.
|
||||||
pub fn counts(&self) -> StorageCounts {
|
pub fn counts(&self) -> StorageCounts {
|
||||||
|
|||||||
@@ -37,3 +37,7 @@ tauri-plugin-single-instance = "2.5.3"
|
|||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
trash = "5"
|
trash = "5"
|
||||||
|
|
||||||
|
[target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies]
|
||||||
|
tauri-plugin-process = "2.4.0"
|
||||||
|
tauri-plugin-updater = "2.14.1"
|
||||||
|
|
||||||
@@ -3,5 +3,5 @@
|
|||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Permissions for the main window",
|
"description": "Permissions for the main window",
|
||||||
"windows": ["main"],
|
"windows": ["main"],
|
||||||
"permissions": ["core:default"]
|
"permissions": ["core:default", "updater:default", "process:allow-restart"]
|
||||||
}
|
}
|
||||||
@@ -57,16 +57,6 @@ pub fn waypoint(
|
|||||||
state.read(move |e| api::waypoint(e, file_id, waypoint_id))
|
state.read(move |e| api::waypoint(e, file_id, waypoint_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command(async)]
|
|
||||||
pub fn trackpoint(
|
|
||||||
state: State<AppState>,
|
|
||||||
file_id: Uuid,
|
|
||||||
segment_id: Uuid,
|
|
||||||
index: usize,
|
|
||||||
) -> Option<api::TrackpointDetails> {
|
|
||||||
state.read(move |e| api::trackpoint(e, file_id, segment_id, index))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A file as GPX (UTF-8 bytes), the first byte telling whether the file exists.
|
/// A file as GPX (UTF-8 bytes), the first byte telling whether the file exists.
|
||||||
#[tauri::command(async)]
|
#[tauri::command(async)]
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
|||||||
+6
-1
@@ -55,6 +55,12 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
|
#[cfg(desktop)]
|
||||||
|
{
|
||||||
|
app.handle()
|
||||||
|
.plugin(tauri_plugin_updater::Builder::new().build())?;
|
||||||
|
app.handle().plugin(tauri_plugin_process::init())?;
|
||||||
|
}
|
||||||
let handle = app.handle().clone();
|
let handle = app.handle().clone();
|
||||||
let emit: state::Emit = std::sync::Arc::new(move |event, payload| {
|
let emit: state::Emit = std::sync::Arc::new(move |event, payload| {
|
||||||
let _ = handle.emit(event, payload);
|
let _ = handle.emit(event, payload);
|
||||||
@@ -167,7 +173,6 @@ pub fn register_commands<R: tauri::Runtime>(builder: tauri::Builder<R>) -> tauri
|
|||||||
queries::can_redo,
|
queries::can_redo,
|
||||||
queries::can_paste,
|
queries::can_paste,
|
||||||
queries::waypoint,
|
queries::waypoint,
|
||||||
queries::trackpoint,
|
|
||||||
queries::export_file,
|
queries::export_file,
|
||||||
queries::exportable_data,
|
queries::exportable_data,
|
||||||
queries::reduction_distances,
|
queries::reduction_distances,
|
||||||
|
|||||||
+10
-1
@@ -27,7 +27,8 @@
|
|||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": "all",
|
"targets": ["app", "dmg", "appimage", "deb", "rpm", "nsis"],
|
||||||
|
"macOS": { "signingIdentity": "-" },
|
||||||
"fileAssociations": [
|
"fileAssociations": [
|
||||||
{
|
{
|
||||||
"ext": ["gpx"],
|
"ext": ["gpx"],
|
||||||
@@ -44,5 +45,13 @@
|
|||||||
"icons/icon.icns",
|
"icons/icon.icns",
|
||||||
"icons/icon.ico"
|
"icons/icon.ico"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"updater": {
|
||||||
|
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDE5NTFDQzVDOUYwNEMyNEMKUldSTXdnU2ZYTXhSR1ZXRDV6aVErc3dUWXBaK0VGSitYRnFFR21hZ2s5UEJTT01aYkE5Unp5c0cKdW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEFFNTU3Q0Y1NzhFODkyMTEKUldRUmt1aDQ5WHhWcmdXQmx0UG83V29TenRxSVJjdkc4ckN1Zi93ajc3ZUhVUVp3eFlCOXphb1IK",
|
||||||
|
"endpoints": [
|
||||||
|
"https://github.com/gpxstudio/gpx.studio/releases/latest/download/latest.json"
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+18
-6
@@ -26,6 +26,9 @@ thread_local! {
|
|||||||
static PERSISTER: RefCell<Option<Persister>> = const { RefCell::new(None) };
|
static PERSISTER: RefCell<Option<Persister>> = const { RefCell::new(None) };
|
||||||
static SAVING: Cell<bool> = const { Cell::new(false) };
|
static SAVING: Cell<bool> = const { Cell::new(false) };
|
||||||
static DIRTY: Cell<bool> = const { Cell::new(false) };
|
static DIRTY: Cell<bool> = const { Cell::new(false) };
|
||||||
|
/// Whether the last write failed, or was not done because a newer version wrote the storage:
|
||||||
|
/// what the engine holds is then not all stored.
|
||||||
|
static NOT_WRITTEN: Cell<bool> = const { Cell::new(false) };
|
||||||
/// Resolve functions of the promises of `flush_storage`.
|
/// Resolve functions of the promises of `flush_storage`.
|
||||||
/// Called after the files were written, and when a write was refused because they were
|
/// Called after the files were written, and when a write was refused because they were
|
||||||
/// written by someone else since: see `open_storage`.
|
/// written by someone else since: see `open_storage`.
|
||||||
@@ -73,20 +76,28 @@ pub(crate) fn schedule_save() {
|
|||||||
let result = persister.save(&*storage, snapshot).await;
|
let result = persister.save(&*storage, snapshot).await;
|
||||||
PERSISTER.with(|slot| *slot.borrow_mut() = Some(persister));
|
PERSISTER.with(|slot| *slot.borrow_mut() = Some(persister));
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => saved = true,
|
Ok(()) => {
|
||||||
|
NOT_WRITTEN.set(false);
|
||||||
|
saved = true;
|
||||||
|
}
|
||||||
// nothing was written, so that the files of the other tab are not damaged: this
|
// nothing was written, so that the files of the other tab are not damaged: this
|
||||||
// tab has to read them, which replaces what it did
|
// tab has to read them, which replaces what it did
|
||||||
Err(engine::CommitError::Stale { .. }) => {
|
Err(engine::CommitError::Stale { .. }) => {
|
||||||
|
NOT_WRITTEN.set(false);
|
||||||
DIRTY.set(false);
|
DIRTY.set(false);
|
||||||
behind = true;
|
behind = true;
|
||||||
}
|
}
|
||||||
// nothing is written, so that the newer version finds its data again
|
// nothing is written, so that the newer version finds its data again
|
||||||
Err(engine::CommitError::Failed(engine::StorageError::Newer)) => {
|
Err(engine::CommitError::Failed(engine::StorageError::Newer)) => {
|
||||||
|
NOT_WRITTEN.set(true);
|
||||||
DIRTY.set(false);
|
DIRTY.set(false);
|
||||||
newer = true;
|
newer = true;
|
||||||
}
|
}
|
||||||
// what failed is written with the next change
|
// what failed is written with the next change
|
||||||
Err(error) => console_error(&error.to_string()),
|
Err(error) => {
|
||||||
|
NOT_WRITTEN.set(true);
|
||||||
|
console_error(&error.to_string());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SAVING.set(false);
|
SAVING.set(false);
|
||||||
@@ -188,17 +199,18 @@ pub(crate) fn to_js_error(error: engine::StorageError) -> JsValue {
|
|||||||
JsValue::from_str(&error.to_string())
|
JsValue::from_str(&error.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves when what was changed has been written to the storage.
|
/// Resolves when the writes of what was changed are done, to whether they went well. A write that
|
||||||
|
/// was refused because another tab wrote since is fine: this tab then reads what that tab wrote.
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
pub async fn flush_storage() -> Result<(), JsValue> {
|
pub async fn flush_storage() -> Result<bool, JsValue> {
|
||||||
if !SAVING.get() {
|
if !SAVING.get() {
|
||||||
return Ok(());
|
return Ok(!NOT_WRITTEN.get());
|
||||||
}
|
}
|
||||||
let promise = js_sys::Promise::new(&mut |resolve, _| {
|
let promise = js_sys::Promise::new(&mut |resolve, _| {
|
||||||
WAITERS.with(|waiters| waiters.borrow_mut().push(resolve));
|
WAITERS.with(|waiters| waiters.borrow_mut().push(resolve));
|
||||||
});
|
});
|
||||||
wasm_bindgen_futures::JsFuture::from(promise).await?;
|
wasm_bindgen_futures::JsFuture::from(promise).await?;
|
||||||
Ok(())
|
Ok(!NOT_WRITTEN.get())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// If another tab wrote the files since this one last did, reads them again and overwrites the
|
/// If another tab wrote the files since this one last did, reads them again and overwrites the
|
||||||
|
|||||||
@@ -101,18 +101,6 @@ pub fn waypoint(file_id: &str, waypoint_id: &str) -> WaypointDetails {
|
|||||||
optional_js(details).unchecked_into()
|
optional_js(details).unchecked_into()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A trackpoint of a segment of a file, `undefined` if it does not exist.
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn trackpoint(file_id: &str, segment_id: &str, index: usize) -> TrackpointDetails {
|
|
||||||
let details =
|
|
||||||
parse_uuid(file_id)
|
|
||||||
.zip(parse_uuid(segment_id))
|
|
||||||
.and_then(|(file_id, segment_id)| {
|
|
||||||
with_engine(|e| api::trackpoint(e, file_id, segment_id, index)).flatten()
|
|
||||||
});
|
|
||||||
optional_js(details).unchecked_into()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What was copied or cut and is waiting to be pasted, `undefined` if nothing: the kind of the
|
/// What was copied or cut and is waiting to be pasted, `undefined` if nothing: the kind of the
|
||||||
/// elements and their ids (UUID strings).
|
/// elements and their ids (UUID strings).
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
|
|||||||
@@ -59,14 +59,6 @@ export interface WaypointDetails {
|
|||||||
links?: { href: string; text?: string }[];
|
links?: { href: string; text?: string }[];
|
||||||
sym?: string;
|
sym?: string;
|
||||||
}
|
}
|
||||||
/** The position, elevation and time of a trackpoint. */
|
|
||||||
export interface TrackpointDetails {
|
|
||||||
lng: number;
|
|
||||||
lat: number;
|
|
||||||
ele: number;
|
|
||||||
/** ms since epoch */
|
|
||||||
time?: number;
|
|
||||||
}
|
|
||||||
/**
|
/**
|
||||||
* A trackpoint of the selection: what the statistics buffers do not hold for each trackpoint.
|
* A trackpoint of the selection: what the statistics buffers do not hold for each trackpoint.
|
||||||
* Absent fields are unknown.
|
* Absent fields are unknown.
|
||||||
@@ -189,8 +181,6 @@ extern "C" {
|
|||||||
pub type Selection;
|
pub type Selection;
|
||||||
#[wasm_bindgen(typescript_type = "WaypointDetails | undefined")]
|
#[wasm_bindgen(typescript_type = "WaypointDetails | undefined")]
|
||||||
pub type WaypointDetails;
|
pub type WaypointDetails;
|
||||||
#[wasm_bindgen(typescript_type = "TrackpointDetails | undefined")]
|
|
||||||
pub type TrackpointDetails;
|
|
||||||
#[wasm_bindgen(typescript_type = "SelectionPoint | undefined")]
|
#[wasm_bindgen(typescript_type = "SelectionPoint | undefined")]
|
||||||
pub type SelectionPoint;
|
pub type SelectionPoint;
|
||||||
#[wasm_bindgen(typescript_type = "MoveTarget")]
|
#[wasm_bindgen(typescript_type = "MoveTarget")]
|
||||||
|
|||||||
Generated
+22
@@ -32,6 +32,8 @@
|
|||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@tailwindcss/vite": "^4.1.8",
|
"@tailwindcss/vite": "^4.1.8",
|
||||||
"@tauri-apps/api": "^2.12.3",
|
"@tauri-apps/api": "^2.12.3",
|
||||||
|
"@tauri-apps/plugin-process": "^2.4.0",
|
||||||
|
"@tauri-apps/plugin-updater": "^2.14.1",
|
||||||
"@types/eslint": "^9.6.1",
|
"@types/eslint": "^9.6.1",
|
||||||
"@types/events": "^3.0.3",
|
"@types/events": "^3.0.3",
|
||||||
"@types/file-saver": "^2.0.7",
|
"@types/file-saver": "^2.0.7",
|
||||||
@@ -2667,6 +2669,26 @@
|
|||||||
"url": "https://opencollective.com/tauri"
|
"url": "https://opencollective.com/tauri"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tauri-apps/plugin-process": {
|
||||||
|
"version": "2.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-process/-/plugin-process-2.4.0.tgz",
|
||||||
|
"integrity": "sha512-GddrmsSLAYrLa/yTXlXK/YRDzUgYeeOnUXTbiyvJWkmI424RmDPD3FVLeqcon7rv6O6ZfhBilcrHWnSo7o6hIQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tauri-apps/plugin-updater": {
|
||||||
|
"version": "2.14.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.14.1.tgz",
|
||||||
|
"integrity": "sha512-Hr7nlBm1wT6xbwzjZ/S36YULazu8MNlj5vvOwMl47fXI7HTa5Bjl1PbDEwVHaZTAKzE1VZsUy35UqnbrQxmYQw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/cookie": {
|
"node_modules/@types/cookie": {
|
||||||
"version": "0.6.0",
|
"version": "0.6.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
|||||||
@@ -27,6 +27,8 @@
|
|||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@tailwindcss/vite": "^4.1.8",
|
"@tailwindcss/vite": "^4.1.8",
|
||||||
"@tauri-apps/api": "^2.12.3",
|
"@tauri-apps/api": "^2.12.3",
|
||||||
|
"@tauri-apps/plugin-process": "^2.4.0",
|
||||||
|
"@tauri-apps/plugin-updater": "^2.14.1",
|
||||||
"@types/eslint": "^9.6.1",
|
"@types/eslint": "^9.6.1",
|
||||||
"@types/events": "^3.0.3",
|
"@types/events": "^3.0.3",
|
||||||
"@types/file-saver": "^2.0.7",
|
"@types/file-saver": "^2.0.7",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
statistics,
|
statistics,
|
||||||
slicedStatistics,
|
slicedStatistics,
|
||||||
hoveredPoint,
|
hoveredPoint,
|
||||||
|
hoveredTrackpoint,
|
||||||
additionalDatasets,
|
additionalDatasets,
|
||||||
elevationFill,
|
elevationFill,
|
||||||
showControls = true,
|
showControls = true,
|
||||||
@@ -38,6 +39,7 @@
|
|||||||
statistics: Readable<SelectionStatistics>;
|
statistics: Readable<SelectionStatistics>;
|
||||||
slicedStatistics: Writable<SlicedStatistics | undefined>;
|
slicedStatistics: Writable<SlicedStatistics | undefined>;
|
||||||
hoveredPoint: Writable<Coordinates | null>;
|
hoveredPoint: Writable<Coordinates | null>;
|
||||||
|
hoveredTrackpoint?: Readable<number | null>;
|
||||||
additionalDatasets: Writable<string[]>;
|
additionalDatasets: Writable<string[]>;
|
||||||
elevationFill: Writable<'slope' | 'surface' | 'highway' | undefined>;
|
elevationFill: Writable<'slope' | 'surface' | 'highway' | undefined>;
|
||||||
showControls?: boolean;
|
showControls?: boolean;
|
||||||
@@ -55,7 +57,8 @@
|
|||||||
additionalDatasets,
|
additionalDatasets,
|
||||||
elevationFill,
|
elevationFill,
|
||||||
canvas,
|
canvas,
|
||||||
overlay
|
overlay,
|
||||||
|
hoveredTrackpoint
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -15,10 +15,15 @@ import {
|
|||||||
getVelocityWithUnits,
|
getVelocityWithUnits,
|
||||||
} from '$lib/units/units';
|
} from '$lib/units/units';
|
||||||
import Chart, {
|
import Chart, {
|
||||||
|
Tooltip,
|
||||||
|
type ActiveDataPoint,
|
||||||
type ChartEvent,
|
type ChartEvent,
|
||||||
type ChartOptions,
|
type ChartOptions,
|
||||||
|
type ChartType,
|
||||||
|
type Point,
|
||||||
type ScriptableLineSegmentContext,
|
type ScriptableLineSegmentContext,
|
||||||
type TooltipItem,
|
type TooltipItem,
|
||||||
|
type TooltipPositionerFunction,
|
||||||
} from 'chart.js/auto';
|
} from 'chart.js/auto';
|
||||||
import { get, type Readable, type Writable } from 'svelte/store';
|
import { get, type Readable, type Writable } from 'svelte/store';
|
||||||
import type { Coordinates } from '$lib/geo/coordinates';
|
import type { Coordinates } from '$lib/geo/coordinates';
|
||||||
@@ -40,6 +45,25 @@ const { distanceUnits, velocityUnits, temperatureUnits } = settings;
|
|||||||
Chart.defaults.font.family =
|
Chart.defaults.font.family =
|
||||||
'ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"'; // Tailwind CSS font
|
'ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"'; // Tailwind CSS font
|
||||||
|
|
||||||
|
declare module 'chart.js' {
|
||||||
|
interface TooltipPositionerMap {
|
||||||
|
trackpoint: TooltipPositionerFunction<ChartType>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marks the positions of the trackpoints shown without an event of the chart. */
|
||||||
|
const trackpointPosition = Symbol('trackpointPosition');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like the default for the events of the chart. A trackpoint that is shown without one is given by
|
||||||
|
* its position, as it can be between the points of the chart (see `activateTrackpoint`).
|
||||||
|
*/
|
||||||
|
Tooltip.positioners.trackpoint = function (items, position) {
|
||||||
|
return trackpointPosition in position
|
||||||
|
? position
|
||||||
|
: Tooltip.positioners.average.call(this, items, position);
|
||||||
|
};
|
||||||
|
|
||||||
/** `undefined` for a missing measure (NaN), so that Chart.js leaves a gap instead of a value. */
|
/** `undefined` for a missing measure (NaN), so that Chart.js leaves a gap instead of a value. */
|
||||||
function optional(value: number, convert: (value: number) => number = (v) => v) {
|
function optional(value: number, convert: (value: number) => number = (v) => v) {
|
||||||
return Number.isNaN(value) ? undefined : convert(value);
|
return Number.isNaN(value) ? undefined : convert(value);
|
||||||
@@ -55,6 +79,21 @@ interface ElevationProfilePoint {
|
|||||||
index: number;
|
index: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The index of the point the closest to `x`, in points sorted by `x`. */
|
||||||
|
function nearestPointIndex(points: { x: number }[], x: number): number {
|
||||||
|
let low = 0;
|
||||||
|
let high = points.length - 1;
|
||||||
|
while (low < high) {
|
||||||
|
const middle = (low + high) >> 1;
|
||||||
|
if (points[middle].x < x) {
|
||||||
|
low = middle + 1;
|
||||||
|
} else {
|
||||||
|
high = middle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return low > 0 && x - points[low - 1].x < points[low].x - x ? low - 1 : low;
|
||||||
|
}
|
||||||
|
|
||||||
export class ElevationProfile {
|
export class ElevationProfile {
|
||||||
private _chart: Chart | null = null;
|
private _chart: Chart | null = null;
|
||||||
private _canvas: HTMLCanvasElement;
|
private _canvas: HTMLCanvasElement;
|
||||||
@@ -71,10 +110,14 @@ export class ElevationProfile {
|
|||||||
*/
|
*/
|
||||||
private _hovered: { index: number; loaded: boolean; point?: SelectionPoint } | null = null;
|
private _hovered: { index: number; loaded: boolean; point?: SelectionPoint } | null = null;
|
||||||
private _loadingHovered = false;
|
private _loadingHovered = false;
|
||||||
/** The last move over the chart, held until the data of its trackpoint is read. */
|
|
||||||
private _heldMove: MouseEvent | null = null;
|
|
||||||
private _slicedStatistics: Writable<SlicedStatistics | undefined>;
|
private _slicedStatistics: Writable<SlicedStatistics | undefined>;
|
||||||
private _hoveredPoint: Writable<Coordinates | null>;
|
private _hoveredPoint: Writable<Coordinates | null>;
|
||||||
|
/** All the points of each dataset, including the ones that the chart decimated. */
|
||||||
|
private _points: ElevationProfilePoint[][] = [];
|
||||||
|
/** The trackpoint hovered somewhere else, that the tooltip shows instead of its elements. */
|
||||||
|
private _shownTrackpoint: number | null = null;
|
||||||
|
private _hoveredTrackpoint: Readable<number | null> | undefined;
|
||||||
|
private _unsubscribeHoveredTrackpoint: (() => void) | undefined;
|
||||||
private _additionalDatasets: Readable<string[]>;
|
private _additionalDatasets: Readable<string[]>;
|
||||||
private _elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>;
|
private _elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>;
|
||||||
/** The statistics that are only read from the engine while the profile shows them. */
|
/** The statistics that are only read from the engine while the profile shows them. */
|
||||||
@@ -87,7 +130,8 @@ export class ElevationProfile {
|
|||||||
additionalDatasets: Readable<string[]>,
|
additionalDatasets: Readable<string[]>,
|
||||||
elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>,
|
elevationFill: Readable<'slope' | 'surface' | 'highway' | undefined>,
|
||||||
canvas: HTMLCanvasElement,
|
canvas: HTMLCanvasElement,
|
||||||
overlay: HTMLCanvasElement
|
overlay: HTMLCanvasElement,
|
||||||
|
hoveredTrackpoint?: Readable<number | null>
|
||||||
) {
|
) {
|
||||||
this._statistics = statistics;
|
this._statistics = statistics;
|
||||||
this._slicedStatistics = slicedStatistics;
|
this._slicedStatistics = slicedStatistics;
|
||||||
@@ -96,6 +140,7 @@ export class ElevationProfile {
|
|||||||
this._elevationFill = elevationFill;
|
this._elevationFill = elevationFill;
|
||||||
this._canvas = canvas;
|
this._canvas = canvas;
|
||||||
this._overlay = overlay;
|
this._overlay = overlay;
|
||||||
|
this._hoveredTrackpoint = hoveredTrackpoint;
|
||||||
this._request = engine.requestStatistics();
|
this._request = engine.requestStatistics();
|
||||||
this.updateRequest();
|
this.updateRequest();
|
||||||
|
|
||||||
@@ -127,6 +172,9 @@ export class ElevationProfile {
|
|||||||
this.updateRequest();
|
this.updateRequest();
|
||||||
this.updateFill();
|
this.updateFill();
|
||||||
});
|
});
|
||||||
|
this._unsubscribeHoveredTrackpoint = this._hoveredTrackpoint?.subscribe((index) => {
|
||||||
|
this.showTrackpoint(index);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,20 +243,16 @@ export class ElevationProfile {
|
|||||||
},
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
enabled: () => !this._dragging && !this._panning,
|
enabled: () => !this._dragging && !this._panning,
|
||||||
|
position: 'trackpoint',
|
||||||
callbacks: {
|
callbacks: {
|
||||||
title: () => {
|
title: () => {
|
||||||
return '';
|
return '';
|
||||||
},
|
},
|
||||||
label: (context: TooltipItem<'line'>) => {
|
label: (context: TooltipItem<'line'>) => {
|
||||||
const point = context.raw as ElevationProfilePoint;
|
const point = this.pointOf(context);
|
||||||
if (context.datasetIndex === 0) {
|
if (context.datasetIndex === 0) {
|
||||||
this._hoveredPoint.set(
|
this._hoveredPoint.set(
|
||||||
this._dragging
|
this._dragging ? null : this.coordinatesOf(point.index)
|
||||||
? null
|
|
||||||
: {
|
|
||||||
lat: this._data.lat[point.index],
|
|
||||||
lng: this._data.lng[point.index],
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
return `${i18n._('quantities.elevation')}: ${getElevationWithUnits(point.y, false)}`;
|
return `${i18n._('quantities.elevation')}: ${getElevationWithUnits(point.y, false)}`;
|
||||||
} else if (context.datasetIndex === 1) {
|
} else if (context.datasetIndex === 1) {
|
||||||
@@ -228,7 +272,7 @@ export class ElevationProfile {
|
|||||||
(context) => context.datasetIndex === 0
|
(context) => context.datasetIndex === 0
|
||||||
);
|
);
|
||||||
if (context.length === 0) return;
|
if (context.length === 0) return;
|
||||||
const point = context[0].raw as ElevationProfilePoint;
|
const point = this.pointOf(context[0]);
|
||||||
// the rest of the data is there once it is read (see `hover`)
|
// the rest of the data is there once it is read (see `hover`)
|
||||||
const hovered = this._hovered;
|
const hovered = this._hovered;
|
||||||
const details =
|
const details =
|
||||||
@@ -365,29 +409,21 @@ export class ElevationProfile {
|
|||||||
afterEvent: (chart: Chart, args: { event: ChartEvent }) => {
|
afterEvent: (chart: Chart, args: { event: ChartEvent }) => {
|
||||||
if (args.event.type === 'mouseout') {
|
if (args.event.type === 'mouseout') {
|
||||||
this._hovered = null;
|
this._hovered = null;
|
||||||
this._heldMove = null;
|
|
||||||
this._hoveredPoint.set(null);
|
this._hoveredPoint.set(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// The tooltip only shows a trackpoint once all its data is read: a move to
|
// The tooltip follows the pointer at once with what the chart knows of the
|
||||||
// another one is held until then, and replayed (see `loadHovered`).
|
// trackpoint, and shows the rest of its data once it is read (see
|
||||||
id: 'waitForTrackpoint',
|
// `loadHovered`).
|
||||||
|
id: 'loadTrackpoint',
|
||||||
events: ['mousemove'],
|
events: ['mousemove'],
|
||||||
beforeEvent: (chart: Chart, args: { event: ChartEvent }) => {
|
beforeEvent: (chart: Chart, args: { event: ChartEvent }) => {
|
||||||
const index = this.indexAt(args.event);
|
const index = this.indexAt(args.event);
|
||||||
if (index === undefined) {
|
if (index !== undefined) {
|
||||||
return;
|
this.hover(index);
|
||||||
}
|
}
|
||||||
const hovered = this._hovered;
|
|
||||||
if (hovered?.index === index && hovered.loaded) {
|
|
||||||
this._heldMove = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._heldMove = args.event.native as MouseEvent;
|
|
||||||
this.hover(index);
|
|
||||||
return false;
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -478,6 +514,7 @@ export class ElevationProfile {
|
|||||||
}
|
}
|
||||||
const data = get(this._statistics);
|
const data = get(this._statistics);
|
||||||
this._data = data;
|
this._data = data;
|
||||||
|
const shownTrackpoint = this._shownTrackpoint;
|
||||||
// the indices of the trackpoints are the ones of other statistics
|
// the indices of the trackpoints are the ones of other statistics
|
||||||
this._hovered = null;
|
this._hovered = null;
|
||||||
const units = {
|
const units = {
|
||||||
@@ -524,6 +561,7 @@ export class ElevationProfile {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this._points = datasets;
|
||||||
this._chart.data.datasets[0] = {
|
this._chart.data.datasets[0] = {
|
||||||
label: i18n._('quantities.elevation'),
|
label: i18n._('quantities.elevation'),
|
||||||
data: datasets[0],
|
data: datasets[0],
|
||||||
@@ -570,6 +608,10 @@ export class ElevationProfile {
|
|||||||
this.setFill();
|
this.setFill();
|
||||||
|
|
||||||
this._chart.update();
|
this._chart.update();
|
||||||
|
|
||||||
|
if (shownTrackpoint !== null) {
|
||||||
|
this.showTrackpoint(shownTrackpoint);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateDataVisibility() {
|
updateDataVisibility() {
|
||||||
@@ -678,6 +720,74 @@ export class ElevationProfile {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The point that a line of the tooltip is about. */
|
||||||
|
private pointOf(context: TooltipItem<'line'>): ElevationProfilePoint {
|
||||||
|
return this._shownTrackpoint === null
|
||||||
|
? (context.raw as ElevationProfilePoint)
|
||||||
|
: this._points[context.datasetIndex][this._shownTrackpoint];
|
||||||
|
}
|
||||||
|
|
||||||
|
private coordinatesOf(index: number): Coordinates {
|
||||||
|
return { lat: this._data.lat[index], lng: this._data.lng[index] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shows a trackpoint in the tooltip like when hovering it, or nothing anymore with `null`. */
|
||||||
|
private showTrackpoint(index: number | null) {
|
||||||
|
const shown = this._shownTrackpoint;
|
||||||
|
this._shownTrackpoint = index;
|
||||||
|
if (index !== null) {
|
||||||
|
this.hover(index);
|
||||||
|
if (this._hovered?.loaded) {
|
||||||
|
this.activateTrackpoint(index);
|
||||||
|
}
|
||||||
|
} else if (shown !== null) {
|
||||||
|
this._hovered = null;
|
||||||
|
this.setActiveElements([], { x: 0, y: 0 });
|
||||||
|
this._hoveredPoint.set(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the tooltip on a trackpoint, once its data is read. The chart can only activate the
|
||||||
|
* points that it kept after decimating its data, which are different for each dataset and might
|
||||||
|
* not include the trackpoint: it activates the nearest ones, within a pixel of it, and the
|
||||||
|
* tooltip reads the values of the trackpoint (see `pointOf`).
|
||||||
|
*/
|
||||||
|
private activateTrackpoint(index: number) {
|
||||||
|
const point = this._points[0]?.[index];
|
||||||
|
if (!this._chart || !point) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { x: xScale, y: yScale } = this._chart.scales;
|
||||||
|
const elements: ActiveDataPoint[] = [];
|
||||||
|
// when the chart is zoomed, the trackpoint can be outside of it
|
||||||
|
if (xScale.min <= point.x && point.x <= xScale.max) {
|
||||||
|
for (const { index: datasetIndex } of this._chart.getSortedVisibleDatasetMetas()) {
|
||||||
|
if (this._points[datasetIndex]?.[index]?.y !== undefined) {
|
||||||
|
const data = this._chart.data.datasets[datasetIndex].data as { x: number }[];
|
||||||
|
elements.push({ datasetIndex, index: nearestPointIndex(data, point.x) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (elements.length === 0) {
|
||||||
|
// no tooltip to tell the map about the point
|
||||||
|
this._hoveredPoint.set(this.coordinatesOf(index));
|
||||||
|
}
|
||||||
|
this.setActiveElements(elements, {
|
||||||
|
x: xScale.getPixelForValue(point.x),
|
||||||
|
y: yScale.getPixelForValue(point.y),
|
||||||
|
[trackpointPosition]: true,
|
||||||
|
} as Point);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setActiveElements(elements: ActiveDataPoint[], position: Point) {
|
||||||
|
if (this._chart) {
|
||||||
|
this._chart.tooltip?.setActiveElements(elements, position);
|
||||||
|
this._chart.setActiveElements(elements);
|
||||||
|
this._chart.render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** The trackpoint that the tooltip shows for an event, `undefined` outside of the chart. */
|
/** The trackpoint that the tooltip shows for an event, `undefined` outside of the chart. */
|
||||||
private indexAt(event: ChartEvent): number | undefined {
|
private indexAt(event: ChartEvent): number | undefined {
|
||||||
const elements = this._chart?.getElementsAtEventForMode(
|
const elements = this._chart?.getElementsAtEventForMode(
|
||||||
@@ -718,11 +828,10 @@ export class ElevationProfile {
|
|||||||
}
|
}
|
||||||
hovered.loaded = true;
|
hovered.loaded = true;
|
||||||
hovered.point = point;
|
hovered.point = point;
|
||||||
// the move that was held can now show the tooltip
|
if (this._shownTrackpoint === hovered.index) {
|
||||||
const move = this._heldMove;
|
this.activateTrackpoint(hovered.index);
|
||||||
this._heldMove = null;
|
} else {
|
||||||
if (move) {
|
this.refreshTooltip();
|
||||||
this._canvas.dispatchEvent(new MouseEvent(move.type, move));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -730,6 +839,18 @@ export class ElevationProfile {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Writes the tooltip again, on the same trackpoint, for the data that was read since. */
|
||||||
|
private refreshTooltip() {
|
||||||
|
// the plugin of the tooltip, whose type only has its model
|
||||||
|
const tooltip = this._chart?.tooltip as
|
||||||
|
| (Chart['tooltip'] & { update(changed: boolean): void })
|
||||||
|
| undefined;
|
||||||
|
if (tooltip && tooltip.getActiveElements().length > 0) {
|
||||||
|
tooltip.update(true);
|
||||||
|
this._chart!.render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** The trackpoint that a segment of the chart starts at. */
|
/** The trackpoint that a segment of the chart starts at. */
|
||||||
private static segmentStart(context: ScriptableLineSegmentContext & { p0: { raw: any } }) {
|
private static segmentStart(context: ScriptableLineSegmentContext & { p0: { raw: any } }) {
|
||||||
return (context.p0.raw as ElevationProfilePoint).index;
|
return (context.p0.raw as ElevationProfilePoint).index;
|
||||||
@@ -758,6 +879,7 @@ export class ElevationProfile {
|
|||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
this._request.release();
|
this._request.release();
|
||||||
|
this._unsubscribeHoveredTrackpoint?.();
|
||||||
if (this._chart) {
|
if (this._chart) {
|
||||||
this._chart.destroy();
|
this._chart.destroy();
|
||||||
this._chart = null;
|
this._chart = null;
|
||||||
|
|||||||
@@ -18,7 +18,11 @@
|
|||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { isSelected, toggle } from '$lib/components/map/layer-control/utils';
|
import { isSelected, toggle } from '$lib/components/map/layer-control/utils';
|
||||||
import { engine } from '$lib/engine';
|
import { engine } from '$lib/engine';
|
||||||
import { hoveredPoint, slicedStatistics } from '$lib/state/selection-statistics';
|
import {
|
||||||
|
hoveredPoint,
|
||||||
|
hoveredTrackpoint,
|
||||||
|
slicedStatistics,
|
||||||
|
} from '$lib/state/selection-statistics';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
useHash = true,
|
useHash = true,
|
||||||
@@ -119,6 +123,7 @@
|
|||||||
{statistics}
|
{statistics}
|
||||||
{slicedStatistics}
|
{slicedStatistics}
|
||||||
{hoveredPoint}
|
{hoveredPoint}
|
||||||
|
{hoveredTrackpoint}
|
||||||
{additionalDatasets}
|
{additionalDatasets}
|
||||||
{elevationFill}
|
{elevationFill}
|
||||||
showControls={options.elevation.controls}
|
showControls={options.elevation.controls}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import { StartEndMarkers } from '$lib/components/map/gpx-layer/start-end-markers';
|
import { StartEndMarkers } from '$lib/components/map/gpx-layer/start-end-markers';
|
||||||
import { createPopups, removePopups } from '$lib/components/map/gpx-layer/gpx-layer-popup';
|
import { createPopups, removePopups } from '$lib/components/map/gpx-layer/gpx-layer-popup';
|
||||||
import { map } from '$lib/components/map/map';
|
import { map } from '$lib/components/map/map';
|
||||||
|
import { hoveredTrackpoint } from '$lib/state/selection-statistics';
|
||||||
|
|
||||||
let distanceMarkers: DistanceMarkers;
|
let distanceMarkers: DistanceMarkers;
|
||||||
let startEndMarkers: StartEndMarkers;
|
let startEndMarkers: StartEndMarkers;
|
||||||
@@ -14,6 +15,11 @@
|
|||||||
startEndMarkers = new StartEndMarkers();
|
startEndMarkers = new StartEndMarkers();
|
||||||
distanceMarkers = new DistanceMarkers();
|
distanceMarkers = new DistanceMarkers();
|
||||||
createPopups(map_);
|
createPopups(map_);
|
||||||
|
// the layers do not see the pointer when it leaves the map or when it drags something
|
||||||
|
const stopHovering = () => hoveredTrackpoint.set(null);
|
||||||
|
map_.on('mouseout', stopHovering);
|
||||||
|
map_.on('mousedown', stopHovering);
|
||||||
|
map_.on('movestart', stopHovering);
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
|
|||||||
@@ -2,17 +2,15 @@
|
|||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import CopyCoordinates from '$lib/components/map/gpx-layer/CopyCoordinates.svelte';
|
import CopyCoordinates from '$lib/components/map/gpx-layer/CopyCoordinates.svelte';
|
||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
import WithUnits from '$lib/components/WithUnits.svelte';
|
import { Compass, Earth } from '@lucide/svelte';
|
||||||
import { Compass, Earth, Mountain, Timer } from '@lucide/svelte';
|
|
||||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
import type { PopupItem } from '$lib/components/map/map-popup';
|
import type { PopupItem } from '$lib/components/map/map-popup';
|
||||||
import type { TrackpointDetails } from '$lib/engine';
|
|
||||||
import { map } from '$lib/components/map/map';
|
import { map } from '$lib/components/map/map';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
trackpoint,
|
trackpoint,
|
||||||
}: {
|
}: {
|
||||||
trackpoint: PopupItem<Pick<TrackpointDetails, 'lng' | 'lat'> & Partial<TrackpointDetails>>;
|
trackpoint: PopupItem<{ lng: number; lat: number }>;
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -22,33 +20,19 @@
|
|||||||
<Compass size="14" />
|
<Compass size="14" />
|
||||||
{trackpoint.item.lat.toFixed(6)}° {trackpoint.item.lng.toFixed(6)}°
|
{trackpoint.item.lat.toFixed(6)}° {trackpoint.item.lng.toFixed(6)}°
|
||||||
</div>
|
</div>
|
||||||
{#if trackpoint.item.ele !== undefined}
|
|
||||||
<div class="flex flex-row items-center gap-1">
|
|
||||||
<Mountain size="14" />
|
|
||||||
<WithUnits value={trackpoint.item.ele} type="elevation" />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if trackpoint.item.time !== undefined}
|
|
||||||
<div class="flex flex-row items-center gap-1">
|
|
||||||
<Timer size="14" />
|
|
||||||
{i18n.df.format(new Date(trackpoint.item.time))}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<CopyCoordinates
|
<CopyCoordinates
|
||||||
coordinates={trackpoint.item}
|
coordinates={trackpoint.item}
|
||||||
onCopy={() => trackpoint.hide?.()}
|
onCopy={() => trackpoint.hide?.()}
|
||||||
class="mt-0.5"
|
class="mt-0.5"
|
||||||
/>
|
/>
|
||||||
{#if trackpoint.fileId === undefined}
|
<Button
|
||||||
<Button
|
variant="outline"
|
||||||
variant="outline"
|
class="justify-start"
|
||||||
class="justify-start"
|
href={`https://www.openstreetmap.org/edit?#map=${(($map?.getZoom() ?? 17) + 1).toFixed(0)}/${trackpoint.item.lat.toFixed(5)}/${trackpoint.item.lng.toFixed(5)}`}
|
||||||
href={`https://www.openstreetmap.org/edit?#map=${(($map?.getZoom() ?? 17) + 1).toFixed(0)}/${trackpoint.item.lat.toFixed(5)}/${trackpoint.item.lng.toFixed(5)}`}
|
target="_blank"
|
||||||
target="_blank"
|
>
|
||||||
>
|
<Earth size="14" />
|
||||||
<Earth size="14" />
|
{i18n._('menu.edit_osm')}
|
||||||
{i18n._('menu.edit_osm')}
|
</Button>
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
@@ -7,8 +7,8 @@ import {
|
|||||||
Point,
|
Point,
|
||||||
} from 'maplibre-gl';
|
} from 'maplibre-gl';
|
||||||
import { map } from '$lib/components/map/map';
|
import { map } from '$lib/components/map/map';
|
||||||
import { waypointPopup, trackpointPopup } from './gpx-layer-popup';
|
import { waypointPopup } from './gpx-layer-popup';
|
||||||
import { closestPointIndex } from '$lib/geo/closest-point';
|
import { closestPointIndexIn } from '$lib/geo/closest-point';
|
||||||
import { getElevation, loadSVGIcon } from '$lib/utils';
|
import { getElevation, loadSVGIcon } from '$lib/utils';
|
||||||
import { selectedWaypoint } from '$lib/components/toolbar/tools/waypoint/waypoint';
|
import { selectedWaypoint } from '$lib/components/toolbar/tools/waypoint/waypoint';
|
||||||
import { MapPin, Square } from 'lucide-static';
|
import { MapPin, Square } from 'lucide-static';
|
||||||
@@ -21,6 +21,7 @@ import { currentTool, Tool } from '$lib/components/toolbar/tools';
|
|||||||
import { splitAtPoint } from '$lib/components/toolbar/tools/scissors/scissors';
|
import { splitAtPoint } from '$lib/components/toolbar/tools/scissors/scissors';
|
||||||
import { mapCursor, MapCursorState } from '$lib/state/map-cursor';
|
import { mapCursor, MapCursorState } from '$lib/state/map-cursor';
|
||||||
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
||||||
|
import { hoveredTrackpoint } from '$lib/state/selection-statistics';
|
||||||
|
|
||||||
export function getSvgForSymbol(symbol?: string | undefined, layerColor?: string | undefined) {
|
export function getSvgForSymbol(symbol?: string | undefined, layerColor?: string | undefined) {
|
||||||
const symbolSvg = symbol ? symbols[symbol]?.iconSvg : undefined;
|
const symbolSvg = symbol ? symbols[symbol]?.iconSvg : undefined;
|
||||||
@@ -63,6 +64,7 @@ export class GPXLayer {
|
|||||||
currentWaypointData: GeoJSON.FeatureCollection | null = null;
|
currentWaypointData: GeoJSON.FeatureCollection | null = null;
|
||||||
draggedWaypointIndex: number | null = null;
|
draggedWaypointIndex: number | null = null;
|
||||||
draggingStartingPosition: Point = new Point(0, 0);
|
draggingStartingPosition: Point = new Point(0, 0);
|
||||||
|
hoversTrackpoint: boolean = false;
|
||||||
unsubscribe: (() => void)[] = [];
|
unsubscribe: (() => void)[] = [];
|
||||||
|
|
||||||
updateBinded: () => void = this.update.bind(this);
|
updateBinded: () => void = this.update.bind(this);
|
||||||
@@ -400,25 +402,55 @@ export class GPXLayer {
|
|||||||
layerOnMouseLeave() {
|
layerOnMouseLeave() {
|
||||||
mapCursor.notify(MapCursorState.SCISSORS, false);
|
mapCursor.notify(MapCursorState.SCISSORS, false);
|
||||||
mapCursor.notify(MapCursorState.LAYER_HOVER, false);
|
mapCursor.notify(MapCursorState.LAYER_HOVER, false);
|
||||||
|
this.stopHoveringTrackpoint();
|
||||||
}
|
}
|
||||||
|
|
||||||
async layerOnMouseMove(e: MapLayerMouseEvent) {
|
layerOnMouseMove(e: MapLayerMouseEvent) {
|
||||||
if (e.originalEvent.shiftKey) {
|
// these tools act on the track under the pointer, and show it themselves
|
||||||
const { segmentId } = e.features![0].properties;
|
const tool = get(currentTool);
|
||||||
const lngLat = { lng: e.lngLat.lng, lat: e.lngLat.lat };
|
const index =
|
||||||
const index = closestPointIndex(await engine.segmentCoordinates(segmentId), lngLat);
|
tool === Tool.ROUTING || tool === Tool.SCISSORS
|
||||||
const trackpoint =
|
? undefined
|
||||||
index === undefined
|
: this.selectedTrackpointAt(e);
|
||||||
? undefined
|
if (index === undefined) {
|
||||||
: await engine.trackpoint(this.fileId, segmentId, index);
|
this.stopHoveringTrackpoint();
|
||||||
if (trackpoint) {
|
} else {
|
||||||
trackpointPopup?.setItem({
|
this.hoversTrackpoint = true;
|
||||||
item: trackpoint,
|
hoveredTrackpoint.set(index);
|
||||||
kind: 'trackpoint',
|
}
|
||||||
fileId: this.fileId,
|
}
|
||||||
});
|
|
||||||
|
stopHoveringTrackpoint() {
|
||||||
|
if (this.hoversTrackpoint) {
|
||||||
|
this.hoversTrackpoint = false;
|
||||||
|
hoveredTrackpoint.set(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The trackpoint of the statistics of the selection that is the closest to the pointer, in the
|
||||||
|
* first segment under it that is part of the selection.
|
||||||
|
*/
|
||||||
|
selectedTrackpointAt(e: MapLayerMouseEvent): number | undefined {
|
||||||
|
const statistics = get(engine.statistics);
|
||||||
|
if (!statistics.segments) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const { starts, ids } = statistics.segments;
|
||||||
|
for (const { properties } of e.features!) {
|
||||||
|
const segment = ids.indexOf(properties.segmentId);
|
||||||
|
if (segment >= 0) {
|
||||||
|
const end = segment + 1 < starts.length ? starts[segment + 1] : statistics.length;
|
||||||
|
return closestPointIndexIn(
|
||||||
|
statistics.lng,
|
||||||
|
statistics.lat,
|
||||||
|
starts[segment],
|
||||||
|
end,
|
||||||
|
e.lngLat
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
layerOnClick(e: MapLayerMouseEvent) {
|
layerOnClick(e: MapLayerMouseEvent) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { FileStateCollectionObserver } from '$lib/engine';
|
import { engine, FileStateCollectionObserver } from '$lib/engine';
|
||||||
import { writable } from 'svelte/store';
|
import { writable } from 'svelte/store';
|
||||||
import { GPXLayer } from './gpx-layer';
|
import { GPXLayer } from './gpx-layer';
|
||||||
|
|
||||||
@@ -14,6 +14,7 @@ export class GPXLayerCollection {
|
|||||||
if (this._fileStateCollectionObserver) {
|
if (this._fileStateCollectionObserver) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
engine.requestStatistics().set(['segments']);
|
||||||
this._fileStateCollectionObserver = new FileStateCollectionObserver(
|
this._fileStateCollectionObserver = new FileStateCollectionObserver(
|
||||||
(newFiles) => {
|
(newFiles) => {
|
||||||
newFiles.forEach((fileStore, fileId) => {
|
newFiles.forEach((fileStore, fileId) => {
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
} from '$lib/data/layers';
|
} from '$lib/data/layers';
|
||||||
import { getLayers, isSelected, toggle } from '$lib/components/map/layer-control/utils';
|
import { getLayers, isSelected, toggle } from '$lib/components/map/layer-control/utils';
|
||||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
import { map } from '$lib/components/map/map';
|
|
||||||
import CustomLayers from './CustomLayers.svelte';
|
import CustomLayers from './CustomLayers.svelte';
|
||||||
import { settings } from '$lib/state/settings';
|
import { settings } from '$lib/state/settings';
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
@@ -209,19 +208,6 @@
|
|||||||
disabled={selectedOverlay === undefined}
|
disabled={selectedOverlay === undefined}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
if (selectedOverlay) {
|
if (selectedOverlay) {
|
||||||
if (
|
|
||||||
$map &&
|
|
||||||
$currentOverlays &&
|
|
||||||
isSelected($currentOverlays, selectedOverlay)
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
if ($map.getLayer(selectedOverlay)) {
|
|
||||||
$map.removeLayer(selectedOverlay);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// No reliable way to check if the map is ready to remove sources and layers
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$opacities[selectedOverlay] = value;
|
$opacities[selectedOverlay] = value;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
let {
|
let {
|
||||||
name,
|
name,
|
||||||
node,
|
node,
|
||||||
|
parent = '',
|
||||||
selected = '',
|
selected = '',
|
||||||
onselect = () => {},
|
onselect = () => {},
|
||||||
multiple = false,
|
multiple = false,
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
}: {
|
}: {
|
||||||
name: string;
|
name: string;
|
||||||
node: LayerTreeType;
|
node: LayerTreeType;
|
||||||
|
parent?: string;
|
||||||
selected?: string;
|
selected?: string;
|
||||||
onselect?: (value: string) => void;
|
onselect?: (value: string) => void;
|
||||||
multiple: boolean;
|
multiple: boolean;
|
||||||
@@ -26,7 +28,21 @@
|
|||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const { customLayers } = settings;
|
const { customLayers } = settings;
|
||||||
const { isLayerFromExtension, getLayerName } = extensionAPI;
|
const { isLayerFromExtension, getLayerName, isExtensionGroup } = extensionAPI;
|
||||||
|
|
||||||
|
/** The groups of extensions are right below the overlays, with the name they were given. */
|
||||||
|
function getGroupLabel(id: string) {
|
||||||
|
return parent === 'overlays' && $isExtensionGroup(id)
|
||||||
|
? id
|
||||||
|
: i18n._(`layers.label.${id}`, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLabel(id: string) {
|
||||||
|
if (Object.hasOwn($customLayers, id)) {
|
||||||
|
return $customLayers[id].name;
|
||||||
|
}
|
||||||
|
return $isLayerFromExtension(id) ? $getLayerName(id) : i18n._(`layers.label.${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
$effect.pre(() => {
|
$effect.pre(() => {
|
||||||
if (checked !== undefined) {
|
if (checked !== undefined) {
|
||||||
@@ -57,7 +73,7 @@
|
|||||||
() => checked[id] === true, (value) => (checked[id] = value)
|
() => checked[id] === true, (value) => (checked[id] = value)
|
||||||
}
|
}
|
||||||
class="scale-90"
|
class="scale-90"
|
||||||
aria-label={i18n._(`layers.label.${id}`)}
|
aria-label={getLabel(id)}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<input
|
<input
|
||||||
@@ -74,25 +90,20 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<Label for="{name}-{id}" class="flex flex-row items-center gap-1">
|
<Label for="{name}-{id}" class="flex flex-row items-center gap-1">
|
||||||
{#if Object.hasOwn($customLayers, id)}
|
{getLabel(id)}
|
||||||
{$customLayers[id].name}
|
|
||||||
{:else if $isLayerFromExtension(id)}
|
|
||||||
{$getLayerName(id)}
|
|
||||||
{:else}
|
|
||||||
{i18n._(`layers.label.${id}`)}
|
|
||||||
{/if}
|
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else if anySelectedLayer(node[id])}
|
{:else if anySelectedLayer(node[id])}
|
||||||
<CollapsibleTreeNode {id}>
|
<CollapsibleTreeNode {id}>
|
||||||
{#snippet trigger()}
|
{#snippet trigger()}
|
||||||
<span>{i18n._(`layers.label.${id}`, id)}</span>
|
<span>{getGroupLabel(id)}</span>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet content()}
|
{#snippet content()}
|
||||||
<div class="ml-2">
|
<div class="ml-2">
|
||||||
<Self
|
<Self
|
||||||
node={node[id] as LayerTreeType}
|
node={node[id] as LayerTreeType}
|
||||||
|
parent={id}
|
||||||
{name}
|
{name}
|
||||||
{selected}
|
{selected}
|
||||||
{onselect}
|
{onselect}
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import { get } from 'svelte/store';
|
|||||||
import { overlays, overlayTree, type LayerTreeType } from '$lib/data/layers';
|
import { overlays, overlayTree, type LayerTreeType } from '$lib/data/layers';
|
||||||
import { engine } from '$lib/engine';
|
import { engine } from '$lib/engine';
|
||||||
import { settings } from '$lib/state/settings';
|
import { settings } from '$lib/state/settings';
|
||||||
import { ExtensionAPI, type CustomOverlay } from './extension-api';
|
import { map } from '$lib/components/map/map';
|
||||||
import { isSelected } from './utils';
|
import type { StyleSpecification } from 'maplibre-gl';
|
||||||
|
import { ExtensionAPI, type CustomOverlay, type ExtensionOverlay } from './extension-api';
|
||||||
|
import { isSelected, prefixStyle } from './utils';
|
||||||
|
|
||||||
const original = { set: engine.setSetting, delete: engine.deleteSetting };
|
const original = { set: engine.setSetting, delete: engine.deleteSetting };
|
||||||
|
|
||||||
@@ -41,6 +43,8 @@ beforeEach(() => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
// the layers are shared by the whole app: leave them as they were
|
// the layers are shared by the whole app: leave them as they were
|
||||||
api.filterOverlays([]);
|
api.filterOverlays([]);
|
||||||
|
api.setOverlays('Heatmaps', []);
|
||||||
|
api.setOverlays('Other', []);
|
||||||
for (const extension of ['My extension', 'Other extension']) {
|
for (const extension of ['My extension', 'Other extension']) {
|
||||||
delete (overlayTree.overlays as LayerTreeType)[extension];
|
delete (overlayTree.overlays as LayerTreeType)[extension];
|
||||||
}
|
}
|
||||||
@@ -151,3 +155,346 @@ describe('ExtensionAPI', () => {
|
|||||||
assert.equal(Object.keys(extension as LayerTreeType).at(-1), 'extension-two');
|
assert.equal(Object.keys(extension as LayerTreeType).at(-1), 'extension-two');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const rasterStyle = (url = 'https://tiles.example.com/{z}/{x}/{y}.png'): StyleSpecification => ({
|
||||||
|
version: 8,
|
||||||
|
sources: { tiles: { type: 'raster', tiles: [url], tileSize: 256 } },
|
||||||
|
layers: [{ id: 'tiles', type: 'raster', source: 'tiles' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const v2 = (id: string, extra: Partial<ExtensionOverlay> = {}): ExtensionOverlay => ({
|
||||||
|
id,
|
||||||
|
name: `Overlay ${id}`,
|
||||||
|
style: rasterStyle(),
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
const id = (overlayId: string, extension = 'Heatmaps') =>
|
||||||
|
`extension-${encodeURIComponent(extension)}/${encodeURIComponent(overlayId)}`;
|
||||||
|
|
||||||
|
/** The overlays of an extension in a tree, with their state. */
|
||||||
|
const group = (tree: LayerTreeType | undefined, extension = 'Heatmaps') =>
|
||||||
|
(tree!.overlays as LayerTreeType)[extension] as LayerTreeType | undefined;
|
||||||
|
|
||||||
|
/** What the user does in the layer menu. */
|
||||||
|
function setShown(overlayId: string, shown: boolean) {
|
||||||
|
settings.currentOverlays.update((tree) => {
|
||||||
|
group(tree)![overlayId] = shown;
|
||||||
|
return tree;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('prefixStyle', () => {
|
||||||
|
it('prefixes the sources, the layers, and the source of the layers', () => {
|
||||||
|
const prefixed = prefixStyle('overlay', {
|
||||||
|
version: 8,
|
||||||
|
sources: { dem: { type: 'raster-dem', tiles: ['https://dem/{z}/{x}/{y}.png'] } },
|
||||||
|
layers: [
|
||||||
|
{ id: 'relief', type: 'color-relief', source: 'dem' },
|
||||||
|
{ id: 'background', type: 'background' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.deepEqual(Object.keys(prefixed.sources), ['overlay/dem']);
|
||||||
|
assert.deepEqual(
|
||||||
|
prefixed.layers.map((layer) => layer.id),
|
||||||
|
['overlay/relief', 'overlay/background']
|
||||||
|
);
|
||||||
|
assert.equal((prefixed.layers[0] as { source: string }).source, 'overlay/dem');
|
||||||
|
assert.equal('source' in prefixed.layers[1], false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a style whose layers cannot work', () => {
|
||||||
|
const style = (layers: unknown) =>
|
||||||
|
({ version: 8, sources: {}, layers }) as StyleSpecification;
|
||||||
|
assert.throws(() => prefixStyle('o', style(undefined)), /list of layers/);
|
||||||
|
assert.throws(
|
||||||
|
() => prefixStyle('o', style([{ id: 'a', type: 'raster', source: 'none' }])),
|
||||||
|
/unknown source/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
prefixStyle(
|
||||||
|
'o',
|
||||||
|
style([
|
||||||
|
{ id: 'a', type: 'background' },
|
||||||
|
{ id: 'a', type: 'background' },
|
||||||
|
])
|
||||||
|
),
|
||||||
|
/repeated layer id/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ExtensionAPI v2', () => {
|
||||||
|
it('adds the overlays of an extension, in the order of the list, hidden by default', () => {
|
||||||
|
api.setOverlays('Heatmaps', [v2('b'), v2('a', { name: 'A' }), v2('c', { visible: true })]);
|
||||||
|
|
||||||
|
const expected = [id('b'), id('a'), id('c')];
|
||||||
|
assert.deepEqual(Object.keys(group(overlayTree)!), expected);
|
||||||
|
assert.deepEqual(Object.keys(group(get(settings.selectedOverlayTree))!), expected);
|
||||||
|
assert.deepEqual(group(get(settings.currentOverlays)), {
|
||||||
|
[id('b')]: false,
|
||||||
|
[id('a')]: false,
|
||||||
|
[id('c')]: true,
|
||||||
|
});
|
||||||
|
assert.ok(get(api.isLayerFromExtension)(id('a')));
|
||||||
|
assert.equal(get(api.getLayerName)(id('a')), 'A');
|
||||||
|
assert.equal(api.version, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the style as is, with prefixed ids', () => {
|
||||||
|
api.setOverlays('Heatmaps', [v2('a')]);
|
||||||
|
const style = overlays[id('a')] as StyleSpecification;
|
||||||
|
assert.deepEqual(Object.keys(style.sources), [`${id('a')}/tiles`]);
|
||||||
|
assert.equal(style.layers[0].id, `${id('a')}/tiles`);
|
||||||
|
assert.equal((style.layers[0] as { source: string }).source, `${id('a')}/tiles`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names its overlays after the extension, the way the v1 API does without it', () => {
|
||||||
|
api.setOverlays('My heatmap', [v2('all')]);
|
||||||
|
assert.ok(isSelected(overlayTree, 'extension-My%20heatmap/all'));
|
||||||
|
api.setOverlays('My heatmap', []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps two extensions with the same overlay ids, or similar names, apart', () => {
|
||||||
|
api.setOverlays('Heatmaps', [v2('a')]);
|
||||||
|
api.setOverlays('Other', [v2('a')]);
|
||||||
|
assert.deepEqual(Object.keys(group(overlayTree)!), [id('a')]);
|
||||||
|
assert.deepEqual(Object.keys(group(overlayTree, 'Other')!), [id('a', 'Other')]);
|
||||||
|
|
||||||
|
api.setOverlays('Heatmaps/a', [v2('b')]);
|
||||||
|
api.setOverlays('Heatmaps', [v2('a'), v2('a/b')]);
|
||||||
|
assert.notEqual(id('a/b'), id('b', 'Heatmaps/a'));
|
||||||
|
assert.ok(get(api.isLayerFromExtension)(id('a/b')));
|
||||||
|
assert.ok(get(api.isLayerFromExtension)(id('b', 'Heatmaps/a')));
|
||||||
|
|
||||||
|
api.setOverlays('Other', []);
|
||||||
|
assert.deepEqual(Object.keys(group(overlayTree)!).length, 2);
|
||||||
|
api.setOverlays('Heatmaps/a', []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('knows the groups of the extensions, of either version', () => {
|
||||||
|
api.setOverlays('Heatmaps', [v2('a')]);
|
||||||
|
api.addOrUpdateOverlay(overlay('one'));
|
||||||
|
assert.equal(get(api.isExtensionGroup)('Heatmaps'), true);
|
||||||
|
assert.equal(get(api.isExtensionGroup)('My extension'), true);
|
||||||
|
assert.equal(get(api.isExtensionGroup)('france'), false);
|
||||||
|
api.setOverlays('Heatmaps', []);
|
||||||
|
assert.equal(get(api.isExtensionGroup)('Heatmaps'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes what is not in the list anymore, with its group when it is empty', () => {
|
||||||
|
api.setOverlays('Heatmaps', [v2('a', { visible: true }), v2('b')]);
|
||||||
|
api.setOverlays('Heatmaps', [v2('b')]);
|
||||||
|
assert.ok(!(id('a') in overlays));
|
||||||
|
assert.equal(get(api.isLayerFromExtension)(id('a')), false);
|
||||||
|
assert.equal(isSelected(get(settings.currentOverlays)!, id('a')), false);
|
||||||
|
assert.deepEqual(Object.keys(group(overlayTree)!), [id('b')]);
|
||||||
|
|
||||||
|
api.setOverlays('Heatmaps', []);
|
||||||
|
assert.equal(group(overlayTree), undefined);
|
||||||
|
assert.equal(group(get(settings.selectedOverlayTree)), undefined);
|
||||||
|
assert.equal(group(get(settings.currentOverlays)), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the state of an overlay unless told otherwise, and the opacity and menu choices', () => {
|
||||||
|
api.setOverlays('Heatmaps', [v2('a'), v2('b')]);
|
||||||
|
setShown(id('a'), true);
|
||||||
|
settings.opacities.update((opacities) => ({ ...opacities, [id('a')]: 0.3 }));
|
||||||
|
settings.selectedOverlayTree.update((tree) => {
|
||||||
|
group(tree)![id('b')] = false;
|
||||||
|
return tree;
|
||||||
|
});
|
||||||
|
|
||||||
|
api.setOverlays('Heatmaps', [v2('b'), v2('a')]);
|
||||||
|
assert.equal(isSelected(get(settings.currentOverlays)!, id('a')), true);
|
||||||
|
assert.equal(isSelected(get(settings.selectedOverlayTree), id('b')), false);
|
||||||
|
assert.equal(get(settings.opacities)[id('a')], 0.3);
|
||||||
|
assert.deepEqual(Object.keys(group(get(settings.selectedOverlayTree))!), [
|
||||||
|
id('b'),
|
||||||
|
id('a'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
api.setOverlays('Heatmaps', [v2('a', { visible: false }), v2('b', { visible: true })]);
|
||||||
|
assert.equal(isSelected(get(settings.currentOverlays)!, id('a')), false);
|
||||||
|
// the user took it out of the menu, where it could not be hidden again
|
||||||
|
assert.equal(isSelected(get(settings.currentOverlays)!, id('b')), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changes nothing when the list is not valid', () => {
|
||||||
|
api.setOverlays('Heatmaps', [v2('a')]);
|
||||||
|
const invalid: unknown[] = [
|
||||||
|
[v2('b'), v2('b')],
|
||||||
|
[v2('b'), { id: 'c', name: 'C' }],
|
||||||
|
[v2('b'), { ...v2('c'), style: { version: 8, sources: {} } }],
|
||||||
|
'not a list',
|
||||||
|
];
|
||||||
|
for (const list of invalid) {
|
||||||
|
assert.throws(() => api.setOverlays('Heatmaps', list as ExtensionOverlay[]));
|
||||||
|
}
|
||||||
|
assert.deepEqual(Object.keys(group(overlayTree)!), [id('a')]);
|
||||||
|
assert.ok(!(id('b') in overlays));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses the names that cannot be the group of an extension', () => {
|
||||||
|
for (const name of ['', 'world', 'countries', '__proto__']) {
|
||||||
|
assert.throws(() => api.setOverlays(name, [v2('a')]));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('waits for the settings', () => {
|
||||||
|
const current = get(settings.currentOverlays);
|
||||||
|
(settings.currentOverlays as unknown as { _value: { set(v: unknown): void } })._value.set(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
assert.throws(() => api.setOverlays('Heatmaps', [v2('a')]), /gpxstudio\.ready/);
|
||||||
|
settings.currentOverlays.set(current!);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tells the extension about the actions of the user only', () => {
|
||||||
|
const calls: Record<string, boolean>[] = [];
|
||||||
|
const stop = api.onVisibilityChange('Heatmaps', (visible) => calls.push(visible));
|
||||||
|
const others: unknown[] = [];
|
||||||
|
api.onVisibilityChange('Other', (visible) => others.push(visible));
|
||||||
|
api.setOverlays('Other', [v2('x')]);
|
||||||
|
|
||||||
|
api.setOverlays('Heatmaps', [v2('a'), v2('b', { visible: true })]);
|
||||||
|
api.setOverlays('Heatmaps', [v2('a', { visible: true }), v2('b', { visible: false })]);
|
||||||
|
assert.deepEqual(calls, []);
|
||||||
|
|
||||||
|
setShown(id('b'), true);
|
||||||
|
assert.deepEqual(calls, [{ a: true, b: true }]);
|
||||||
|
// nothing changed
|
||||||
|
setShown(id('b'), true);
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.deepEqual(others, []);
|
||||||
|
|
||||||
|
stop();
|
||||||
|
setShown(id('b'), false);
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tells the extension when the overlays are all hidden from the menu', () => {
|
||||||
|
const calls: Record<string, boolean>[] = [];
|
||||||
|
api.onVisibilityChange('Heatmaps', (visible) => calls.push(visible));
|
||||||
|
api.setOverlays('Heatmaps', [v2('a', { visible: true })]);
|
||||||
|
settings.currentOverlays.set({ overlays: {} });
|
||||||
|
assert.deepEqual(calls, [{ a: false }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('survives a callback that throws, or that sets the overlays', () => {
|
||||||
|
const error = console.error;
|
||||||
|
console.error = () => {};
|
||||||
|
const calls: unknown[] = [];
|
||||||
|
api.onVisibilityChange('Heatmaps', () => {
|
||||||
|
throw new Error('from the extension');
|
||||||
|
});
|
||||||
|
api.onVisibilityChange('Heatmaps', (visible) => {
|
||||||
|
calls.push(visible);
|
||||||
|
api.setOverlays('Heatmaps', [v2('a', { visible: visible.a })]);
|
||||||
|
});
|
||||||
|
api.setOverlays('Heatmaps', [v2('a')]);
|
||||||
|
setShown(id('a'), true);
|
||||||
|
console.error = error;
|
||||||
|
assert.deepEqual(calls, [{ a: true }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forgets the state of the overlays with the next session', () => {
|
||||||
|
const saved = () => ({
|
||||||
|
currentOverlays: JSON.stringify(get(settings.currentOverlays)),
|
||||||
|
previousOverlays: JSON.stringify(get(settings.previousOverlays)),
|
||||||
|
selectedOverlayTree: JSON.stringify(get(settings.selectedOverlayTree)),
|
||||||
|
opacities: JSON.stringify(get(settings.opacities)),
|
||||||
|
});
|
||||||
|
// what was saved, not the default trees, which are the ones that are allowed
|
||||||
|
settings.connect(saved());
|
||||||
|
|
||||||
|
api.setOverlays('Heatmaps', [v2('a', { visible: true }), v2('b')]);
|
||||||
|
api.addOrUpdateOverlay(overlay('one'));
|
||||||
|
settings.selectedOverlayTree.update((tree) => {
|
||||||
|
group(tree)![id('b')] = false;
|
||||||
|
return tree;
|
||||||
|
});
|
||||||
|
settings.opacities.update((opacities) => ({ ...opacities, [id('a')]: 0.3 }));
|
||||||
|
const stored = saved();
|
||||||
|
assert.ok(stored.currentOverlays.includes(id('a')));
|
||||||
|
|
||||||
|
settings.connect(stored);
|
||||||
|
for (const tree of [
|
||||||
|
get(settings.currentOverlays),
|
||||||
|
get(settings.previousOverlays),
|
||||||
|
get(settings.selectedOverlayTree),
|
||||||
|
]) {
|
||||||
|
assert.ok(!JSON.stringify(tree).includes('extension-'));
|
||||||
|
}
|
||||||
|
assert.equal(get(settings.opacities)[id('a')], 0.3);
|
||||||
|
|
||||||
|
// an extension that is still there starts again, as the source of what it remembers
|
||||||
|
const next = new ExtensionAPI();
|
||||||
|
next.setOverlays('Heatmaps', [v2('a'), v2('b', { visible: true })]);
|
||||||
|
assert.equal(isSelected(get(settings.currentOverlays)!, id('a')), false);
|
||||||
|
assert.equal(isSelected(get(settings.currentOverlays)!, id('b')), true);
|
||||||
|
assert.equal(isSelected(get(settings.selectedOverlayTree), id('b')), true);
|
||||||
|
next.setOverlays('Heatmaps', []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a style URL for the map to fetch', () => {
|
||||||
|
const fetchBefore = globalThis.fetch;
|
||||||
|
globalThis.fetch = (() => assert.fail('fetched')) as typeof fetch;
|
||||||
|
try {
|
||||||
|
const url = 'https://example.com/style.json';
|
||||||
|
api.setOverlays('Heatmaps', [v2('a', { style: url })]);
|
||||||
|
assert.equal(overlays[id('a')], url);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = fetchBefore;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes a style that changes from the map, layers first, but not one that stays', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const prefix = id('a');
|
||||||
|
const fakeMap = {
|
||||||
|
getStyle: () => ({
|
||||||
|
sources: { [`${prefix}/tiles`]: {}, [`${prefix}2/tiles`]: {}, core: {} },
|
||||||
|
layers: [{ id: `${prefix}/tiles` }, { id: `${prefix}2/tiles` }, { id: 'core' }],
|
||||||
|
}),
|
||||||
|
removeLayer: (layer: string) => calls.push(`layer ${layer}`),
|
||||||
|
removeSource: (source: string) => calls.push(`source ${source}`),
|
||||||
|
};
|
||||||
|
const store = (map as unknown as { _mapStore: { set(value: unknown): void } })._mapStore;
|
||||||
|
store.set(fakeMap);
|
||||||
|
try {
|
||||||
|
api.setOverlays('Heatmaps', [v2('a', { visible: true })]);
|
||||||
|
api.setOverlays('Heatmaps', [v2('a', { name: 'Renamed' })]);
|
||||||
|
assert.deepEqual(calls, []);
|
||||||
|
|
||||||
|
api.setOverlays('Heatmaps', [v2('a', { style: rasterStyle('https://other/{z}') })]);
|
||||||
|
assert.deepEqual(calls, [`layer ${prefix}/tiles`, `source ${prefix}/tiles`]);
|
||||||
|
|
||||||
|
calls.length = 0;
|
||||||
|
api.setOverlays('Heatmaps', [v2('a', { style: 'https://example.com/style.json' })]);
|
||||||
|
assert.equal(calls.length, 2);
|
||||||
|
|
||||||
|
calls.length = 0;
|
||||||
|
api.setOverlays('Heatmaps', []);
|
||||||
|
assert.equal(calls.length, 2);
|
||||||
|
} finally {
|
||||||
|
store.set(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is not affected by the v1 API, and can live next to it', () => {
|
||||||
|
api.setOverlays('Heatmaps', [v2('a')]);
|
||||||
|
api.addOrUpdateOverlay(overlay('one', 'Heatmaps'));
|
||||||
|
api.filterOverlays([]);
|
||||||
|
assert.ok(id('a') in overlays);
|
||||||
|
assert.ok(get(api.isLayerFromExtension)(id('a')));
|
||||||
|
assert.ok(isSelected(overlayTree, id('a')));
|
||||||
|
|
||||||
|
api.addOrUpdateOverlay(overlay('one', 'Heatmaps'));
|
||||||
|
api.setOverlays('Heatmaps', [v2('a'), v2('b')]);
|
||||||
|
assert.ok(isSelected(overlayTree, 'extension-one'));
|
||||||
|
api.setOverlays('Heatmaps', []);
|
||||||
|
assert.ok(isSelected(overlayTree, 'extension-one'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
import { settings } from '$lib/state/settings';
|
import { settings } from '$lib/state/settings';
|
||||||
|
import type { StyleSpecification } from 'maplibre-gl';
|
||||||
import { derived, get, writable, type Writable } from 'svelte/store';
|
import { derived, get, writable, type Writable } from 'svelte/store';
|
||||||
import { isSelected, remove, removeAll } from './utils';
|
import { isSelected, prefixStyle, removeAll } from './utils';
|
||||||
import { layerGroup, overlays, overlayTree, type LayerTreeType } from '$lib/data/layers';
|
import { layerGroup, overlays, overlayTree, type LayerTreeType } from '$lib/data/layers';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { map } from '$lib/components/map/map';
|
import { map } from '$lib/components/map/map';
|
||||||
|
|
||||||
const { currentOverlays, previousOverlays, selectedOverlayTree } = settings;
|
const { currentOverlays, previousOverlays, selectedOverlayTree } = settings;
|
||||||
|
|
||||||
|
/** An overlay of the v1 API. */
|
||||||
export type CustomOverlay = {
|
export type CustomOverlay = {
|
||||||
extensionName: string;
|
extensionName: string;
|
||||||
id: string;
|
id: string;
|
||||||
@@ -15,20 +17,97 @@ export type CustomOverlay = {
|
|||||||
maxZoom?: number;
|
maxZoom?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** An overlay of the v2 API. */
|
||||||
|
export type ExtensionOverlay = {
|
||||||
|
/** Unique within the extension. Keep it stable: the opacity and menu choices are tied to it. */
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
* The sources and layers of the overlay, or the URL of a style JSON (which must allow
|
||||||
|
* cross-origin requests). It is used as is, with MapLibre's defaults: a `raster` source has
|
||||||
|
* tiles of 512 pixels unless its `tileSize` says otherwise. Layers can only use the sources of
|
||||||
|
* this style, and the rest of a style (`sprite`, `glyphs`, `terrain`...) is ignored.
|
||||||
|
*/
|
||||||
|
style: StyleSpecification | string;
|
||||||
|
/** Applied if given, otherwise the current state is kept (hidden for a new overlay). */
|
||||||
|
visible?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** What the API knows of an overlay. */
|
||||||
|
type RegisteredOverlay = {
|
||||||
|
extensionName: string;
|
||||||
|
name: string;
|
||||||
|
/** The style given by the extension, to tell when it changes; only for the v2 API. */
|
||||||
|
signature?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type VisibilityCallback = (visible: Record<string, boolean>) => void;
|
||||||
|
|
||||||
|
/** The groups of the overlay tree that an extension cannot put its overlays in. */
|
||||||
|
const reservedGroups = new Set(['__proto__', ...Object.keys(layerGroup(overlayTree.overlays))]);
|
||||||
|
|
||||||
|
/** The id of an overlay of the v1 API, whose methods do not all get the name of the extension. */
|
||||||
|
function getOverlayId(id: string): string {
|
||||||
|
return `extension-${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The id of an overlay of the v2 API. It is encoded so that two extensions cannot clash. */
|
||||||
|
function getExtensionOverlayId(extensionName: string, id: string): string {
|
||||||
|
return getOverlayId(`${encodeURIComponent(extensionName)}/${encodeURIComponent(id)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the overlays of an extension in a tree, in order. Its other entries are dropped, except for
|
||||||
|
* those of `keep`: its overlays of the v1 API, which can be in the same group. An extension that
|
||||||
|
* has nothing left has no group anymore.
|
||||||
|
*/
|
||||||
|
function setGroup(
|
||||||
|
tree: LayerTreeType,
|
||||||
|
extensionName: string,
|
||||||
|
entries: Record<string, boolean>,
|
||||||
|
keep: (id: string) => boolean
|
||||||
|
) {
|
||||||
|
const groups = layerGroup(tree.overlays);
|
||||||
|
const group = groups[extensionName];
|
||||||
|
const kept =
|
||||||
|
typeof group === 'object'
|
||||||
|
? Object.entries(group).filter(([id]) => keep(id) && !Object.hasOwn(entries, id))
|
||||||
|
: [];
|
||||||
|
const updated = { ...entries, ...Object.fromEntries(kept) };
|
||||||
|
if (Object.keys(updated).length > 0) {
|
||||||
|
groups[extensionName] = updated;
|
||||||
|
} else {
|
||||||
|
delete groups[extensionName];
|
||||||
|
}
|
||||||
|
return tree;
|
||||||
|
}
|
||||||
|
|
||||||
export class ExtensionAPI {
|
export class ExtensionAPI {
|
||||||
private _overlays: Writable<Map<string, CustomOverlay>> = writable(new Map());
|
/** The version of the API; the methods of v1 are still there, until extensions have moved. */
|
||||||
|
readonly version = 2;
|
||||||
|
/**
|
||||||
|
* Resolved once overlays can be set. `gpxstudio:extension-api-ready` is sent on `window` at that
|
||||||
|
* moment.
|
||||||
|
*/
|
||||||
|
ready!: Promise<void>;
|
||||||
|
|
||||||
|
private _overlays: Writable<Map<string, RegisteredOverlay>> = writable(new Map());
|
||||||
|
/** What each extension knows of the visibility of its overlays, by its own overlay ids. */
|
||||||
|
private _visibility: Map<string, Record<string, boolean>> = new Map();
|
||||||
|
private _visibilityCallbacks: Map<string, Set<VisibilityCallback>> = new Map();
|
||||||
|
private _watchingVisibility = false;
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
if (browser && !Object.hasOwn(window, 'gpxstudio')) {
|
if (browser && !Object.hasOwn(window, 'gpxstudio')) {
|
||||||
|
this.ready = this.ensureLoaded();
|
||||||
|
this.ready.then(() => window.dispatchEvent(new Event('gpxstudio:extension-api-ready')));
|
||||||
Object.defineProperty(window, 'gpxstudio', {
|
Object.defineProperty(window, 'gpxstudio', {
|
||||||
value: this,
|
value: this,
|
||||||
});
|
});
|
||||||
addEventListener('beforeunload', () => {
|
|
||||||
this.destroy();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @deprecated v1: use `ready`. */
|
||||||
ensureLoaded(): Promise<void> {
|
ensureLoaded(): Promise<void> {
|
||||||
let unsubscribe: () => void;
|
let unsubscribe: () => void;
|
||||||
const promise = new Promise<void>((resolve) => {
|
const promise = new Promise<void>((resolve) => {
|
||||||
@@ -46,6 +125,168 @@ export class ExtensionAPI {
|
|||||||
return promise;
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the overlays of an extension: the order of the list is the order in the layer menu,
|
||||||
|
* and the overlays that are not in the list are removed. The opacity and the choices of the
|
||||||
|
* layer menu are kept for the ones that stay. Throws, changing nothing, if the list is invalid;
|
||||||
|
* a style URL is only fetched when the overlay is shown, and a failure is then only logged.
|
||||||
|
*/
|
||||||
|
setOverlays(extensionName: string, newOverlays: ExtensionOverlay[]) {
|
||||||
|
if (typeof extensionName !== 'string' || !extensionName) {
|
||||||
|
throw new Error('An extension must have a name.');
|
||||||
|
}
|
||||||
|
if (reservedGroups.has(extensionName)) {
|
||||||
|
throw new Error(`The extension name ${extensionName} is reserved.`);
|
||||||
|
}
|
||||||
|
if (!Array.isArray(newOverlays)) {
|
||||||
|
throw new Error('The overlays must be a list.');
|
||||||
|
}
|
||||||
|
const current = get(currentOverlays);
|
||||||
|
if (!current) {
|
||||||
|
throw new Error('Overlays cannot be set yet: wait for `gpxstudio.ready`.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const previous = this.getOverlaysOf(extensionName);
|
||||||
|
const next = new Map<string, RegisteredOverlay>();
|
||||||
|
const styles = new Map<string, StyleSpecification | string>();
|
||||||
|
for (const overlay of newOverlays) {
|
||||||
|
if (!overlay?.id || !overlay.name || !overlay.style) {
|
||||||
|
throw new Error('Overlay must have an id, a name and a style.');
|
||||||
|
}
|
||||||
|
const id = getExtensionOverlayId(extensionName, overlay.id);
|
||||||
|
if (next.has(id)) {
|
||||||
|
throw new Error(`Overlay ${overlay.id} is in the list twice.`);
|
||||||
|
}
|
||||||
|
const signature = JSON.stringify(overlay.style);
|
||||||
|
// the ids of the style at a URL are prefixed when the map fetches it
|
||||||
|
styles.set(
|
||||||
|
id,
|
||||||
|
typeof overlay.style === 'string'
|
||||||
|
? overlay.style
|
||||||
|
: prefixStyle(id, JSON.parse(signature))
|
||||||
|
);
|
||||||
|
next.set(id, { extensionName, name: overlay.name, signature });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing can fail from here. The map does not replace a source that it already has, so
|
||||||
|
// what has changed has to be removed first; it is then added back with the new style.
|
||||||
|
const removedIds: string[] = [];
|
||||||
|
previous.forEach((overlay, id) => {
|
||||||
|
if (next.get(id)?.signature !== overlay.signature) {
|
||||||
|
this.removeFromMap(id);
|
||||||
|
}
|
||||||
|
if (!next.has(id)) {
|
||||||
|
removedIds.push(id);
|
||||||
|
delete overlays[id];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
styles.forEach((style, id) => {
|
||||||
|
overlays[id] = style;
|
||||||
|
});
|
||||||
|
this._overlays.update(($overlays) => {
|
||||||
|
removedIds.forEach((id) => $overlays.delete(id));
|
||||||
|
next.forEach((overlay, id) => $overlays.set(id, overlay));
|
||||||
|
return $overlays;
|
||||||
|
});
|
||||||
|
|
||||||
|
const available: Record<string, boolean> = {};
|
||||||
|
const selected: Record<string, boolean> = {};
|
||||||
|
const shown: Record<string, boolean> = {};
|
||||||
|
const visibility: Record<string, boolean> = {};
|
||||||
|
const selectedGroup = layerGroup(get(selectedOverlayTree).overlays)[extensionName];
|
||||||
|
newOverlays.forEach((overlay) => {
|
||||||
|
const id = getExtensionOverlayId(extensionName, overlay.id);
|
||||||
|
available[id] = true;
|
||||||
|
// an overlay that the user took out of the layer menu could not be hidden again
|
||||||
|
selected[id] =
|
||||||
|
typeof selectedGroup !== 'object' ||
|
||||||
|
!Object.hasOwn(selectedGroup, id) ||
|
||||||
|
selectedGroup[id] === true;
|
||||||
|
shown[id] = selected[id] && Boolean(overlay.visible ?? isSelected(current, id));
|
||||||
|
visibility[overlay.id] = shown[id];
|
||||||
|
});
|
||||||
|
|
||||||
|
const keep = (id: string) => {
|
||||||
|
const overlay = get(this._overlays).get(id);
|
||||||
|
return overlay !== undefined && overlay.signature === undefined;
|
||||||
|
};
|
||||||
|
setGroup(overlayTree, extensionName, available, keep);
|
||||||
|
selectedOverlayTree.update((tree) => setGroup(tree, extensionName, selected, keep));
|
||||||
|
previousOverlays.update((tree) => removeAll(tree, removedIds));
|
||||||
|
// this is not an action of the user: there must be nothing to tell the extension about
|
||||||
|
this._visibility.set(extensionName, visibility);
|
||||||
|
currentOverlays.update((tree) => setGroup(tree, extensionName, shown, keep));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls `callback` with the visibility of all the overlays of the extension, by id, when the
|
||||||
|
* user shows or hides one of them (not when `setOverlays` does). Returns how to stop.
|
||||||
|
*/
|
||||||
|
onVisibilityChange(extensionName: string, callback: VisibilityCallback): () => void {
|
||||||
|
const callbacks = this._visibilityCallbacks.get(extensionName) ?? new Set();
|
||||||
|
this._visibilityCallbacks.set(extensionName, callbacks);
|
||||||
|
callbacks.add(callback);
|
||||||
|
if (!this._watchingVisibility) {
|
||||||
|
this._watchingVisibility = true;
|
||||||
|
currentOverlays.subscribe(() => this.notifyVisibility());
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
callbacks.delete(callback);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifyVisibility() {
|
||||||
|
const current = get(currentOverlays);
|
||||||
|
if (!current) return;
|
||||||
|
this._visibilityCallbacks.forEach((callbacks, extensionName) => {
|
||||||
|
const known = this._visibility.get(extensionName);
|
||||||
|
if (!known) return;
|
||||||
|
const visibility = Object.fromEntries(
|
||||||
|
Object.keys(known).map((id) => [
|
||||||
|
id,
|
||||||
|
isSelected(current, getExtensionOverlayId(extensionName, id)),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
if (Object.keys(known).every((id) => known[id] === visibility[id])) return;
|
||||||
|
this._visibility.set(extensionName, visibility);
|
||||||
|
callbacks.forEach((callback) => {
|
||||||
|
try {
|
||||||
|
callback({ ...visibility });
|
||||||
|
} catch (e) {
|
||||||
|
// an extension must not break the updates of the store for the others
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private getOverlaysOf(extensionName: string): Map<string, RegisteredOverlay> {
|
||||||
|
return new Map(
|
||||||
|
Array.from(get(this._overlays)).filter(
|
||||||
|
([, overlay]) =>
|
||||||
|
overlay.extensionName === extensionName && overlay.signature !== undefined
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes the layers and sources of an overlay: their ids start with the one of the overlay. */
|
||||||
|
private removeFromMap(overlayId: string) {
|
||||||
|
const map_ = get(map);
|
||||||
|
if (!map_) return;
|
||||||
|
try {
|
||||||
|
const style = map_.getStyle();
|
||||||
|
style.layers
|
||||||
|
.filter((layer) => layer.id.startsWith(`${overlayId}/`))
|
||||||
|
.forEach((layer) => map_.removeLayer(layer.id));
|
||||||
|
Object.keys(style.sources)
|
||||||
|
.filter((source) => source.startsWith(`${overlayId}/`))
|
||||||
|
.forEach((source) => map_.removeSource(source));
|
||||||
|
} catch {
|
||||||
|
// No reliable way to check if the map is ready to remove sources and layers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated v1: use `setOverlays`. */
|
||||||
addOrUpdateOverlay(overlay: CustomOverlay) {
|
addOrUpdateOverlay(overlay: CustomOverlay) {
|
||||||
if (
|
if (
|
||||||
!overlay.extensionName ||
|
!overlay.extensionName ||
|
||||||
@@ -58,7 +299,7 @@ export class ExtensionAPI {
|
|||||||
'Overlay must have an extensionName, id, name, and at least one tile URL.'
|
'Overlay must have an extensionName, id, name, and at least one tile URL.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
overlay.id = this.getOverlayId(overlay.id);
|
overlay.id = getOverlayId(overlay.id);
|
||||||
|
|
||||||
this._overlays.update(($overlays) => {
|
this._overlays.update(($overlays) => {
|
||||||
$overlays.set(overlay.id, overlay);
|
$overlays.set(overlay.id, overlay);
|
||||||
@@ -119,11 +360,12 @@ export class ExtensionAPI {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @deprecated v1: use `setOverlays`. Leaves the overlays of the v2 API alone. */
|
||||||
filterOverlays(ids: string[]) {
|
filterOverlays(ids: string[]) {
|
||||||
ids = ids.map((id) => this.getOverlayId(id));
|
ids = ids.map((id) => getOverlayId(id));
|
||||||
const idsToRemove = Array.from(get(this._overlays).keys()).filter(
|
const idsToRemove = Array.from(get(this._overlays))
|
||||||
(id) => !ids.includes(id)
|
.filter(([id, overlay]) => overlay.signature === undefined && !ids.includes(id))
|
||||||
);
|
.map(([id]) => id);
|
||||||
|
|
||||||
currentOverlays.update((current) => {
|
currentOverlays.update((current) => {
|
||||||
removeAll(current, idsToRemove);
|
removeAll(current, idsToRemove);
|
||||||
@@ -153,8 +395,9 @@ export class ExtensionAPI {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @deprecated v1: use `setOverlays`. */
|
||||||
updateOverlaysOrder(ids: string[]) {
|
updateOverlaysOrder(ids: string[]) {
|
||||||
ids = ids.map((id) => this.getOverlayId(id));
|
ids = ids.map((id) => getOverlayId(id));
|
||||||
selectedOverlayTree.update((selected) => {
|
selectedOverlayTree.update((selected) => {
|
||||||
const isSelected: Record<string, boolean> = {};
|
const isSelected: Record<string, boolean> = {};
|
||||||
ids.forEach((id) => {
|
ids.forEach((id) => {
|
||||||
@@ -184,31 +427,11 @@ export class ExtensionAPI {
|
|||||||
return (id: string) => $overlays.get(id)?.name || '';
|
return (id: string) => $overlays.get(id)?.name || '';
|
||||||
});
|
});
|
||||||
|
|
||||||
private getOverlayId(id: string): string {
|
/** Whether a group of the layer trees is an extension, whose name is shown as it was given. */
|
||||||
return `extension-${id}`;
|
isExtensionGroup = derived(this._overlays, ($overlays) => {
|
||||||
}
|
const names = new Set(Array.from($overlays.values(), (overlay) => overlay.extensionName));
|
||||||
|
return (id: string) => names.has(id);
|
||||||
private destroy() {
|
});
|
||||||
const ids = Array.from(get(this._overlays).keys());
|
|
||||||
currentOverlays.update((current) => {
|
|
||||||
ids.forEach((id) => {
|
|
||||||
remove(current, id);
|
|
||||||
});
|
|
||||||
return current;
|
|
||||||
});
|
|
||||||
previousOverlays.update((previous) => {
|
|
||||||
ids.forEach((id) => {
|
|
||||||
remove(previous, id);
|
|
||||||
});
|
|
||||||
return previous;
|
|
||||||
});
|
|
||||||
selectedOverlayTree.update((selected) => {
|
|
||||||
ids.forEach((id) => {
|
|
||||||
remove(selected, id);
|
|
||||||
});
|
|
||||||
return selected;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const extensionAPI = new ExtensionAPI();
|
export const extensionAPI = new ExtensionAPI();
|
||||||
@@ -1,7 +1,16 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { describe, it } from 'node:test';
|
import { describe, it } from 'node:test';
|
||||||
import type { LayerTreeType } from '$lib/data/layers';
|
import type { LayerTreeType } from '$lib/data/layers';
|
||||||
import { anySelectedLayer, getLayers, isSelected, remove, removeAll, toggle } from './utils';
|
import type { LayerSpecification } from 'maplibre-gl';
|
||||||
|
import {
|
||||||
|
anySelectedLayer,
|
||||||
|
getLayers,
|
||||||
|
getOpacityPaint,
|
||||||
|
isSelected,
|
||||||
|
remove,
|
||||||
|
removeAll,
|
||||||
|
toggle,
|
||||||
|
} from './utils';
|
||||||
|
|
||||||
// roads: bike (selected), foot
|
// roads: bike (selected), foot
|
||||||
// satellite: esri, ign: fr (selected)
|
// satellite: esri, ign: fr (selected)
|
||||||
@@ -72,3 +81,39 @@ describe('layer trees', () => {
|
|||||||
assert.deepEqual(removeAll(tree(), []), tree());
|
assert.deepEqual(removeAll(tree(), []), tree());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getOpacityPaint', () => {
|
||||||
|
const layer = (type: string, paint?: Record<string, unknown>) =>
|
||||||
|
({ id: 'layer', type, source: 'source', paint }) as LayerSpecification;
|
||||||
|
|
||||||
|
it('uses the opacity properties of each type of layer', () => {
|
||||||
|
assert.deepEqual(getOpacityPaint(layer('raster'), 0.5), { 'raster-opacity': 0.5 });
|
||||||
|
assert.deepEqual(getOpacityPaint(layer('color-relief'), 0.5), {
|
||||||
|
'color-relief-opacity': 0.5,
|
||||||
|
});
|
||||||
|
assert.deepEqual(getOpacityPaint(layer('symbol'), 0.5), {
|
||||||
|
'icon-opacity': 0.5,
|
||||||
|
'text-opacity': 0.5,
|
||||||
|
});
|
||||||
|
assert.deepEqual(getOpacityPaint(layer('circle'), 0.5), {
|
||||||
|
'circle-opacity': 0.5,
|
||||||
|
'circle-stroke-opacity': 0.5,
|
||||||
|
});
|
||||||
|
assert.deepEqual(getOpacityPaint(layer('hillshade'), 0.5), {
|
||||||
|
'hillshade-exaggeration': 0.25,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scales the opacity that the layer already has', () => {
|
||||||
|
assert.deepEqual(getOpacityPaint(layer('line', { 'line-opacity': 0.5 }), 0.5), {
|
||||||
|
'line-opacity': 0.25,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces an expression, unless the opacity is left at 1', () => {
|
||||||
|
const expression = ['interpolate', ['linear'], ['zoom'], 5, 0, 10, 1];
|
||||||
|
const paint = { 'fill-opacity': expression };
|
||||||
|
assert.deepEqual(getOpacityPaint(layer('fill', paint), 0.5), { 'fill-opacity': 0.5 });
|
||||||
|
assert.deepEqual(getOpacityPaint(layer('fill', paint), 1), { 'fill-opacity': expression });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,89 @@
|
|||||||
import type { LayerTreeType } from '$lib/data/layers';
|
import type { LayerTreeType } from '$lib/data/layers';
|
||||||
|
import type { LayerSpecification, StyleSpecification } from 'maplibre-gl';
|
||||||
|
|
||||||
|
/** Parts of a style that apply to a whole map, not to an overlay. */
|
||||||
|
const ignoredStyleProperties = ['sprite', 'glyphs', 'terrain', 'sky', 'light', 'projection'];
|
||||||
|
/** The overlays that were warned about those: a style at a URL is read again and again. */
|
||||||
|
const warnedOverlays = new Set<string>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A copy of the style of an overlay whose sources and layers have ids of their own, so that they
|
||||||
|
* cannot clash with the ones of gpx.studio or of another overlay.
|
||||||
|
*/
|
||||||
|
export function prefixStyle(overlayId: string, style: StyleSpecification): StyleSpecification {
|
||||||
|
if (typeof style !== 'object' || style === null || !Array.isArray(style.layers)) {
|
||||||
|
throw new Error(`The style of overlay ${overlayId} must have a list of layers.`);
|
||||||
|
}
|
||||||
|
const ignored = ignoredStyleProperties.filter((property) => Object.hasOwn(style, property));
|
||||||
|
if (ignored.length > 0 && !warnedOverlays.has(overlayId)) {
|
||||||
|
warnedOverlays.add(overlayId);
|
||||||
|
console.warn(`Overlay ${overlayId} ignores these style properties: ${ignored.join(', ')}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefixed = (id: string) => `${overlayId}/${id}`;
|
||||||
|
const sources = style.sources ?? {};
|
||||||
|
const layerIds = new Set<string>();
|
||||||
|
const layers = style.layers.map((layer) => {
|
||||||
|
if (!layer.id || layerIds.has(layer.id)) {
|
||||||
|
throw new Error(`Overlay ${overlayId} has a missing or repeated layer id.`);
|
||||||
|
}
|
||||||
|
layerIds.add(layer.id);
|
||||||
|
if ('source' in layer) {
|
||||||
|
if (!Object.hasOwn(sources, layer.source)) {
|
||||||
|
throw new Error(
|
||||||
|
`Layer ${layer.id} of overlay ${overlayId} uses unknown source ${layer.source}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { ...layer, id: prefixed(layer.id), source: prefixed(layer.source) };
|
||||||
|
}
|
||||||
|
return { ...layer, id: prefixed(layer.id) };
|
||||||
|
}) as LayerSpecification[];
|
||||||
|
|
||||||
|
return {
|
||||||
|
...style,
|
||||||
|
sources: Object.fromEntries(
|
||||||
|
Object.entries(sources).map(([id, source]) => [prefixed(id), source])
|
||||||
|
),
|
||||||
|
layers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The paint properties that make a layer transparent, by type of layer. */
|
||||||
|
const opacityProperties: Partial<Record<LayerSpecification['type'], string[]>> = {
|
||||||
|
background: ['background-opacity'],
|
||||||
|
circle: ['circle-opacity', 'circle-stroke-opacity'],
|
||||||
|
'color-relief': ['color-relief-opacity'],
|
||||||
|
fill: ['fill-opacity'],
|
||||||
|
'fill-extrusion': ['fill-extrusion-opacity'],
|
||||||
|
heatmap: ['heatmap-opacity'],
|
||||||
|
line: ['line-opacity'],
|
||||||
|
raster: ['raster-opacity'],
|
||||||
|
symbol: ['icon-opacity', 'text-opacity'],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The paint properties that give a layer an opacity. The opacity of an overlay applies on top of
|
||||||
|
* those of its layers, so that they stay different from one another. A value that is computed
|
||||||
|
* (an expression) cannot always be scaled: the opacity replaces it, unless it is left at 1.
|
||||||
|
*/
|
||||||
|
export function getOpacityPaint(
|
||||||
|
layer: LayerSpecification,
|
||||||
|
opacity: number
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (layer.type === 'hillshade') {
|
||||||
|
return { 'hillshade-exaggeration': opacity / 2 };
|
||||||
|
}
|
||||||
|
const paint = (layer.paint ?? {}) as Record<string, unknown>;
|
||||||
|
return Object.fromEntries(
|
||||||
|
(opacityProperties[layer.type] ?? []).map((property) => {
|
||||||
|
const value = paint[property];
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return [property, value * opacity];
|
||||||
|
}
|
||||||
|
return [property, value !== undefined && opacity === 1 ? value : opacity];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function anySelectedLayer(node: LayerTreeType) {
|
export function anySelectedLayer(node: LayerTreeType) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import MapPopupComponent from '$lib/components/map/MapPopup.svelte';
|
|||||||
export type PopupItem<T = any> = {
|
export type PopupItem<T = any> = {
|
||||||
/** Has a position (`lng` and `lat`). */
|
/** Has a position (`lng` and `lat`). */
|
||||||
item: T;
|
item: T;
|
||||||
/** What the item is: a `WaypointDetails`, a `TrackpointDetails` or an Overpass element. */
|
/** What the item is: a `WaypointDetails`, the coordinates of a point or an Overpass element. */
|
||||||
kind: 'waypoint' | 'trackpoint' | 'overpass';
|
kind: 'waypoint' | 'trackpoint' | 'overpass';
|
||||||
fileId?: string;
|
fileId?: string;
|
||||||
hide?: () => void;
|
hide?: () => void;
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
// How the map shows the overlays: here, those of extensions that give the URL of their style.
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { afterEach, beforeEach, describe, it } from 'node:test';
|
||||||
|
import type { Map as MapLibreMap, StyleSpecification } from 'maplibre-gl';
|
||||||
|
import { writable } from 'svelte/store';
|
||||||
|
import { overlays, type LayerTreeType } from '$lib/data/layers';
|
||||||
|
import { engine } from '$lib/engine';
|
||||||
|
import { settings } from '$lib/state/settings';
|
||||||
|
import { StyleManager } from './style';
|
||||||
|
|
||||||
|
const URL_A = 'https://example.com/a.json';
|
||||||
|
const URL_B = 'https://example.com/b.json';
|
||||||
|
const ID = 'extension-Heatmaps/a';
|
||||||
|
|
||||||
|
const style = (tiles: string): StyleSpecification => ({
|
||||||
|
version: 8,
|
||||||
|
sources: { tiles: { type: 'raster', tiles: [tiles], tileSize: 256 } },
|
||||||
|
layers: [{ id: 'tiles', type: 'raster', source: 'tiles' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const original = {
|
||||||
|
set: engine.setSetting,
|
||||||
|
delete: engine.deleteSetting,
|
||||||
|
fetch: globalThis.fetch,
|
||||||
|
error: console.error,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A map that only knows of its overlays. */
|
||||||
|
function fakeMap() {
|
||||||
|
const sources = new Map<string, { tiles: string[] }>([['empty-source', { tiles: [] }]]);
|
||||||
|
const layers = new Map<string, { source: string; paint: Record<string, unknown> }>();
|
||||||
|
return {
|
||||||
|
sources,
|
||||||
|
layers,
|
||||||
|
on: () => {},
|
||||||
|
setStyle: () => {},
|
||||||
|
getPitch: () => 0,
|
||||||
|
getTerrain: () => null,
|
||||||
|
setTerrain: () => {},
|
||||||
|
getSource: (id: string) => sources.get(id),
|
||||||
|
addSource: (id: string, source: { tiles: string[] }) => sources.set(id, source),
|
||||||
|
getLayer: (id: string) => layers.get(id),
|
||||||
|
addLayer: (layer: { id: string; source: string; paint: Record<string, unknown> }) =>
|
||||||
|
layers.set(layer.id, layer),
|
||||||
|
removeLayer: (id: string) => layers.delete(id),
|
||||||
|
setPaintProperty: (id: string, name: string, value: unknown) => {
|
||||||
|
layers.get(id)!.paint[name] = value;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shows the overlay, and gives the manager of the map. */
|
||||||
|
function setUp(fake: ReturnType<typeof fakeMap>) {
|
||||||
|
settings.currentOverlays.update((tree) => {
|
||||||
|
(tree.overlays as LayerTreeType).Heatmaps = { [ID]: true };
|
||||||
|
return tree;
|
||||||
|
});
|
||||||
|
const store = writable<MapLibreMap | null>(null);
|
||||||
|
const manager = new StyleManager(store, '');
|
||||||
|
store.set(fake as unknown as MapLibreMap);
|
||||||
|
return manager;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
engine.setSetting = () => {};
|
||||||
|
engine.deleteSetting = () => {};
|
||||||
|
settings.connect({});
|
||||||
|
settings.initialize();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete overlays[ID];
|
||||||
|
delete overlays.plainUrl;
|
||||||
|
settings.currentOverlays.update((tree) => {
|
||||||
|
delete (tree.overlays as LayerTreeType).Heatmaps;
|
||||||
|
delete (tree.overlays as LayerTreeType).plainUrl;
|
||||||
|
return tree;
|
||||||
|
});
|
||||||
|
engine.setSetting = original.set;
|
||||||
|
engine.deleteSetting = original.delete;
|
||||||
|
globalThis.fetch = original.fetch;
|
||||||
|
console.error = original.error;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Answers the style requests with the styles of `responses`, by URL. */
|
||||||
|
function answerWith(responses: Record<string, StyleSpecification>) {
|
||||||
|
globalThis.fetch = (async (url: string) =>
|
||||||
|
Object.hasOwn(responses, url)
|
||||||
|
? new Response(JSON.stringify(responses[url]))
|
||||||
|
: new Response(JSON.stringify(style('https://basemap/{z}')))) as typeof fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('StyleManager, with the style URL of an extension', () => {
|
||||||
|
it('fetches it, and prefixes the ids of its sources and layers', async () => {
|
||||||
|
answerWith({ [URL_A]: style('https://a/{z}') });
|
||||||
|
overlays[ID] = URL_A;
|
||||||
|
const fake = fakeMap();
|
||||||
|
await setUp(fake).updateOverlays();
|
||||||
|
|
||||||
|
assert.deepEqual(fake.sources.get(`${ID}/tiles`)?.tiles, ['https://a/{z}']);
|
||||||
|
assert.equal(fake.layers.get(`${ID}/tiles`)?.source, `${ID}/tiles`);
|
||||||
|
assert.ok(!fake.sources.has('tiles'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the opacity of the overlay', async () => {
|
||||||
|
answerWith({ [URL_A]: style('https://a/{z}') });
|
||||||
|
overlays[ID] = URL_A;
|
||||||
|
settings.opacities.update((opacities) => ({ ...opacities, [ID]: 0.3 }));
|
||||||
|
const fake = fakeMap();
|
||||||
|
await setUp(fake).updateOverlays();
|
||||||
|
assert.equal(fake.layers.get(`${ID}/tiles`)?.paint['raster-opacity'], 0.3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes its layers when it is hidden', async () => {
|
||||||
|
answerWith({ [URL_A]: style('https://a/{z}') });
|
||||||
|
overlays[ID] = URL_A;
|
||||||
|
const fake = fakeMap();
|
||||||
|
const manager = setUp(fake);
|
||||||
|
await manager.updateOverlays();
|
||||||
|
assert.ok(fake.layers.has(`${ID}/tiles`));
|
||||||
|
|
||||||
|
settings.currentOverlays.update((tree) => {
|
||||||
|
(tree.overlays as LayerTreeType).Heatmaps = { [ID]: false };
|
||||||
|
return tree;
|
||||||
|
});
|
||||||
|
await manager.updateOverlays();
|
||||||
|
assert.ok(!fake.layers.has(`${ID}/tiles`));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fetch it while the overlay is hidden', async () => {
|
||||||
|
const urls: string[] = [];
|
||||||
|
answerWith({});
|
||||||
|
const answer = globalThis.fetch;
|
||||||
|
globalThis.fetch = ((url: string) => {
|
||||||
|
urls.push(url);
|
||||||
|
return answer(url);
|
||||||
|
}) as typeof fetch;
|
||||||
|
overlays[ID] = URL_A;
|
||||||
|
settings.currentOverlays.update((tree) => {
|
||||||
|
(tree.overlays as LayerTreeType).Heatmaps = { [ID]: false };
|
||||||
|
return tree;
|
||||||
|
});
|
||||||
|
const store = writable<MapLibreMap | null>(null);
|
||||||
|
const manager = new StyleManager(store, '');
|
||||||
|
store.set(fakeMap() as unknown as MapLibreMap);
|
||||||
|
await manager.updateOverlays();
|
||||||
|
assert.ok(!urls.includes(URL_A));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a style that was replaced or removed while it was fetched', async () => {
|
||||||
|
for (const next of [URL_B, undefined]) {
|
||||||
|
let respond: (response: Response) => void = () => {};
|
||||||
|
const answers: Record<string, StyleSpecification> = {
|
||||||
|
[URL_B]: style('https://b/{z}'),
|
||||||
|
};
|
||||||
|
answerWith(answers);
|
||||||
|
const answer = globalThis.fetch;
|
||||||
|
globalThis.fetch = ((url: string) =>
|
||||||
|
url === URL_A
|
||||||
|
? new Promise((resolve) => (respond = resolve))
|
||||||
|
: answer(url)) as typeof fetch;
|
||||||
|
|
||||||
|
overlays[ID] = URL_A;
|
||||||
|
const fake = fakeMap();
|
||||||
|
const manager = setUp(fake);
|
||||||
|
const pending = manager.updateOverlays();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve));
|
||||||
|
if (next) {
|
||||||
|
overlays[ID] = next;
|
||||||
|
} else {
|
||||||
|
delete overlays[ID];
|
||||||
|
}
|
||||||
|
respond(new Response(JSON.stringify(style('https://a/{z}'))));
|
||||||
|
await pending;
|
||||||
|
assert.ok(!fake.sources.has(`${ID}/tiles`));
|
||||||
|
|
||||||
|
await manager.updateOverlays();
|
||||||
|
assert.deepEqual(
|
||||||
|
fake.sources.get(`${ID}/tiles`)?.tiles,
|
||||||
|
next ? ['https://b/{z}'] : undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs a style that cannot be used, which leaves the others alone', async () => {
|
||||||
|
const logged: unknown[] = [];
|
||||||
|
console.error = (...args) => logged.push(args);
|
||||||
|
for (const answer of [
|
||||||
|
new Response('', { status: 404 }),
|
||||||
|
new Response(JSON.stringify({ version: 8, sources: {} })),
|
||||||
|
]) {
|
||||||
|
answerWith({});
|
||||||
|
const basemap = globalThis.fetch;
|
||||||
|
globalThis.fetch = ((url: string) =>
|
||||||
|
url === URL_A ? Promise.resolve(answer.clone()) : basemap(url)) as typeof fetch;
|
||||||
|
overlays[ID] = URL_A;
|
||||||
|
const fake = fakeMap();
|
||||||
|
logged.length = 0;
|
||||||
|
await setUp(fake).updateOverlays();
|
||||||
|
assert.ok(logged.length > 0);
|
||||||
|
assert.equal(fake.layers.size, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not prefix the style URL of anything else', async () => {
|
||||||
|
answerWith({ [URL_A]: style('https://a/{z}') });
|
||||||
|
overlays.plainUrl = URL_A;
|
||||||
|
settings.currentOverlays.update((tree) => {
|
||||||
|
(tree.overlays as LayerTreeType).plainUrl = { plainUrl: true };
|
||||||
|
return tree;
|
||||||
|
});
|
||||||
|
const fake = fakeMap();
|
||||||
|
const store = writable<MapLibreMap | null>(null);
|
||||||
|
const manager = new StyleManager(store, '');
|
||||||
|
store.set(fake as unknown as MapLibreMap);
|
||||||
|
await manager.updateOverlays();
|
||||||
|
assert.ok(fake.sources.has('tiles'));
|
||||||
|
assert.ok(fake.layers.has('tiles'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
overlays,
|
overlays,
|
||||||
terrainSources,
|
terrainSources,
|
||||||
} from '$lib/data/layers';
|
} from '$lib/data/layers';
|
||||||
import { getLayers } from '$lib/components/map/layer-control/utils';
|
import { getLayers, getOpacityPaint, prefixStyle } from '$lib/components/map/layer-control/utils';
|
||||||
import { i18n } from '$lib/i18n/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
|
|
||||||
const { currentBasemap, currentOverlays, customLayers, opacities, terrainSource, distanceUnits } =
|
const { currentBasemap, currentOverlays, customLayers, opacities, terrainSource, distanceUnits } =
|
||||||
@@ -147,13 +147,18 @@ export class StyleManager {
|
|||||||
const custom = get(customLayers);
|
const custom = get(customLayers);
|
||||||
const overlayOpacities = get(opacities);
|
const overlayOpacities = get(opacities);
|
||||||
try {
|
try {
|
||||||
|
// an overlay on the map can be gone from the tree, which has been replaced as a whole
|
||||||
const layers = getLayers(get(currentOverlays) ?? {});
|
const layers = getLayers(get(currentOverlays) ?? {});
|
||||||
|
this._pastOverlays.forEach((id) => (layers[id] ??= false));
|
||||||
for (const overlay in layers) {
|
for (const overlay in layers) {
|
||||||
if (!layers[overlay]) {
|
const overlayInfo = custom[overlay]?.value ?? overlays[overlay];
|
||||||
|
if (!overlayInfo) {
|
||||||
|
// removed, together with what it had on the map
|
||||||
|
this._pastOverlays.delete(overlay);
|
||||||
|
} else if (!layers[overlay]) {
|
||||||
if (this._pastOverlays.has(overlay)) {
|
if (this._pastOverlays.has(overlay)) {
|
||||||
const overlayInfo = custom[overlay]?.value ?? overlays[overlay];
|
|
||||||
try {
|
try {
|
||||||
const overlayStyle = await this.get(overlayInfo);
|
const overlayStyle = await this.getOverlayStyle(overlay, overlayInfo);
|
||||||
for (const layer of overlayStyle.layers ?? []) {
|
for (const layer of overlayStyle.layers ?? []) {
|
||||||
if (map_.getLayer(layer.id)) {
|
if (map_.getLayer(layer.id)) {
|
||||||
map_.removeLayer(layer.id);
|
map_.removeLayer(layer.id);
|
||||||
@@ -165,9 +170,10 @@ export class StyleManager {
|
|||||||
this._pastOverlays.delete(overlay);
|
this._pastOverlays.delete(overlay);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const overlayInfo = custom[overlay]?.value ?? overlays[overlay];
|
|
||||||
try {
|
try {
|
||||||
const overlayStyle = await this.get(overlayInfo);
|
const overlayStyle = await this.getOverlayStyle(overlay, overlayInfo);
|
||||||
|
// replaced or removed while it was fetched: its new style has its own turn
|
||||||
|
if (!custom[overlay] && overlays[overlay] !== overlayInfo) continue;
|
||||||
const opacity = overlayOpacities[overlay];
|
const opacity = overlayOpacities[overlay];
|
||||||
|
|
||||||
for (const sourceId in overlayStyle.sources) {
|
for (const sourceId in overlayStyle.sources) {
|
||||||
@@ -177,21 +183,24 @@ export class StyleManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const layer of overlayStyle.layers ?? []) {
|
for (const layer of overlayStyle.layers ?? []) {
|
||||||
if (!map_.getLayer(layer.id)) {
|
const paint =
|
||||||
if (opacity !== undefined) {
|
opacity === undefined ? {} : getOpacityPaint(layer, opacity);
|
||||||
if (layer.type === 'raster') {
|
if (map_.getLayer(layer.id)) {
|
||||||
if (!layer.paint) {
|
for (const property in paint) {
|
||||||
layer.paint = {};
|
map_.setPaintProperty(
|
||||||
}
|
layer.id,
|
||||||
layer.paint['raster-opacity'] = opacity;
|
property as never,
|
||||||
} else if (layer.type === 'hillshade') {
|
paint[property] as never
|
||||||
if (!layer.paint) {
|
);
|
||||||
layer.paint = {};
|
|
||||||
}
|
|
||||||
layer.paint['hillshade-exaggeration'] = opacity / 2;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
map_.addLayer(layer, ANCHOR_LAYER_KEY.overlays);
|
} else {
|
||||||
|
map_.addLayer(
|
||||||
|
{
|
||||||
|
...layer,
|
||||||
|
paint: { ...layer.paint, ...paint },
|
||||||
|
} as LayerSpecification,
|
||||||
|
ANCHOR_LAYER_KEY.overlays
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this._pastOverlays.add(overlay);
|
this._pastOverlays.add(overlay);
|
||||||
@@ -221,6 +230,17 @@ export class StyleManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The style of an overlay. The ids of one from an extension's URL still have to be prefixed. */
|
||||||
|
private async getOverlayStyle(
|
||||||
|
overlay: string,
|
||||||
|
styleInfo: StyleSpecification | string
|
||||||
|
): Promise<StyleSpecification> {
|
||||||
|
const style = await this.get(styleInfo);
|
||||||
|
return typeof styleInfo === 'string' && overlay.startsWith('extension-')
|
||||||
|
? prefixStyle(overlay, style)
|
||||||
|
: style;
|
||||||
|
}
|
||||||
|
|
||||||
async get(styleInfo: StyleSpecification | string): Promise<StyleSpecification> {
|
async get(styleInfo: StyleSpecification | string): Promise<StyleSpecification> {
|
||||||
if (typeof styleInfo === 'string') {
|
if (typeof styleInfo === 'string') {
|
||||||
const styleUrl = styleInfo as string;
|
const styleUrl = styleInfo as string;
|
||||||
|
|||||||
@@ -218,6 +218,7 @@
|
|||||||
</span>
|
</span>
|
||||||
{:else}
|
{:else}
|
||||||
<TimePicker
|
<TimePicker
|
||||||
|
id="speed"
|
||||||
bind:value={speed}
|
bind:value={speed}
|
||||||
showHours={false}
|
showHours={false}
|
||||||
disabled={!canUpdate}
|
disabled={!canUpdate}
|
||||||
@@ -243,6 +244,7 @@
|
|||||||
{i18n._('toolbar.time.total_time')}
|
{i18n._('toolbar.time.total_time')}
|
||||||
</Label>
|
</Label>
|
||||||
<TimePicker
|
<TimePicker
|
||||||
|
id="duration"
|
||||||
bind:value={movingTime}
|
bind:value={movingTime}
|
||||||
disabled={!canUpdate}
|
disabled={!canUpdate}
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
|
|||||||
@@ -633,6 +633,11 @@ export class RoutingControls {
|
|||||||
|
|
||||||
const anchor = this.anchors[e.features![0].properties.anchorIndex];
|
const anchor = this.anchors[e.features![0].properties.anchorIndex];
|
||||||
if (!anchor) {
|
if (!anchor) {
|
||||||
|
// The temporary anchor on the line is not one of the anchors (nor is a feature
|
||||||
|
// still rendered from before they were rebuilt), a click on it adds it
|
||||||
|
if (e.type === 'click') {
|
||||||
|
this.addIntermediateAnchor(e);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.originalEvent.shiftKey) {
|
if (e.originalEvent.shiftKey) {
|
||||||
|
|||||||
@@ -45,6 +45,19 @@
|
|||||||
|
|
||||||
let marker: Marker | null = null;
|
let marker: Marker | null = null;
|
||||||
|
|
||||||
|
// A number field holds what is typed, out of range or empty (null) on the way to a
|
||||||
|
// coordinate, and a map click sets strings. MapLibre throws on a latitude past ±90.
|
||||||
|
function inRange(value: number | string | null, limit: number) {
|
||||||
|
if (value === null || value === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const n = Number(value);
|
||||||
|
return Number.isFinite(n) && Math.abs(n) <= limit;
|
||||||
|
}
|
||||||
|
let latitudeValid = $derived(inRange(latitude, 90));
|
||||||
|
let longitudeValid = $derived(inRange(longitude, 180));
|
||||||
|
let coordinatesValid = $derived(latitudeValid && longitudeValid);
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
if ($selectedWaypoint) {
|
if ($selectedWaypoint) {
|
||||||
selectedWaypoint.reset();
|
selectedWaypoint.reset();
|
||||||
@@ -78,6 +91,9 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function createOrUpdateWaypoint() {
|
async function createOrUpdateWaypoint() {
|
||||||
|
if (!coordinatesValid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (typeof latitude === 'string') {
|
if (typeof latitude === 'string') {
|
||||||
latitude = parseFloat(latitude);
|
latitude = parseFloat(latitude);
|
||||||
}
|
}
|
||||||
@@ -128,7 +144,8 @@
|
|||||||
marker = null;
|
marker = null;
|
||||||
}
|
}
|
||||||
} else if (latitude != 0 || longitude != 0) {
|
} else if (latitude != 0 || longitude != 0) {
|
||||||
if ($map) {
|
// while a coordinate is being typed the pin stays where it last was
|
||||||
|
if ($map && coordinatesValid) {
|
||||||
if (marker) {
|
if (marker) {
|
||||||
marker.setLngLat([longitude, latitude]).getElement().innerHTML =
|
marker.setLngLat([longitude, latitude]).getElement().innerHTML =
|
||||||
getSvgForSymbol(symbolKey);
|
getSvgForSymbol(symbolKey);
|
||||||
@@ -248,6 +265,7 @@
|
|||||||
min={-90}
|
min={-90}
|
||||||
max={90}
|
max={90}
|
||||||
class="text-xs h-8"
|
class="text-xs h-8"
|
||||||
|
aria-invalid={!latitudeValid}
|
||||||
disabled={!canCreate && !$selectedWaypoint}
|
disabled={!canCreate && !$selectedWaypoint}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -261,6 +279,7 @@
|
|||||||
min={-180}
|
min={-180}
|
||||||
max={180}
|
max={180}
|
||||||
class="text-xs h-8"
|
class="text-xs h-8"
|
||||||
|
aria-invalid={!longitudeValid}
|
||||||
disabled={!canCreate && !$selectedWaypoint}
|
disabled={!canCreate && !$selectedWaypoint}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -269,7 +288,7 @@
|
|||||||
<div class="flex flex-row gap-1.5 items-center">
|
<div class="flex flex-row gap-1.5 items-center">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={!canCreate && !$selectedWaypoint}
|
disabled={(!canCreate && !$selectedWaypoint) || !coordinatesValid}
|
||||||
class="grow shrink h-fit min-h-8 whitespace-normal py-1"
|
class="grow shrink h-fit min-h-8 whitespace-normal py-1"
|
||||||
onclick={createOrUpdateWaypoint}
|
onclick={createOrUpdateWaypoint}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,63 +1,40 @@
|
|||||||
<script lang="ts">
|
<script lang="ts" module>
|
||||||
import { Input } from '$lib/components/ui/input';
|
/** Shown by an empty field: real text rather than a placeholder, so that it can be selected. */
|
||||||
|
export const EMPTY_FIELD = '--';
|
||||||
let {
|
|
||||||
id,
|
|
||||||
value = $bindable(),
|
|
||||||
disabled,
|
|
||||||
oninput = () => {},
|
|
||||||
onchange = () => {},
|
|
||||||
onkeypress = () => {},
|
|
||||||
onfocusin = () => {},
|
|
||||||
class: className,
|
|
||||||
}: {
|
|
||||||
id: string;
|
|
||||||
value: string | number;
|
|
||||||
disabled?: boolean;
|
|
||||||
oninput?: (e: Event) => void;
|
|
||||||
onchange?: (e: Event) => void;
|
|
||||||
onkeypress?: (e: KeyboardEvent) => void;
|
|
||||||
onfocusin?: (e: FocusEvent) => void;
|
|
||||||
class?: string;
|
|
||||||
} = $props();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<script lang="ts">
|
||||||
<Input
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
{id}
|
import type { HTMLInputAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
value,
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<Omit<HTMLInputAttributes, 'type' | 'value'>> & { value: string } = $props();
|
||||||
|
|
||||||
|
// The padding of the fields of a native time input, which is larger in Firefox.
|
||||||
|
const padding = 'px-px [@supports(-moz-appearance:none)]:px-[3px]';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- The invisible copy of the text sizes the field to its exact width, like in a native time input. -->
|
||||||
|
<span class="relative inline-block">
|
||||||
|
<span aria-hidden="true" class={cn('invisible block whitespace-pre', padding)}>
|
||||||
|
{value || EMPTY_FIELD}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
bind:this={ref}
|
||||||
type="text"
|
type="text"
|
||||||
step={1}
|
inputmode="numeric"
|
||||||
bind:value
|
autocomplete="off"
|
||||||
{disabled}
|
value={value || EMPTY_FIELD}
|
||||||
{oninput}
|
class={cn(
|
||||||
{onchange}
|
'absolute inset-0 w-full bg-transparent text-center outline-none',
|
||||||
{onkeypress}
|
padding,
|
||||||
onfocusin={(e) => {
|
className
|
||||||
let input = document.activeElement;
|
)}
|
||||||
if (input instanceof HTMLInputElement) {
|
onfocus={(e) => e.currentTarget.select()}
|
||||||
input.select();
|
{...restProps}
|
||||||
}
|
|
||||||
onfocusin(e);
|
|
||||||
}}
|
|
||||||
class="w-[22px] {className ?? ''}"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</span>
|
||||||
|
|
||||||
<style lang="postcss">
|
|
||||||
@reference "tailwindcss";
|
|
||||||
|
|
||||||
div :global(input) {
|
|
||||||
@apply px-0.5;
|
|
||||||
@apply py-0;
|
|
||||||
@apply bg-transparent;
|
|
||||||
@apply text-right;
|
|
||||||
@apply border-none;
|
|
||||||
@apply shadow-none;
|
|
||||||
@apply focus:ring-0;
|
|
||||||
@apply focus:ring-offset-0;
|
|
||||||
@apply focus:outline-none;
|
|
||||||
@apply focus-visible:ring-0;
|
|
||||||
@apply focus-visible:ring-offset-0;
|
|
||||||
@apply focus-visible:outline-none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,174 +1,176 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import TimeComponentInput from './TimeComponentInput.svelte';
|
import { cn } from '$lib/utils.js';
|
||||||
|
import TimeComponentInput, { EMPTY_FIELD } from './TimeComponentInput.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
id,
|
||||||
showHours = true,
|
showHours = true,
|
||||||
value = $bindable(),
|
value = $bindable(),
|
||||||
disabled = false,
|
disabled = false,
|
||||||
onChange = () => {},
|
onChange = () => {},
|
||||||
}: {
|
}: {
|
||||||
|
/** Set on the first field, so that a label can target the picker. */
|
||||||
|
id?: string;
|
||||||
showHours?: boolean;
|
showHours?: boolean;
|
||||||
value?: number;
|
value?: number;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onChange?: () => void;
|
onChange?: () => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let hours: string | number = $state('--');
|
let hours = $state('');
|
||||||
let minutes: string | number = $state('--');
|
let minutes = $state('');
|
||||||
let seconds: string | number = $state('--');
|
let seconds = $state('');
|
||||||
|
|
||||||
function maybeParseInt(value: string | number): number {
|
let hoursInput: HTMLInputElement | null = $state(null);
|
||||||
if (value === '--' || value === '') {
|
let minutesInput: HTMLInputElement | null = $state(null);
|
||||||
return 0;
|
let secondsInput: HTMLInputElement | null = $state(null);
|
||||||
|
|
||||||
|
function parseField(field: string): number {
|
||||||
|
return field === '' ? 0 : parseInt(field, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldsToSeconds(): number {
|
||||||
|
const total =
|
||||||
|
(showHours ? parseField(hours) * 3600 : 0) +
|
||||||
|
parseField(minutes) * 60 +
|
||||||
|
parseField(seconds);
|
||||||
|
// Callers divide by this value, so it must stay positive.
|
||||||
|
return Math.max(total, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad(field: number): string {
|
||||||
|
return field.toString().padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showValue(newValue: number | undefined) {
|
||||||
|
if (!Number.isFinite(newValue)) {
|
||||||
|
hours = minutes = seconds = '';
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
return typeof value === 'string' ? parseInt(value) : value;
|
const total = Math.max(Math.round(newValue!), 1);
|
||||||
}
|
const totalMinutes = Math.floor(total / 60);
|
||||||
|
if (showHours) {
|
||||||
function computeValue(): number {
|
hours = Math.floor(total / 3600).toString();
|
||||||
return Math.max(
|
minutes = pad(totalMinutes % 60);
|
||||||
maybeParseInt(hours) * 3600 + maybeParseInt(minutes) * 60 + maybeParseInt(seconds),
|
} else {
|
||||||
1
|
minutes = totalMinutes.toString();
|
||||||
);
|
}
|
||||||
|
seconds = pad(total % 60);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only the display follows `value`: it is written back on user input only, so that showing a
|
||||||
|
// value never rounds or overwrites it. A value that matches the fields comes from them, and
|
||||||
|
// must not reformat them during typing.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const val = computeValue();
|
const newValue = value;
|
||||||
|
void showHours;
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
value = val;
|
if (newValue !== fieldsToSeconds()) {
|
||||||
|
showValue(newValue);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
/** A field without an upper bound: hours, or minutes when there are no hours. */
|
||||||
if (value === undefined) {
|
function normalizeUnbounded(input: string): string {
|
||||||
untrack(() => {
|
const digits = input.replace(/\D/g, '').slice(-6);
|
||||||
hours = '--';
|
return digits === '' ? '' : parseInt(digits, 10).toString();
|
||||||
minutes = '--';
|
}
|
||||||
seconds = '--';
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
untrack(() => {
|
|
||||||
if (value != computeValue()) {
|
|
||||||
let rounded = Math.max(Math.round(value!), 1);
|
|
||||||
if (showHours) {
|
|
||||||
hours = Math.floor(rounded / 3600);
|
|
||||||
minutes = Math.floor((rounded % 3600) / 60)
|
|
||||||
.toString()
|
|
||||||
.padStart(2, '0');
|
|
||||||
} else {
|
|
||||||
minutes = Math.floor(rounded / 60).toString();
|
|
||||||
}
|
|
||||||
seconds = (rounded % 60).toString().padStart(2, '0');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let container: HTMLDivElement;
|
/** Minutes or seconds: new digits shift in from the right, so "12" then "3" gives "23". */
|
||||||
let countKeyPress = 0;
|
function normalizeSexagesimal(input: string): string {
|
||||||
function onKeyPress(e: KeyboardEvent) {
|
const digits = input.replace(/\D/g, '').slice(-2);
|
||||||
const target = e.target as HTMLInputElement | null;
|
return digits === '' ? '' : pad(Math.min(parseInt(digits, 10), 59));
|
||||||
if (target && ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'].includes(e.key)) {
|
}
|
||||||
countKeyPress++;
|
|
||||||
if (countKeyPress === 2) {
|
let digitsTyped = 0;
|
||||||
const nextInput =
|
|
||||||
target.id === 'hours'
|
function handleInput(
|
||||||
? (container.querySelector('#minutes') as HTMLInputElement)
|
e: Event & { currentTarget: HTMLInputElement },
|
||||||
: target.id === 'minutes'
|
normalize: (input: string) => string,
|
||||||
? (container.querySelector('#seconds') as HTMLInputElement)
|
setField: (field: string) => void,
|
||||||
: null;
|
nextInput: HTMLInputElement | null
|
||||||
if (nextInput) {
|
) {
|
||||||
nextInput.focus();
|
const input = e.currentTarget;
|
||||||
}
|
const field = normalize(input.value);
|
||||||
|
// The state is unchanged when the input is rejected (a letter, for example), in which case
|
||||||
|
// Svelte would leave the rejected text in the DOM.
|
||||||
|
input.value = field || EMPTY_FIELD;
|
||||||
|
if (!field) {
|
||||||
|
input.select();
|
||||||
|
}
|
||||||
|
setField(field);
|
||||||
|
|
||||||
|
value = fieldsToSeconds();
|
||||||
|
onChange();
|
||||||
|
|
||||||
|
if (/^\d$/.test((e as unknown as InputEvent).data ?? '')) {
|
||||||
|
digitsTyped++;
|
||||||
|
if (digitsTyped === 2) {
|
||||||
|
nextInput?.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handlePointerDown(e: PointerEvent) {
|
||||||
|
// Like a native input, the whole box gives focus, not only the text.
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
e.preventDefault();
|
||||||
|
(hoursInput ?? minutesInput)?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFocusOut(e: FocusEvent & { currentTarget: HTMLDivElement }) {
|
||||||
|
// Fill in the fields left empty once the user is done with the picker.
|
||||||
|
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
|
||||||
|
showValue(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
bind:this={container}
|
data-slot="time-picker"
|
||||||
class="h-9 flex flex-row items-center w-full min-w-fit border rounded-md px-3 focus-within:outline-none focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 {disabled
|
class={cn(
|
||||||
? 'opacity-50 cursor-not-allowed'
|
'dark:bg-input/30 border-input focus-within:border-ring focus-within:ring-ring/50 flex h-8 w-full min-w-28 cursor-text items-center justify-end rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors focus-within:ring-3 md:text-sm',
|
||||||
: ''}"
|
disabled && 'bg-input/50 dark:bg-input/80 pointer-events-none cursor-not-allowed opacity-50'
|
||||||
|
)}
|
||||||
|
onpointerdown={handlePointerDown}
|
||||||
|
onfocusin={() => {
|
||||||
|
digitsTyped = 0;
|
||||||
|
}}
|
||||||
|
onfocusout={handleFocusOut}
|
||||||
>
|
>
|
||||||
{#if showHours}
|
{#if showHours}
|
||||||
<TimeComponentInput
|
<TimeComponentInput
|
||||||
id="hours"
|
{id}
|
||||||
bind:value={hours}
|
bind:ref={hoursInput}
|
||||||
|
value={hours}
|
||||||
{disabled}
|
{disabled}
|
||||||
class="w-[30px]"
|
oninput={(e) =>
|
||||||
oninput={() => {
|
handleInput(e, normalizeUnbounded, (field) => (hours = field), minutesInput)}
|
||||||
if (typeof hours === 'string') {
|
|
||||||
hours = parseInt(hours);
|
|
||||||
}
|
|
||||||
if (hours < 0) {
|
|
||||||
hours = 0;
|
|
||||||
}
|
|
||||||
onChange();
|
|
||||||
}}
|
|
||||||
onkeypress={onKeyPress}
|
|
||||||
onfocusin={() => {
|
|
||||||
countKeyPress = 0;
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<span class="text-sm">:</span>
|
<span>:</span>
|
||||||
{/if}
|
{/if}
|
||||||
<TimeComponentInput
|
<TimeComponentInput
|
||||||
id="minutes"
|
id={showHours ? undefined : id}
|
||||||
bind:value={minutes}
|
bind:ref={minutesInput}
|
||||||
|
value={minutes}
|
||||||
{disabled}
|
{disabled}
|
||||||
oninput={() => {
|
oninput={(e) =>
|
||||||
if (typeof minutes === 'string') {
|
handleInput(
|
||||||
minutes = parseInt(minutes);
|
e,
|
||||||
}
|
showHours ? normalizeSexagesimal : normalizeUnbounded,
|
||||||
if (showHours && minutes > 59) {
|
(field) => (minutes = field),
|
||||||
minutes = 59;
|
secondsInput
|
||||||
} else if (minutes < 0) {
|
)}
|
||||||
minutes = 0;
|
|
||||||
}
|
|
||||||
minutes = minutes.toString().padStart(showHours ? 2 : 1, '0');
|
|
||||||
onChange();
|
|
||||||
}}
|
|
||||||
onkeypress={onKeyPress}
|
|
||||||
onfocusin={() => {
|
|
||||||
countKeyPress = 0;
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<span class="text-sm">:</span>
|
<span>:</span>
|
||||||
<TimeComponentInput
|
<TimeComponentInput
|
||||||
id="seconds"
|
bind:ref={secondsInput}
|
||||||
bind:value={seconds}
|
value={seconds}
|
||||||
{disabled}
|
{disabled}
|
||||||
oninput={() => {
|
oninput={(e) => handleInput(e, normalizeSexagesimal, (field) => (seconds = field), null)}
|
||||||
if (typeof seconds === 'string') {
|
|
||||||
seconds = parseInt(seconds);
|
|
||||||
}
|
|
||||||
if (seconds > 59) {
|
|
||||||
seconds = 59;
|
|
||||||
} else if (seconds < 0) {
|
|
||||||
seconds = 0;
|
|
||||||
}
|
|
||||||
seconds = seconds.toString().padStart(2, '0');
|
|
||||||
onChange();
|
|
||||||
}}
|
|
||||||
onkeypress={onKeyPress}
|
|
||||||
onfocusin={() => {
|
|
||||||
countKeyPress = 0;
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
|
||||||
div :global(input::-webkit-outer-spin-button) {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
div :global(input::-webkit-inner-spin-button) {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
div :global(input[type='number']) {
|
|
||||||
appearance: textfield;
|
|
||||||
-moz-appearance: textfield;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -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
|
### <Plus size="16" class="inline-block" style="margin-bottom: 2px" /> New route
|
||||||
|
|
||||||
Create a new route in the selected file.
|
Crear una nueva ruta en el archivo seleccionado.
|
||||||
|
|
||||||
<DocsNote>
|
<DocsNote>
|
||||||
|
|
||||||
@@ -62,10 +62,10 @@ Además, la selección debe ser una sola pista.
|
|||||||
|
|
||||||
### <ArrowRightLeft size="16" class="inline-block" style="margin-bottom: 2px" /> Convert to route/track
|
### <ArrowRightLeft size="16" class="inline-block" style="margin-bottom: 2px" /> Convert to route/track
|
||||||
|
|
||||||
Convert the selected routes to tracks, or the selected tracks or segments to routes.
|
Convertir las rutas seleccionadas a recorridos o los recorridos o segmentos seleccionados a rutas.
|
||||||
Since a route has a single segment, a track with multiple segments is converted into one route per segment.
|
Dado que una ruta tiene un solo segmento, un recorrido con múltiples segmentos se convierte en una ruta por segmento.
|
||||||
|
|
||||||
When the selected files contain only routes, or only tracks, you can also convert all of them at once.
|
Cuando los archivos seleccionados solo contienen rutas o pistas, también puede convertir todos a la vez.
|
||||||
|
|
||||||
### <FileStack size="16" class="inline-block" style="margin-bottom: 2px" /> Seleccionar todo
|
### <FileStack size="16" class="inline-block" style="margin-bottom: 2px" /> Seleccionar todo
|
||||||
|
|
||||||
|
|||||||
@@ -159,8 +159,6 @@ export async function createTauriBackend(): Promise<Backend> {
|
|||||||
(await invoke('file_statistics', { fileId })) ?? undefined,
|
(await invoke('file_statistics', { fileId })) ?? undefined,
|
||||||
waypoint: async (fileId, waypointId) =>
|
waypoint: async (fileId, waypointId) =>
|
||||||
(await invoke('waypoint', { fileId, waypointId })) ?? undefined,
|
(await invoke('waypoint', { fileId, waypointId })) ?? undefined,
|
||||||
trackpoint: async (fileId, segmentId, index) =>
|
|
||||||
(await invoke('trackpoint', { fileId, segmentId, index })) ?? undefined,
|
|
||||||
export_file: async (fileId, time, hr, cad, atemp, power, osm) => {
|
export_file: async (fileId, time, hr, cad, atemp, power, osm) => {
|
||||||
const file = await optionalArray('export_file', Uint8Array, {
|
const file = await optionalArray('export_file', Uint8Array, {
|
||||||
fileId,
|
fileId,
|
||||||
@@ -245,7 +243,7 @@ export async function createTauriBackend(): Promise<Backend> {
|
|||||||
|
|
||||||
importsLegacyData: false,
|
importsLegacyData: false,
|
||||||
open_storage: () => invoke<StorageOpened>('open_storage'),
|
open_storage: () => invoke<StorageOpened>('open_storage'),
|
||||||
flush_storage: () => invoke('flush_storage'),
|
flush_storage: () => invoke('flush_storage').then(() => true),
|
||||||
sync_storage: () => invoke('sync_storage'),
|
sync_storage: () => invoke('sync_storage'),
|
||||||
set_setting: (key, value) => invoke('set_setting', { key, value }),
|
set_setting: (key, value) => invoke('set_setting', { key, value }),
|
||||||
delete_setting: (key) => invoke('delete_setting', { key }),
|
delete_setting: (key) => invoke('delete_setting', { key }),
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import type {
|
|||||||
Selection,
|
Selection,
|
||||||
SelectionPoint,
|
SelectionPoint,
|
||||||
StorageOpened,
|
StorageOpened,
|
||||||
TrackpointDetails,
|
|
||||||
WaypointDetails,
|
WaypointDetails,
|
||||||
} from 'gpx-wasm';
|
} from 'gpx-wasm';
|
||||||
import type { ExportOptions, MergeType, SelectMode, SplitType } from '../types';
|
import type { ExportOptions, MergeType, SelectMode, SplitType } from '../types';
|
||||||
@@ -266,11 +265,6 @@ export interface Backend {
|
|||||||
file_structure(fileId: string): Promise<FileStructure | undefined>;
|
file_structure(fileId: string): Promise<FileStructure | undefined>;
|
||||||
file_statistics(fileId: string): Promise<GlobalStatistics | undefined>;
|
file_statistics(fileId: string): Promise<GlobalStatistics | undefined>;
|
||||||
waypoint(fileId: string, waypointId: string): Promise<WaypointDetails | undefined>;
|
waypoint(fileId: string, waypointId: string): Promise<WaypointDetails | undefined>;
|
||||||
trackpoint(
|
|
||||||
fileId: string,
|
|
||||||
segmentId: string,
|
|
||||||
index: number
|
|
||||||
): Promise<TrackpointDetails | undefined>;
|
|
||||||
/** A file as GPX (UTF-8 bytes), `undefined` if it does not exist. */
|
/** A file as GPX (UTF-8 bytes), `undefined` if it does not exist. */
|
||||||
export_file(
|
export_file(
|
||||||
fileId: string,
|
fileId: string,
|
||||||
@@ -340,8 +334,8 @@ export interface Backend {
|
|||||||
onBehind: () => void,
|
onBehind: () => void,
|
||||||
onNewer: () => void
|
onNewer: () => void
|
||||||
): Promise<StorageOpened>;
|
): Promise<StorageOpened>;
|
||||||
/** Resolves when what was changed has been written to the storage. */
|
/** Resolves when the writes of what was changed are done, to whether they went well. */
|
||||||
flush_storage(): Promise<void>;
|
flush_storage(): Promise<boolean>;
|
||||||
/** Reads the files again if another window wrote them since, and tells what changed. */
|
/** Reads the files again if another window wrote them since, and tells what changed. */
|
||||||
sync_storage(): Promise<Outcome>;
|
sync_storage(): Promise<Outcome>;
|
||||||
set_setting(key: string, json: string): Promise<void>;
|
set_setting(key: string, json: string): Promise<void>;
|
||||||
|
|||||||
@@ -123,7 +123,6 @@ export async function createWasmBackend(): Promise<Backend> {
|
|||||||
file_structure: lift(w.file_structure),
|
file_structure: lift(w.file_structure),
|
||||||
file_statistics: lift(w.file_statistics),
|
file_statistics: lift(w.file_statistics),
|
||||||
waypoint: lift(w.waypoint),
|
waypoint: lift(w.waypoint),
|
||||||
trackpoint: lift(w.trackpoint),
|
|
||||||
// a copy out of the memory of the engine, so a plain ArrayBuffer
|
// a copy out of the memory of the engine, so a plain ArrayBuffer
|
||||||
export_file: lift(
|
export_file: lift(
|
||||||
(...args: Parameters<Backend['export_file']>) =>
|
(...args: Parameters<Backend['export_file']>) =>
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ describe('statistics', () => {
|
|||||||
assert.equal(get(engine.statistics).hr, undefined);
|
assert.equal(get(engine.statistics).hr, undefined);
|
||||||
|
|
||||||
const request = engine.requestStatistics();
|
const request = engine.requestStatistics();
|
||||||
await request.set(['hr', 'anchors', 'slopeSegment']);
|
await request.set(['hr', 'anchors', 'slopeSegment', 'segments']);
|
||||||
const statistics = get(engine.statistics);
|
const statistics = get(engine.statistics);
|
||||||
assert.equal(statistics.slopeSegments!.starts[0], 0);
|
assert.equal(statistics.slopeSegments!.starts[0], 0);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
@@ -270,6 +270,8 @@ describe('statistics', () => {
|
|||||||
assert.ok(statistics.hr!.some((hr) => !Number.isNaN(hr)));
|
assert.ok(statistics.hr!.some((hr) => !Number.isNaN(hr)));
|
||||||
assert.equal(statistics.cad, undefined);
|
assert.equal(statistics.cad, undefined);
|
||||||
assert.equal(statistics.anchors!.indices[0], 0);
|
assert.equal(statistics.anchors!.indices[0], 0);
|
||||||
|
assert.deepEqual([...statistics.segments!.starts], [...statistics.anchors!.segmentStarts]);
|
||||||
|
assert.deepEqual(statistics.segments!.ids, statistics.anchors!.segmentIds);
|
||||||
assert.equal(statistics.anchors!.indices.at(-1), statistics.length - 1);
|
assert.equal(statistics.anchors!.indices.at(-1), statistics.length - 1);
|
||||||
|
|
||||||
// they are read again after a change, without being asked again
|
// they are read again after a change, without being asked again
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ import {
|
|||||||
type FolderEntry,
|
type FolderEntry,
|
||||||
type UnsavedChoice,
|
type UnsavedChoice,
|
||||||
type WorkspaceState,
|
type WorkspaceState,
|
||||||
type TrackpointDetails,
|
|
||||||
type WaypointDetails,
|
type WaypointDetails,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
@@ -881,15 +880,6 @@ class Engine {
|
|||||||
return this.read((b) => b.waypoint(fileId, waypointId), undefined);
|
return this.read((b) => b.waypoint(fileId, waypointId), undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A trackpoint of a segment, `undefined` if it does not exist. */
|
|
||||||
trackpoint(
|
|
||||||
fileId: string,
|
|
||||||
segmentId: string,
|
|
||||||
index: number
|
|
||||||
): Promise<TrackpointDetails | undefined> {
|
|
||||||
return this.read((b) => b.trackpoint(fileId, segmentId, index), undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A file as GPX (UTF-8 bytes), `undefined` if it does not exist. The options say which data of the
|
* A file as GPX (UTF-8 bytes), `undefined` if it does not exist. The options say which data of the
|
||||||
* trackpoints to keep.
|
* trackpoints to keep.
|
||||||
@@ -935,17 +925,6 @@ class Engine {
|
|||||||
return this.read((b) => b.reduction_distances(), new Float64Array());
|
return this.read((b) => b.reduction_distances(), new Float64Array());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Coordinates, as flat [lng, lat, ...] arrays: copies.
|
|
||||||
// Refetch them when the `rev` of the segment (or the `waypointsRev` of the file) changed.
|
|
||||||
|
|
||||||
segmentCoordinates(segmentId: string): Promise<Float64Array> {
|
|
||||||
return this.read((b) => b.segment_coordinates(segmentId), new Float64Array());
|
|
||||||
}
|
|
||||||
|
|
||||||
waypointCoordinates(fileId: string): Promise<Float64Array> {
|
|
||||||
return this.read((b) => b.waypoint_coordinates(fileId), new Float64Array());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers a consumer of the statistics that needs some of the metrics that are not part of
|
* Registers a consumer of the statistics that needs some of the metrics that are not part of
|
||||||
* them by default (see `StatisticsMetric`). The metrics asked for by all the consumers are
|
* them by default (see `StatisticsMetric`). The metrics asked for by all the consumers are
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
hasSelectionWithin,
|
hasSelectionWithin,
|
||||||
isCovered,
|
isCovered,
|
||||||
isInClipboard,
|
isInClipboard,
|
||||||
isEmpty,
|
|
||||||
isSelected,
|
isSelected,
|
||||||
isSelectionHidden,
|
isSelectionHidden,
|
||||||
selectedElementIds,
|
selectedElementIds,
|
||||||
@@ -68,12 +67,6 @@ describe('selection helpers', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('tells whether the selection is empty', () => {
|
|
||||||
assert.equal(isEmpty(empty), true);
|
|
||||||
assert.equal(isEmpty(files('F1')), false);
|
|
||||||
assert.equal(isEmpty(waypoints), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('lists the files holding something selected', () => {
|
it('lists the files holding something selected', () => {
|
||||||
assert.deepEqual(selectedFileIds(empty), []);
|
assert.deepEqual(selectedFileIds(empty), []);
|
||||||
assert.deepEqual(selectedFileIds(files('F1', 'F2')), ['F1', 'F2']);
|
assert.deepEqual(selectedFileIds(files('F1', 'F2')), ['F1', 'F2']);
|
||||||
|
|||||||
@@ -65,10 +65,6 @@ export function coversSegments(selection: Selection): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isEmpty(selection: Selection): boolean {
|
|
||||||
return selection.type === 'empty';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ids of the files holding something selected (or selected themselves). */
|
/** Ids of the files holding something selected (or selected themselves). */
|
||||||
export function selectedFileIds(selection: Selection): string[] {
|
export function selectedFileIds(selection: Selection): string[] {
|
||||||
switch (selection.type) {
|
switch (selection.type) {
|
||||||
|
|||||||
@@ -143,6 +143,14 @@ export class StatisticsReader {
|
|||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'segments': {
|
||||||
|
const [starts, ids] = await Promise.all([
|
||||||
|
backend.segment_starts(),
|
||||||
|
backend.segment_ids(),
|
||||||
|
]);
|
||||||
|
statistics.segments = { starts, ids };
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'highway':
|
case 'highway':
|
||||||
[statistics.highway, statistics.sacScale, statistics.mtbScale] =
|
[statistics.highway, statistics.sacScale, statistics.mtbScale] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
|||||||
@@ -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 { parseVisibility, VISIBILITY_KEY, type Visibility } from '$lib/engine/file-visibility';
|
||||||
import type { Outcome, StorageOpened } from 'gpx-wasm';
|
import type { Outcome, StorageOpened } from 'gpx-wasm';
|
||||||
import type { Backend } from './backend';
|
import type { Backend } from './backend';
|
||||||
@@ -15,7 +15,7 @@ export type StorageHost = {
|
|||||||
/** The explicit visibility per file, to fill with the one that was saved. */
|
/** The explicit visibility per file, to fill with the one that was saved. */
|
||||||
visibility: Map<string, Visibility>;
|
visibility: Map<string, Visibility>;
|
||||||
/** Applies what a call changed to what the facade keeps of the files. */
|
/** Applies what a call changed to what the facade keeps of the files. */
|
||||||
sync(update: Outcome): void;
|
sync(update: Outcome): Promise<void>;
|
||||||
/** Tells that what is stored comes from a newer version of the app: nothing can be saved. */
|
/** Tells that what is stored comes from a newer version of the app: nothing can be saved. */
|
||||||
outdated(): void;
|
outdated(): void;
|
||||||
hasFiles(): boolean;
|
hasFiles(): boolean;
|
||||||
@@ -66,7 +66,10 @@ function followOtherTabs(host: StorageHost): {
|
|||||||
*
|
*
|
||||||
* The first time, what the previous versions of the app kept in the browser is imported.
|
* The first time, what the previous versions of the app kept in the browser is imported.
|
||||||
*/
|
*/
|
||||||
export async function openStoredFiles(host: StorageHost): Promise<Record<string, string>> {
|
export async function openStoredFiles(
|
||||||
|
host: StorageHost,
|
||||||
|
readLegacy: () => Promise<LegacyData | undefined> = readLegacyData
|
||||||
|
): Promise<Record<string, string>> {
|
||||||
const { backend } = host;
|
const { backend } = host;
|
||||||
let opened: StorageOpened;
|
let opened: StorageOpened;
|
||||||
const tabs = followOtherTabs(host);
|
const tabs = followOtherTabs(host);
|
||||||
@@ -81,47 +84,68 @@ export async function openStoredFiles(host: StorageHost): Promise<Record<string,
|
|||||||
parseVisibility(settings[VISIBILITY_KEY]).forEach((visibility, fileId) =>
|
parseVisibility(settings[VISIBILITY_KEY]).forEach((visibility, fileId) =>
|
||||||
host.visibility.set(fileId, visibility)
|
host.visibility.set(fileId, visibility)
|
||||||
);
|
);
|
||||||
host.sync(opened.outcome);
|
await host.sync(opened.outcome);
|
||||||
|
|
||||||
if (
|
if (backend.importsLegacyData && settings[LEGACY_IMPORTED_KEY] === undefined) {
|
||||||
backend.importsLegacyData &&
|
settings = { ...settings, ...(await importLegacyData(host, settings, readLegacy)) };
|
||||||
settings[LEGACY_IMPORTED_KEY] === undefined &&
|
|
||||||
!host.hasFiles()
|
|
||||||
) {
|
|
||||||
settings = { ...settings, ...(await importLegacyData(host)) };
|
|
||||||
await backend.flush_storage();
|
|
||||||
}
|
}
|
||||||
return settings;
|
return settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Puts the files and the settings of the former database in the engine. */
|
/**
|
||||||
async function importLegacyData(host: StorageHost): Promise<Record<string, string>> {
|
* Puts the files and the settings of the former database in the engine. It only counts as done once
|
||||||
|
* the files are written: until then, it is tried again the next time. The files that are stored
|
||||||
|
* mean that it was done, by a visit that did not get to say so.
|
||||||
|
*/
|
||||||
|
async function importLegacyData(
|
||||||
|
host: StorageHost,
|
||||||
|
stored: Record<string, string>,
|
||||||
|
readLegacy: () => Promise<LegacyData | undefined>
|
||||||
|
): Promise<Record<string, string>> {
|
||||||
const { backend } = host;
|
const { backend } = host;
|
||||||
const imported: Record<string, string> = {};
|
const imported: Record<string, string> = {};
|
||||||
try {
|
if (!host.hasFiles()) {
|
||||||
const legacy = await readLegacyData();
|
try {
|
||||||
if (legacy) {
|
const legacy = await readLegacy();
|
||||||
const encoder = new TextEncoder();
|
if (legacy) {
|
||||||
if (legacy.files.length > 0) {
|
// what was changed since a previous try is more recent
|
||||||
await host.loadFiles(
|
for (const [key, value] of Object.entries(legacy.settings)) {
|
||||||
legacy.files.map((file) => ({
|
if (stored[key] === undefined) {
|
||||||
data: encoder.encode(file.gpx),
|
await backend.set_setting(key, value);
|
||||||
name: file.name,
|
imported[key] = value;
|
||||||
}))
|
}
|
||||||
);
|
}
|
||||||
await host.deselect();
|
if (legacy.files.length > 0 && !(await importFiles(host, legacy))) {
|
||||||
}
|
return imported;
|
||||||
for (const [key, value] of Object.entries(legacy.settings)) {
|
}
|
||||||
await backend.set_setting(key, value);
|
|
||||||
imported[key] = value;
|
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('The files of the previous version could not be imported', error);
|
||||||
|
return imported;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
// it is tried again next time
|
|
||||||
console.error('The files of the previous version could not be imported', error);
|
|
||||||
return imported;
|
|
||||||
}
|
}
|
||||||
await backend.set_setting(LEGACY_IMPORTED_KEY, 'true');
|
await backend.set_setting(LEGACY_IMPORTED_KEY, 'true');
|
||||||
imported[LEGACY_IMPORTED_KEY] = 'true';
|
imported[LEGACY_IMPORTED_KEY] = 'true';
|
||||||
return imported;
|
return imported;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Loads the files of the former database, and tells whether they are written. */
|
||||||
|
async function importFiles(host: StorageHost, legacy: LegacyData): Promise<boolean> {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const loaded = await host.loadFiles(
|
||||||
|
legacy.files.map((file) => ({
|
||||||
|
data: encoder.encode(file.gpx),
|
||||||
|
name: file.name,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
if (!loaded) {
|
||||||
|
console.error('The files of the previous version could not be loaded');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await host.deselect();
|
||||||
|
if (!(await host.backend.flush_storage())) {
|
||||||
|
console.error('The files of the previous version could not be written');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -3,52 +3,30 @@ import assert from 'node:assert/strict';
|
|||||||
import {
|
import {
|
||||||
categoryIntervals,
|
categoryIntervals,
|
||||||
categoryValue,
|
categoryValue,
|
||||||
categoryValues,
|
|
||||||
intervalAt,
|
intervalAt,
|
||||||
type CategoryIntervals,
|
type CategoryIntervals,
|
||||||
} from '$lib/engine/trackpoint-categories';
|
} from '$lib/engine/trackpoint-categories';
|
||||||
|
|
||||||
describe('categoryValues', () => {
|
/** The value of each of the `length` trackpoints, to compare with what is expected. */
|
||||||
// asphalt, asphalt, unknown, gravel, gravel, gravel, unknown
|
function categoryValues(
|
||||||
const intervals: CategoryIntervals = {
|
intervals: CategoryIntervals | undefined,
|
||||||
starts: new Uint32Array([0, 2, 3, 6]),
|
length: number
|
||||||
values: new Uint8Array([1, 0, 2, 0]),
|
): (string | undefined)[] {
|
||||||
names: ['asphalt', 'gravel'],
|
const result: (string | undefined)[] = new Array(length).fill(undefined);
|
||||||
};
|
if (!intervals) {
|
||||||
|
return result;
|
||||||
it('is the name of the value of the interval of each trackpoint', () => {
|
}
|
||||||
assert.deepEqual(categoryValues(intervals, 7), [
|
const { starts, values, names } = intervals;
|
||||||
'asphalt',
|
for (let i = 0; i < starts.length; i++) {
|
||||||
'asphalt',
|
if (!values[i]) {
|
||||||
undefined,
|
continue; // unknown, already `undefined`
|
||||||
'gravel',
|
}
|
||||||
'gravel',
|
const name = names[values[i] - 1];
|
||||||
'gravel',
|
const end = i + 1 < starts.length ? Math.min(starts[i + 1], length) : length;
|
||||||
undefined,
|
result.fill(name, starts[i], end);
|
||||||
]);
|
}
|
||||||
});
|
return result;
|
||||||
|
}
|
||||||
it('goes on with the last interval until the last trackpoint', () => {
|
|
||||||
const last = { ...intervals, values: new Uint8Array([1, 0, 2, 2]) };
|
|
||||||
assert.deepEqual(categoryValues(last, 9).slice(5), [
|
|
||||||
'gravel',
|
|
||||||
'gravel',
|
|
||||||
'gravel',
|
|
||||||
'gravel',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not go past the trackpoints', () => {
|
|
||||||
assert.deepEqual(categoryValues(intervals, 3), ['asphalt', 'asphalt', undefined]);
|
|
||||||
assert.deepEqual(categoryValues(intervals, 0), []);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is undefined without intervals, or for a code without name', () => {
|
|
||||||
assert.deepEqual(categoryValues(undefined, 2), [undefined, undefined]);
|
|
||||||
const unnamed = { starts: new Uint32Array([0]), values: new Uint8Array([3]), names: [] };
|
|
||||||
assert.deepEqual(categoryValues(unnamed, 2), [undefined, undefined]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('categoryIntervals', () => {
|
describe('categoryIntervals', () => {
|
||||||
it('groups the trackpoints that share a value', () => {
|
it('groups the trackpoints that share a value', () => {
|
||||||
|
|||||||
@@ -11,31 +11,6 @@ export type CategoryIntervals = {
|
|||||||
names: string[];
|
names: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* The value of each of the `length` trackpoints for a category of the engine, `undefined` when it
|
|
||||||
* is unknown, or when the category is missing (it is unknown for every trackpoint). The intervals
|
|
||||||
* are scanned once, so it takes a time linear in `length`.
|
|
||||||
*/
|
|
||||||
export function categoryValues(
|
|
||||||
intervals: CategoryIntervals | undefined,
|
|
||||||
length: number
|
|
||||||
): (string | undefined)[] {
|
|
||||||
const result: (string | undefined)[] = new Array(length).fill(undefined);
|
|
||||||
if (!intervals) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
const { starts, values, names } = intervals;
|
|
||||||
for (let i = 0; i < starts.length; i++) {
|
|
||||||
if (!values[i]) {
|
|
||||||
continue; // unknown, already `undefined`
|
|
||||||
}
|
|
||||||
const name = names[values[i] - 1];
|
|
||||||
const end = i + 1 < starts.length ? Math.min(starts[i + 1], length) : length;
|
|
||||||
result.fill(name, starts[i], end);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The index of the interval of `starts` (sorted) that the trackpoint `index` is in, -1 if none. */
|
/** The index of the interval of `starts` (sorted) that the trackpoint `index` is in, -1 if none. */
|
||||||
export function intervalAt(starts: Uint32Array, index: number): number {
|
export function intervalAt(starts: Uint32Array, index: number): number {
|
||||||
let low = 0;
|
let low = 0;
|
||||||
@@ -66,7 +41,7 @@ export function categoryValue(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The intervals of the trackpoints that share a value, for a value per trackpoint (`undefined`
|
* The intervals of the trackpoints that share a value, for a value per trackpoint (`undefined`
|
||||||
* when unknown): the inverse of `categoryValues`. The names are in the order of their first
|
* when unknown). The names are in the order of their first
|
||||||
* appearance. A value that is unknown everywhere gives no intervals.
|
* appearance. A value that is unknown everywhere gives no intervals.
|
||||||
*/
|
*/
|
||||||
export function categoryIntervals(values: readonly (string | undefined)[]): CategoryIntervals {
|
export function categoryIntervals(values: readonly (string | undefined)[]): CategoryIntervals {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ export type { FileStatus, FolderEntry, UnsavedChoice, WorkspaceState } from './b
|
|||||||
export type {
|
export type {
|
||||||
Clipboard,
|
Clipboard,
|
||||||
WaypointDetails,
|
WaypointDetails,
|
||||||
TrackpointDetails,
|
|
||||||
MoveTarget,
|
MoveTarget,
|
||||||
GlobalStatistics,
|
GlobalStatistics,
|
||||||
SelectionPoint,
|
SelectionPoint,
|
||||||
@@ -155,7 +154,9 @@ export type StatisticsMetric =
|
|||||||
/** `highway`, `sacScale` and `mtbScale`. */
|
/** `highway`, `sacScale` and `mtbScale`. */
|
||||||
| 'highway'
|
| 'highway'
|
||||||
/** `anchors`. */
|
/** `anchors`. */
|
||||||
| 'anchors';
|
| 'anchors'
|
||||||
|
/** `segments`. */
|
||||||
|
| 'segments';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The anchors of the routing tool among the trackpoints of the selection: the points that can be
|
* The anchors of the routing tool among the trackpoints of the selection: the points that can be
|
||||||
@@ -182,6 +183,15 @@ export type SelectionAnchors = {
|
|||||||
revision: number;
|
revision: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The segments of the selection that have trackpoints: segment `i` has the id `ids[i]`, and its
|
||||||
|
* trackpoints go from `starts[i]` until the next start (or the last trackpoint).
|
||||||
|
*/
|
||||||
|
export type SelectionSegments = {
|
||||||
|
starts: Uint32Array;
|
||||||
|
ids: string[];
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The smoothed elevation segments of the selection, over which the slope is averaged, as the
|
* The smoothed elevation segments of the selection, over which the slope is averaged, as the
|
||||||
* intervals of trackpoints that belong to the same one: segment `i` starts at the trackpoint
|
* intervals of trackpoints that belong to the same one: segment `i` starts at the trackpoint
|
||||||
@@ -229,6 +239,7 @@ export type SelectionStatistics = {
|
|||||||
/** Mountain biking scale. */
|
/** Mountain biking scale. */
|
||||||
mtbScale?: CategoryIntervals;
|
mtbScale?: CategoryIntervals;
|
||||||
anchors?: SelectionAnchors;
|
anchors?: SelectionAnchors;
|
||||||
|
segments?: SelectionSegments;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Global statistics of the trackpoints from `start` to `end` (both included), for example
|
* Global statistics of the trackpoints from `start` to `end` (both included), for example
|
||||||
|
|||||||
@@ -1,32 +1,6 @@
|
|||||||
import { describe, it } from 'node:test';
|
import { describe, it } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { closestPointIndex, closestPointIndexIn } from '$lib/geo/closest-point';
|
import { closestPointIndexIn } from '$lib/geo/closest-point';
|
||||||
|
|
||||||
describe('closestPointIndex', () => {
|
|
||||||
const line = [0, 0, 1, 0, 2, 0, 2, 1];
|
|
||||||
|
|
||||||
it('has no point on an empty polyline', () => {
|
|
||||||
assert.equal(closestPointIndex([], { lng: 0, lat: 0 }), undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is the only point of a polyline of one point', () => {
|
|
||||||
assert.equal(closestPointIndex([3, 4], { lng: 0, lat: 0 }), 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is the nearest end of the nearest segment', () => {
|
|
||||||
assert.equal(closestPointIndex(line, { lng: 0.2, lat: 0.1 }), 0);
|
|
||||||
assert.equal(closestPointIndex(line, { lng: 0.8, lat: 0.1 }), 1);
|
|
||||||
assert.equal(closestPointIndex(line, { lng: 1.6, lat: -0.1 }), 2);
|
|
||||||
assert.equal(closestPointIndex(line, { lng: 2.1, lat: 0.9 }), 3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses the distance to the segment, not to its ends', () => {
|
|
||||||
// far from every point but right next to the long segment
|
|
||||||
const coordinates = [0, 0, 10, 0, 10, 5];
|
|
||||||
assert.equal(closestPointIndex(coordinates, { lng: 7, lat: 0.01 }), 1);
|
|
||||||
assert.equal(closestPointIndex(coordinates, { lng: 3, lat: 0.01 }), 0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('closestPointIndexIn', () => {
|
describe('closestPointIndexIn', () => {
|
||||||
const lng = [0, 1, 2, 2, 5, 6];
|
const lng = [0, 1, 2, 2, 5, 6];
|
||||||
@@ -39,6 +13,22 @@ describe('closestPointIndexIn', () => {
|
|||||||
assert.equal(closestPointIndexIn(lng, lat, 4, 6, { lng: 5.9, lat: 5.1 }), 5);
|
assert.equal(closestPointIndexIn(lng, lat, 4, 6, { lng: 5.9, lat: 5.1 }), 5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('is the nearest end of the nearest segment', () => {
|
||||||
|
assert.equal(closestPointIndexIn(lng, lat, 0, 4, { lng: 0.2, lat: 0.1 }), 0);
|
||||||
|
assert.equal(closestPointIndexIn(lng, lat, 0, 4, { lng: 1.6, lat: -0.1 }), 2);
|
||||||
|
assert.equal(closestPointIndexIn(lng, lat, 0, 4, { lng: 2.1, lat: 0.9 }), 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the distance to the segment, not to its ends', () => {
|
||||||
|
// far from every point but right next to the long segment
|
||||||
|
const [x, y] = [
|
||||||
|
[0, 10, 10],
|
||||||
|
[0, 0, 5],
|
||||||
|
];
|
||||||
|
assert.equal(closestPointIndexIn(x, y, 0, 3, { lng: 7, lat: 0.01 }), 1);
|
||||||
|
assert.equal(closestPointIndexIn(x, y, 0, 3, { lng: 3, lat: 0.01 }), 0);
|
||||||
|
});
|
||||||
|
|
||||||
it('has no point in an empty range', () => {
|
it('has no point in an empty range', () => {
|
||||||
assert.equal(closestPointIndexIn(lng, lat, 3, 3, { lng: 0, lat: 0 }), undefined);
|
assert.equal(closestPointIndexIn(lng, lat, 3, 3, { lng: 0, lat: 0 }), undefined);
|
||||||
assert.equal(closestPointIndexIn(lng, lat, 4, 2, { lng: 0, lat: 0 }), undefined);
|
assert.equal(closestPointIndexIn(lng, lat, 4, 2, { lng: 0, lat: 0 }), undefined);
|
||||||
|
|||||||
@@ -1,25 +1,10 @@
|
|||||||
const metersPerDegree = 111320;
|
const metersPerDegree = 111320;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Index of the point of a polyline (flat `[lng, lat, ...]` coordinates) that is the closest to
|
* Index of the point, among the points `from` (included) to `to` (excluded) of a polyline given as
|
||||||
* `point`, among the two ends of the segment of the polyline that passes the closest to it.
|
* the arrays of its longitudes and latitudes, that is the closest to `point`: one of the two ends
|
||||||
* `undefined` for an empty polyline. Planar approximation, like the one used to simplify tracks.
|
* of the segment of the polyline that passes the closest to it. The index is the one in the arrays,
|
||||||
*/
|
* `undefined` for an empty range. Planar approximation, like the one used to simplify tracks.
|
||||||
export function closestPointIndex(
|
|
||||||
coordinates: ArrayLike<number>,
|
|
||||||
point: { lng: number; lat: number }
|
|
||||||
): number | undefined {
|
|
||||||
return closestIndex(
|
|
||||||
Math.floor(coordinates.length / 2),
|
|
||||||
(i) => coordinates[2 * i],
|
|
||||||
(i) => coordinates[2 * i + 1],
|
|
||||||
point
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Like `closestPointIndex`, for the points `from` (included) to `to` (excluded) of a polyline
|
|
||||||
* given as the arrays of its longitudes and latitudes. The index is the one in the arrays.
|
|
||||||
*/
|
*/
|
||||||
export function closestPointIndexIn(
|
export function closestPointIndexIn(
|
||||||
lng: ArrayLike<number>,
|
lng: ArrayLike<number>,
|
||||||
|
|||||||
@@ -18,3 +18,9 @@ engine.statistics.subscribe(() => slicedStatistics.set(undefined));
|
|||||||
|
|
||||||
/** The point of the elevation profile that the pointer is over, shown on the map. */
|
/** The point of the elevation profile that the pointer is over, shown on the map. */
|
||||||
export const hoveredPoint: Writable<Coordinates | null> = writable(null);
|
export const hoveredPoint: Writable<Coordinates | null> = writable(null);
|
||||||
|
|
||||||
|
/** The trackpoint of the selection that the pointer is over on the map, shown on the profile. */
|
||||||
|
export const hoveredTrackpoint: Writable<number | null> = writable(null);
|
||||||
|
|
||||||
|
// its index only makes sense for the selection it was found in
|
||||||
|
engine.statistics.subscribe(() => hoveredTrackpoint.set(null));
|
||||||
@@ -141,11 +141,13 @@ describe('layer trees', () => {
|
|||||||
assert.deepEqual(filterLayerTree({}, allowed), allowed);
|
assert.deepEqual(filterLayerTree({}, allowed), allowed);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('drops the layers that are not allowed anymore, but not the custom ones', () => {
|
it('drops the layers that are not allowed anymore and those of extensions, not the custom ones', () => {
|
||||||
const filtered = filterLayerTree(
|
const filtered = filterLayerTree(
|
||||||
{
|
{
|
||||||
world: { a: true, gone: true, 'custom-1': true, 'extension-x': false },
|
world: { a: true, gone: true, 'custom-1': true, 'extension-x': true },
|
||||||
old: { x: true },
|
old: { x: true },
|
||||||
|
extension: { 'extension-Name/y': true },
|
||||||
|
custom: { 'custom-2': false },
|
||||||
},
|
},
|
||||||
allowed
|
allowed
|
||||||
);
|
);
|
||||||
@@ -153,10 +155,10 @@ describe('layer trees', () => {
|
|||||||
a: true,
|
a: true,
|
||||||
b: false,
|
b: false,
|
||||||
'custom-1': true,
|
'custom-1': true,
|
||||||
'extension-x': false,
|
|
||||||
});
|
});
|
||||||
assert.equal('old' in filtered, true);
|
assert.equal('old' in filtered, false);
|
||||||
assert.deepEqual(filtered.old, {});
|
assert.equal('extension' in filtered, false);
|
||||||
|
assert.deepEqual(filtered.custom, { 'custom-2': false });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not take a branch for a layer, or a layer for a branch', () => {
|
it('does not take a branch for a layer, or a layer for a branch', () => {
|
||||||
|
|||||||
@@ -196,7 +196,9 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The saved tree `t`, with the layers of `allowed`: the choices saved for them are kept, the new
|
* The saved tree `t`, with the layers of `allowed`: the choices saved for them are kept, the new
|
||||||
* ones have their default, and of the others only the custom and extension layers are kept.
|
* ones have their default, and of the others only the custom layers are kept. The layers of
|
||||||
|
* extensions are saved with the others, but not read back: they come again when an extension sets
|
||||||
|
* them, if it is still there.
|
||||||
*/
|
*/
|
||||||
export function filterLayerTree(t: unknown, allowed: LayerTreeType | undefined): LayerTreeType {
|
export function filterLayerTree(t: unknown, allowed: LayerTreeType | undefined): LayerTreeType {
|
||||||
const tree = isRecord(t) ? t : {};
|
const tree = isRecord(t) ? t : {};
|
||||||
@@ -214,11 +216,14 @@ export function filterLayerTree(t: unknown, allowed: LayerTreeType | undefined):
|
|||||||
Object.entries(tree).forEach(([key, value]) => {
|
Object.entries(tree).forEach(([key, value]) => {
|
||||||
if (!Object.hasOwn(filtered, key)) {
|
if (!Object.hasOwn(filtered, key)) {
|
||||||
if (typeof value === 'boolean') {
|
if (typeof value === 'boolean') {
|
||||||
if (key.startsWith('custom-') || key.startsWith('extension-')) {
|
if (key.startsWith('custom-')) {
|
||||||
filtered[key] = value;
|
filtered[key] = value;
|
||||||
}
|
}
|
||||||
} else if (isRecord(value)) {
|
} else if (isRecord(value)) {
|
||||||
filtered[key] = filterLayerTree(value, undefined);
|
const group = filterLayerTree(value, undefined);
|
||||||
|
if (Object.keys(group).length > 0) {
|
||||||
|
filtered[key] = group;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -95,6 +95,21 @@ describe('legacyFileToGPX', () => {
|
|||||||
assert.ok(gpx.includes('href="https://example.com/?a="1"&b=2"'));
|
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', () => {
|
it('leaves out what is missing or empty', () => {
|
||||||
const gpx = legacyFileToGPX({
|
const gpx = legacyFileToGPX({
|
||||||
metadata: { name: '', desc: null, time: 'not a date' },
|
metadata: { name: '', desc: null, time: 'not a date' },
|
||||||
|
|||||||
@@ -84,7 +84,11 @@ function timeElement(value: unknown): string {
|
|||||||
return isNaN(date.getTime()) ? '' : element('time', date.toISOString());
|
return isNaN(date.getTime()) ? '' : element('time', date.toISOString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The link of an element, or the links: the former parser gave a list when there were several. */
|
||||||
function link(value: Json): string {
|
function link(value: Json): string {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(link).join('');
|
||||||
|
}
|
||||||
const href = value?.attributes?.href;
|
const href = value?.attributes?.href;
|
||||||
return href
|
return href
|
||||||
? `<link href="${escape(href).replaceAll('"', '"')}">${element('text', value.text)}</link>`
|
? `<link href="${escape(href).replaceAll('"', '"')}">${element('text', value.text)}</link>`
|
||||||
|
|||||||
@@ -598,5 +598,12 @@
|
|||||||
"title": "This page is out of date",
|
"title": "This page is out of date",
|
||||||
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
||||||
"reload": "Reload"
|
"reload": "Reload"
|
||||||
|
},
|
||||||
|
"desktop": {
|
||||||
|
"update_available": "gpx.studio {version} is available",
|
||||||
|
"update_restart": "Update and restart",
|
||||||
|
"update_download": "Download",
|
||||||
|
"update_installing": "Installing the update...",
|
||||||
|
"update_failed": "The update could not be installed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -598,5 +598,12 @@
|
|||||||
"title": "This page is out of date",
|
"title": "This page is out of date",
|
||||||
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
||||||
"reload": "Reload"
|
"reload": "Reload"
|
||||||
|
},
|
||||||
|
"desktop": {
|
||||||
|
"update_available": "gpx.studio {version} is available",
|
||||||
|
"update_restart": "Update and restart",
|
||||||
|
"update_download": "Download",
|
||||||
|
"update_installing": "Installing the update...",
|
||||||
|
"update_failed": "The update could not be installed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -598,5 +598,12 @@
|
|||||||
"title": "This page is out of date",
|
"title": "This page is out of date",
|
||||||
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
||||||
"reload": "Reload"
|
"reload": "Reload"
|
||||||
|
},
|
||||||
|
"desktop": {
|
||||||
|
"update_available": "gpx.studio {version} is available",
|
||||||
|
"update_restart": "Update and restart",
|
||||||
|
"update_download": "Download",
|
||||||
|
"update_installing": "Installing the update...",
|
||||||
|
"update_failed": "The update could not be installed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -598,5 +598,12 @@
|
|||||||
"title": "This page is out of date",
|
"title": "This page is out of date",
|
||||||
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
||||||
"reload": "Reload"
|
"reload": "Reload"
|
||||||
|
},
|
||||||
|
"desktop": {
|
||||||
|
"update_available": "gpx.studio {version} is available",
|
||||||
|
"update_restart": "Update and restart",
|
||||||
|
"update_download": "Download",
|
||||||
|
"update_installing": "Installing the update...",
|
||||||
|
"update_failed": "The update could not be installed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -598,5 +598,12 @@
|
|||||||
"title": "This page is out of date",
|
"title": "This page is out of date",
|
||||||
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
||||||
"reload": "Reload"
|
"reload": "Reload"
|
||||||
|
},
|
||||||
|
"desktop": {
|
||||||
|
"update_available": "gpx.studio {version} is available",
|
||||||
|
"update_restart": "Update and restart",
|
||||||
|
"update_download": "Download",
|
||||||
|
"update_installing": "Installing the update...",
|
||||||
|
"update_failed": "The update could not be installed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -598,5 +598,12 @@
|
|||||||
"title": "This page is out of date",
|
"title": "This page is out of date",
|
||||||
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
||||||
"reload": "Reload"
|
"reload": "Reload"
|
||||||
|
},
|
||||||
|
"desktop": {
|
||||||
|
"update_available": "gpx.studio {version} is available",
|
||||||
|
"update_restart": "Update and restart",
|
||||||
|
"update_download": "Download",
|
||||||
|
"update_installing": "Installing the update...",
|
||||||
|
"update_failed": "The update could not be installed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -598,5 +598,12 @@
|
|||||||
"title": "This page is out of date",
|
"title": "This page is out of date",
|
||||||
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
||||||
"reload": "Reload"
|
"reload": "Reload"
|
||||||
|
},
|
||||||
|
"desktop": {
|
||||||
|
"update_available": "gpx.studio {version} is available",
|
||||||
|
"update_restart": "Update and restart",
|
||||||
|
"update_download": "Download",
|
||||||
|
"update_installing": "Installing the update...",
|
||||||
|
"update_failed": "The update could not be installed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+44
-37
@@ -9,42 +9,42 @@
|
|||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"new_file": "Nuevo archivo",
|
"new_file": "Nuevo archivo",
|
||||||
"new_route_file": "New route file",
|
"new_route_file": "Nuevo archivo de ruta",
|
||||||
"new_track": "Nuevo recorrido",
|
"new_track": "Nuevo recorrido",
|
||||||
"new_route": "New route",
|
"new_route": "Nueva ruta",
|
||||||
"convert_to_route": "Convert to route",
|
"convert_to_route": "Convertir a ruta",
|
||||||
"convert_to_track": "Convert to track",
|
"convert_to_track": "Convertir a recorrido",
|
||||||
"convert_to_routes": "Convert to route file",
|
"convert_to_routes": "Convertir a archivo de ruta",
|
||||||
"convert_to_tracks": "Convert to track file",
|
"convert_to_tracks": "Convertir a archivo de recorrido",
|
||||||
"new_segment": "Nuevo segmento",
|
"new_segment": "Nuevo segmento",
|
||||||
"open": "Abrir...",
|
"open": "Abrir...",
|
||||||
"open_folder": "Open folder...",
|
"open_folder": "Abrir carpeta...",
|
||||||
"explorer": "Explorer",
|
"explorer": "Explorador",
|
||||||
"explorer_menu": {
|
"explorer_menu": {
|
||||||
"open": "Open",
|
"open": "Abrir",
|
||||||
"new_file": "New file...",
|
"new_file": "Nuevo archivo...",
|
||||||
"new_route_file": "New route file...",
|
"new_route_file": "Nuevo archivo de ruta...",
|
||||||
"new_folder": "New folder...",
|
"new_folder": "Nueva carpeta...",
|
||||||
"rename": "Rename...",
|
"rename": "Renombrar...",
|
||||||
"reveal_finder": "Reveal in Finder",
|
"reveal_finder": "Mostrar en el buscador",
|
||||||
"reveal": "Reveal in file explorer",
|
"reveal": "Mostrar en explorador de archivos",
|
||||||
"delete_failed": "The files could not be moved to the trash."
|
"delete_failed": "No se pudieron mover los archivos a la papelera."
|
||||||
},
|
},
|
||||||
"workspace": "Workspace",
|
"workspace": "Espacio de trabajo",
|
||||||
"save": "Save",
|
"save": "Guardar",
|
||||||
"save_all": "Save all",
|
"save_all": "Guardar todo",
|
||||||
"save_as": "Save as...",
|
"save_as": "Guardar como…",
|
||||||
"close": "Cerrar",
|
"close": "Cerrar",
|
||||||
"close_all": "Cerrar todo",
|
"close_all": "Cerrar todo",
|
||||||
"close_folder": "Close folder",
|
"close_folder": "Cerrar carpeta",
|
||||||
"reload": "Reload from disk",
|
"reload": "Recargar desde el disco",
|
||||||
"discard": "Discard",
|
"discard": "Descartar",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancelar",
|
||||||
"overwrite": "Overwrite",
|
"overwrite": "Sobreescribir",
|
||||||
"unsaved_title": "Unsaved changes",
|
"unsaved_title": "Cambios sin guardar",
|
||||||
"unsaved_description": "These files have changes that are not saved:",
|
"unsaved_description": "Estos archivos tienen cambios que no están guardados:",
|
||||||
"overwrite_title": "Overwrite changes made elsewhere?",
|
"overwrite_title": "¿Sobrescribir cambios realizados en otro lugar?",
|
||||||
"overwrite_description": "These files have changes, and were also changed on the disk. Overwrite what is on the disk, or reload it and lose your changes?",
|
"overwrite_description": "Estos ficheros tienen cambios, y también fueron modificados en el disco. ¿Sobrescribir lo que hay en el disco, o volver a cargarlo y perder los cambios?",
|
||||||
"duplicate": "Duplicar track",
|
"duplicate": "Duplicar track",
|
||||||
"copy": "Copiar",
|
"copy": "Copiar",
|
||||||
"paste": "Pegar",
|
"paste": "Pegar",
|
||||||
@@ -102,9 +102,9 @@
|
|||||||
"name": "Nombre",
|
"name": "Nombre",
|
||||||
"description": "Descripción",
|
"description": "Descripción",
|
||||||
"save": "Guardar",
|
"save": "Guardar",
|
||||||
"rename_file": "Rename the file on disk",
|
"rename_file": "Renombrar el archivo en disco",
|
||||||
"name_taken": "A file with this name already exists in the folder.",
|
"name_taken": "Ya existe un archivo con este nombre en la carpeta.",
|
||||||
"rename_failed": "The file could not be renamed."
|
"rename_failed": "No se pudo renombrar el archivo."
|
||||||
},
|
},
|
||||||
"style": {
|
"style": {
|
||||||
"button": "Apariencia...",
|
"button": "Apariencia...",
|
||||||
@@ -468,8 +468,8 @@
|
|||||||
"gpx": {
|
"gpx": {
|
||||||
"file": "Archivo",
|
"file": "Archivo",
|
||||||
"files": "Archivos",
|
"files": "Archivos",
|
||||||
"route": "Route",
|
"route": "Ruta",
|
||||||
"routes": "Routes",
|
"routes": "Rutas",
|
||||||
"track": "Track",
|
"track": "Track",
|
||||||
"tracks": "Tracks",
|
"tracks": "Tracks",
|
||||||
"segment": "Segmento",
|
"segment": "Segmento",
|
||||||
@@ -595,8 +595,15 @@
|
|||||||
"enable_webgl2": "Aprenda cómo activar WebGL 2 en su navegador",
|
"enable_webgl2": "Aprenda cómo activar WebGL 2 en su navegador",
|
||||||
"page_not_found": "página no encontrada",
|
"page_not_found": "página no encontrada",
|
||||||
"outdated": {
|
"outdated": {
|
||||||
"title": "This page is out of date",
|
"title": "Esta página está desactualizada",
|
||||||
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
"description": "Tus archivos fueron guardados por una versión más reciente de gpx.studio, que esta página no puede leer. Recarga la página para obtener la última versión.",
|
||||||
"reload": "Reload"
|
"reload": "Recargar"
|
||||||
|
},
|
||||||
|
"desktop": {
|
||||||
|
"update_available": "gpx.studio {version} está disponible",
|
||||||
|
"update_restart": "Actualizar y reiniciar",
|
||||||
|
"update_download": "Descargar",
|
||||||
|
"update_installing": "Instalando la actualización...",
|
||||||
|
"update_failed": "No se pudo instalar la actualización"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+44
-37
@@ -9,42 +9,42 @@
|
|||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"new_file": "Fitxategi berria",
|
"new_file": "Fitxategi berria",
|
||||||
"new_route_file": "New route file",
|
"new_route_file": "Ibilbide fitxategi berria",
|
||||||
"new_track": "Arrasto berria",
|
"new_track": "Arrasto berria",
|
||||||
"new_route": "New route",
|
"new_route": "Ibilbide berria",
|
||||||
"convert_to_route": "Convert to route",
|
"convert_to_route": "Bihurtu ibilbide",
|
||||||
"convert_to_track": "Convert to track",
|
"convert_to_track": "Bihurtu arrasto",
|
||||||
"convert_to_routes": "Convert to route file",
|
"convert_to_routes": "Bihurtu ibilbide fitxategi",
|
||||||
"convert_to_tracks": "Convert to track file",
|
"convert_to_tracks": "Bihurtu arrasto fitxategi",
|
||||||
"new_segment": "Segmentu berria",
|
"new_segment": "Segmentu berria",
|
||||||
"open": "Ireki...",
|
"open": "Ireki...",
|
||||||
"open_folder": "Open folder...",
|
"open_folder": "Ireki karpeta...",
|
||||||
"explorer": "Explorer",
|
"explorer": "Arakatzailea",
|
||||||
"explorer_menu": {
|
"explorer_menu": {
|
||||||
"open": "Open",
|
"open": "Ireki",
|
||||||
"new_file": "New file...",
|
"new_file": "Fitxategi berria...",
|
||||||
"new_route_file": "New route file...",
|
"new_route_file": "Ibilbide fitxategi berria...",
|
||||||
"new_folder": "New folder...",
|
"new_folder": "Karpeta berria...",
|
||||||
"rename": "Rename...",
|
"rename": "Aldatu izena...",
|
||||||
"reveal_finder": "Reveal in Finder",
|
"reveal_finder": "Erakutsi Finder-en",
|
||||||
"reveal": "Reveal in file explorer",
|
"reveal": "Erakutsi fitxategi-arakatzailean",
|
||||||
"delete_failed": "The files could not be moved to the trash."
|
"delete_failed": "Fitxategiak ezin dira zakarrontzira mugitu."
|
||||||
},
|
},
|
||||||
"workspace": "Workspace",
|
"workspace": "Laneko area",
|
||||||
"save": "Save",
|
"save": "Gorde",
|
||||||
"save_all": "Save all",
|
"save_all": "Gorde denak",
|
||||||
"save_as": "Save as...",
|
"save_as": "Gorde honela...",
|
||||||
"close": "Itxi",
|
"close": "Itxi",
|
||||||
"close_all": "Itxi denak",
|
"close_all": "Itxi denak",
|
||||||
"close_folder": "Close folder",
|
"close_folder": "Itxi karpeta",
|
||||||
"reload": "Reload from disk",
|
"reload": "Birkargatu diskotik",
|
||||||
"discard": "Discard",
|
"discard": "Baztertu",
|
||||||
"cancel": "Cancel",
|
"cancel": "Utzi",
|
||||||
"overwrite": "Overwrite",
|
"overwrite": "Gainidatzi",
|
||||||
"unsaved_title": "Unsaved changes",
|
"unsaved_title": "Gorde gabeko aldaketak",
|
||||||
"unsaved_description": "These files have changes that are not saved:",
|
"unsaved_description": "Fitxategi hauek gorde gabeko aldaketak dituzte:",
|
||||||
"overwrite_title": "Overwrite changes made elsewhere?",
|
"overwrite_title": "Egindako aldaketak gainidatzi nahi dituzu?",
|
||||||
"overwrite_description": "These files have changes, and were also changed on the disk. Overwrite what is on the disk, or reload it and lose your changes?",
|
"overwrite_description": "Fitxategi hauek aldaketak dituzte, eta diskoan ere aldatu dira. Gainidatzi diskoan dagoena edo berriro kargatu eta zure aldaketak galdu?",
|
||||||
"duplicate": "Kopia egin",
|
"duplicate": "Kopia egin",
|
||||||
"copy": "Kopiatu",
|
"copy": "Kopiatu",
|
||||||
"paste": "Itsatsi",
|
"paste": "Itsatsi",
|
||||||
@@ -102,9 +102,9 @@
|
|||||||
"name": "Izena",
|
"name": "Izena",
|
||||||
"description": "Deskribapena",
|
"description": "Deskribapena",
|
||||||
"save": "Gorde",
|
"save": "Gorde",
|
||||||
"rename_file": "Rename the file on disk",
|
"rename_file": "Berrizendatu diskoko fitxategia",
|
||||||
"name_taken": "A file with this name already exists in the folder.",
|
"name_taken": "Izen hau duen fitxategi bat dagoeneko badago karpetan.",
|
||||||
"rename_failed": "The file could not be renamed."
|
"rename_failed": "Fitxategia ezin da berrizendatu."
|
||||||
},
|
},
|
||||||
"style": {
|
"style": {
|
||||||
"button": "Itxura...",
|
"button": "Itxura...",
|
||||||
@@ -468,8 +468,8 @@
|
|||||||
"gpx": {
|
"gpx": {
|
||||||
"file": "Fitxategia",
|
"file": "Fitxategia",
|
||||||
"files": "Fitxategiak",
|
"files": "Fitxategiak",
|
||||||
"route": "Route",
|
"route": "Arrastoa",
|
||||||
"routes": "Routes",
|
"routes": "Ibilbideak",
|
||||||
"track": "Arrastoa",
|
"track": "Arrastoa",
|
||||||
"tracks": "Arrastoak",
|
"tracks": "Arrastoak",
|
||||||
"segment": "Sementua",
|
"segment": "Sementua",
|
||||||
@@ -595,8 +595,15 @@
|
|||||||
"enable_webgl2": "Ikasi zure nabigatzailean WebGL 2 aktibatzen",
|
"enable_webgl2": "Ikasi zure nabigatzailean WebGL 2 aktibatzen",
|
||||||
"page_not_found": "orrialdea ez da aurkitu",
|
"page_not_found": "orrialdea ez da aurkitu",
|
||||||
"outdated": {
|
"outdated": {
|
||||||
"title": "This page is out of date",
|
"title": "Orrialde hau zaharkituta dago",
|
||||||
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
"description": "Zure fitxategiak gpx.studio-ren bertsio berriago batek gorde ditu, orrialde honek irakurri ezin duena. Birkargatu orria azken bertsioa lortzeko.",
|
||||||
"reload": "Reload"
|
"reload": "Birkargatu"
|
||||||
|
},
|
||||||
|
"desktop": {
|
||||||
|
"update_available": "gpx.studio {version} eskuragarri dago",
|
||||||
|
"update_restart": "Eguneratu eta berrabiarazi",
|
||||||
|
"update_download": "Deskargatu",
|
||||||
|
"update_installing": "Eguneraketa instalatzen...",
|
||||||
|
"update_failed": "Eguneraketa ezin izan da instalatu"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -598,5 +598,12 @@
|
|||||||
"title": "This page is out of date",
|
"title": "This page is out of date",
|
||||||
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
||||||
"reload": "Reload"
|
"reload": "Reload"
|
||||||
|
},
|
||||||
|
"desktop": {
|
||||||
|
"update_available": "gpx.studio {version} is available",
|
||||||
|
"update_restart": "Update and restart",
|
||||||
|
"update_download": "Download",
|
||||||
|
"update_installing": "Installing the update...",
|
||||||
|
"update_failed": "The update could not be installed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -598,5 +598,12 @@
|
|||||||
"title": "Cette page n'est pas à jour",
|
"title": "Cette page n'est pas à jour",
|
||||||
"description": "Vos fichiers ont été enregistrés par une nouvelle version de gpx.studio, que cette page ne peut pas lire. Rechargez la page pour obtenir la dernière version.",
|
"description": "Vos fichiers ont été enregistrés par une nouvelle version de gpx.studio, que cette page ne peut pas lire. Rechargez la page pour obtenir la dernière version.",
|
||||||
"reload": "Recharger"
|
"reload": "Recharger"
|
||||||
|
},
|
||||||
|
"desktop": {
|
||||||
|
"update_available": "gpx.studio {version} est disponible",
|
||||||
|
"update_restart": "Mettre à jour et redémarrer",
|
||||||
|
"update_download": "Télécharger",
|
||||||
|
"update_installing": "Installation de la mise à jour...",
|
||||||
|
"update_failed": "La mise à jour n'a pas pu être installée"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -598,5 +598,12 @@
|
|||||||
"title": "This page is out of date",
|
"title": "This page is out of date",
|
||||||
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
||||||
"reload": "Reload"
|
"reload": "Reload"
|
||||||
|
},
|
||||||
|
"desktop": {
|
||||||
|
"update_available": "gpx.studio {version} is available",
|
||||||
|
"update_restart": "Update and restart",
|
||||||
|
"update_download": "Download",
|
||||||
|
"update_installing": "Installing the update...",
|
||||||
|
"update_failed": "The update could not be installed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -598,5 +598,12 @@
|
|||||||
"title": "This page is out of date",
|
"title": "This page is out of date",
|
||||||
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
||||||
"reload": "Reload"
|
"reload": "Reload"
|
||||||
|
},
|
||||||
|
"desktop": {
|
||||||
|
"update_available": "gpx.studio {version} is available",
|
||||||
|
"update_restart": "Update and restart",
|
||||||
|
"update_download": "Download",
|
||||||
|
"update_installing": "Installing the update...",
|
||||||
|
"update_failed": "The update could not be installed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -598,5 +598,12 @@
|
|||||||
"title": "This page is out of date",
|
"title": "This page is out of date",
|
||||||
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
||||||
"reload": "Reload"
|
"reload": "Reload"
|
||||||
|
},
|
||||||
|
"desktop": {
|
||||||
|
"update_available": "gpx.studio {version} is available",
|
||||||
|
"update_restart": "Update and restart",
|
||||||
|
"update_download": "Download",
|
||||||
|
"update_installing": "Installing the update...",
|
||||||
|
"update_failed": "The update could not be installed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -598,5 +598,12 @@
|
|||||||
"title": "This page is out of date",
|
"title": "This page is out of date",
|
||||||
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
||||||
"reload": "Reload"
|
"reload": "Reload"
|
||||||
|
},
|
||||||
|
"desktop": {
|
||||||
|
"update_available": "gpx.studio {version} is available",
|
||||||
|
"update_restart": "Update and restart",
|
||||||
|
"update_download": "Download",
|
||||||
|
"update_installing": "Installing the update...",
|
||||||
|
"update_failed": "The update could not be installed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -598,5 +598,12 @@
|
|||||||
"title": "This page is out of date",
|
"title": "This page is out of date",
|
||||||
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
||||||
"reload": "Reload"
|
"reload": "Reload"
|
||||||
|
},
|
||||||
|
"desktop": {
|
||||||
|
"update_available": "gpx.studio {version} is available",
|
||||||
|
"update_restart": "Update and restart",
|
||||||
|
"update_download": "Download",
|
||||||
|
"update_installing": "Installing the update...",
|
||||||
|
"update_failed": "The update could not be installed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"new_file": "Naujas failas",
|
"new_file": "Naujas failas",
|
||||||
"new_route_file": "New route file",
|
"new_route_file": "Naujas maršruto failas",
|
||||||
"new_track": "Nauja trasa",
|
"new_track": "Nauja trasa",
|
||||||
"new_route": "New route",
|
"new_route": "New route",
|
||||||
"convert_to_route": "Convert to route",
|
"convert_to_route": "Convert to route",
|
||||||
@@ -146,7 +146,7 @@
|
|||||||
"racing_bike": "Kelių dviračiu",
|
"racing_bike": "Kelių dviračiu",
|
||||||
"gravel_bike": "Bekelės dviračiu",
|
"gravel_bike": "Bekelės dviračiu",
|
||||||
"mountain_bike": "Kalnų dviračiu",
|
"mountain_bike": "Kalnų dviračiu",
|
||||||
"foot": "Bėgimas / ėjimas",
|
"foot": "Bėgimas / žygiavimas",
|
||||||
"motorcycle": "Motociklu",
|
"motorcycle": "Motociklu",
|
||||||
"water": "Vandeniu",
|
"water": "Vandeniu",
|
||||||
"railway": "Geležinkeliu"
|
"railway": "Geležinkeliu"
|
||||||
@@ -181,7 +181,7 @@
|
|||||||
"unknown": "Unknown",
|
"unknown": "Unknown",
|
||||||
"motorway": "Highway",
|
"motorway": "Highway",
|
||||||
"motorway_link": "Highway link",
|
"motorway_link": "Highway link",
|
||||||
"trunk": "Primary road",
|
"trunk": "Pagrindinis kelias",
|
||||||
"trunk_link": "Primary road link",
|
"trunk_link": "Primary road link",
|
||||||
"primary": "Primary road",
|
"primary": "Primary road",
|
||||||
"primary_link": "Primary road link",
|
"primary_link": "Primary road link",
|
||||||
@@ -552,7 +552,7 @@
|
|||||||
},
|
},
|
||||||
"docs": {
|
"docs": {
|
||||||
"translate": "Improve the translation on Crowdin",
|
"translate": "Improve the translation on Crowdin",
|
||||||
"answer_not_found": "Did not find what you were looking for?",
|
"answer_not_found": "Neradote, ko ieškojote?",
|
||||||
"ask_on_reddit": "Ask the community on Reddit",
|
"ask_on_reddit": "Ask the community on Reddit",
|
||||||
"search": {
|
"search": {
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
@@ -598,5 +598,12 @@
|
|||||||
"title": "This page is out of date",
|
"title": "This page is out of date",
|
||||||
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
||||||
"reload": "Reload"
|
"reload": "Reload"
|
||||||
|
},
|
||||||
|
"desktop": {
|
||||||
|
"update_available": "gpx.studio {version} is available",
|
||||||
|
"update_restart": "Update and restart",
|
||||||
|
"update_download": "Download",
|
||||||
|
"update_installing": "Installing the update...",
|
||||||
|
"update_failed": "Nepavyko įdiegti atnaujinimo"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -598,5 +598,12 @@
|
|||||||
"title": "This page is out of date",
|
"title": "This page is out of date",
|
||||||
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
||||||
"reload": "Reload"
|
"reload": "Reload"
|
||||||
|
},
|
||||||
|
"desktop": {
|
||||||
|
"update_available": "gpx.studio {version} is available",
|
||||||
|
"update_restart": "Update and restart",
|
||||||
|
"update_download": "Download",
|
||||||
|
"update_installing": "Installing the update...",
|
||||||
|
"update_failed": "The update could not be installed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -598,5 +598,12 @@
|
|||||||
"title": "Deze pagina is verouderd",
|
"title": "Deze pagina is verouderd",
|
||||||
"description": "Uw bestanden zijn opgeslagen door een nieuwere versie van gpx.studio, die deze pagina niet kan lezen. Herlaad de pagina om de nieuwste versie te krijgen.",
|
"description": "Uw bestanden zijn opgeslagen door een nieuwere versie van gpx.studio, die deze pagina niet kan lezen. Herlaad de pagina om de nieuwste versie te krijgen.",
|
||||||
"reload": "Vernieuwen"
|
"reload": "Vernieuwen"
|
||||||
|
},
|
||||||
|
"desktop": {
|
||||||
|
"update_available": "gpx.studio {version} is beschikbaar",
|
||||||
|
"update_restart": "Bijwerken en opnieuw opstarten",
|
||||||
|
"update_download": "Download",
|
||||||
|
"update_installing": "De update wordt geïnstalleerd...",
|
||||||
|
"update_failed": "De update kon niet worden geïnstalleerd"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -598,5 +598,12 @@
|
|||||||
"title": "This page is out of date",
|
"title": "This page is out of date",
|
||||||
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
||||||
"reload": "Reload"
|
"reload": "Reload"
|
||||||
|
},
|
||||||
|
"desktop": {
|
||||||
|
"update_available": "gpx.studio {version} is available",
|
||||||
|
"update_restart": "Update and restart",
|
||||||
|
"update_download": "Download",
|
||||||
|
"update_installing": "Installing the update...",
|
||||||
|
"update_failed": "The update could not be installed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -598,5 +598,12 @@
|
|||||||
"title": "This page is out of date",
|
"title": "This page is out of date",
|
||||||
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
||||||
"reload": "Reload"
|
"reload": "Reload"
|
||||||
|
},
|
||||||
|
"desktop": {
|
||||||
|
"update_available": "gpx.studio {version} is available",
|
||||||
|
"update_restart": "Update and restart",
|
||||||
|
"update_download": "Download",
|
||||||
|
"update_installing": "Installing the update...",
|
||||||
|
"update_failed": "The update could not be installed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -598,5 +598,12 @@
|
|||||||
"title": "This page is out of date",
|
"title": "This page is out of date",
|
||||||
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
||||||
"reload": "Reload"
|
"reload": "Reload"
|
||||||
|
},
|
||||||
|
"desktop": {
|
||||||
|
"update_available": "gpx.studio {version} is available",
|
||||||
|
"update_restart": "Update and restart",
|
||||||
|
"update_download": "Download",
|
||||||
|
"update_installing": "Installing the update...",
|
||||||
|
"update_failed": "The update could not be installed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -598,5 +598,12 @@
|
|||||||
"title": "This page is out of date",
|
"title": "This page is out of date",
|
||||||
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
||||||
"reload": "Reload"
|
"reload": "Reload"
|
||||||
|
},
|
||||||
|
"desktop": {
|
||||||
|
"update_available": "gpx.studio {version} is available",
|
||||||
|
"update_restart": "Update and restart",
|
||||||
|
"update_download": "Download",
|
||||||
|
"update_installing": "Installing the update...",
|
||||||
|
"update_failed": "The update could not be installed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -598,5 +598,12 @@
|
|||||||
"title": "This page is out of date",
|
"title": "This page is out of date",
|
||||||
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
||||||
"reload": "Reload"
|
"reload": "Reload"
|
||||||
|
},
|
||||||
|
"desktop": {
|
||||||
|
"update_available": "gpx.studio {version} is available",
|
||||||
|
"update_restart": "Update and restart",
|
||||||
|
"update_download": "Download",
|
||||||
|
"update_installing": "Installing the update...",
|
||||||
|
"update_failed": "The update could not be installed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -598,5 +598,12 @@
|
|||||||
"title": "This page is out of date",
|
"title": "This page is out of date",
|
||||||
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
||||||
"reload": "Reload"
|
"reload": "Reload"
|
||||||
|
},
|
||||||
|
"desktop": {
|
||||||
|
"update_available": "gpx.studio {version} is available",
|
||||||
|
"update_restart": "Update and restart",
|
||||||
|
"update_download": "Download",
|
||||||
|
"update_installing": "Installing the update...",
|
||||||
|
"update_failed": "The update could not be installed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -598,5 +598,12 @@
|
|||||||
"title": "This page is out of date",
|
"title": "This page is out of date",
|
||||||
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
||||||
"reload": "Reload"
|
"reload": "Reload"
|
||||||
|
},
|
||||||
|
"desktop": {
|
||||||
|
"update_available": "gpx.studio {version} is available",
|
||||||
|
"update_restart": "Update and restart",
|
||||||
|
"update_download": "Download",
|
||||||
|
"update_installing": "Installing the update...",
|
||||||
|
"update_failed": "The update could not be installed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -598,5 +598,12 @@
|
|||||||
"title": "This page is out of date",
|
"title": "This page is out of date",
|
||||||
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
||||||
"reload": "Reload"
|
"reload": "Reload"
|
||||||
|
},
|
||||||
|
"desktop": {
|
||||||
|
"update_available": "gpx.studio {version} is available",
|
||||||
|
"update_restart": "Update and restart",
|
||||||
|
"update_download": "Download",
|
||||||
|
"update_installing": "Installing the update...",
|
||||||
|
"update_failed": "The update could not be installed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -598,5 +598,12 @@
|
|||||||
"title": "This page is out of date",
|
"title": "This page is out of date",
|
||||||
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
||||||
"reload": "Reload"
|
"reload": "Reload"
|
||||||
|
},
|
||||||
|
"desktop": {
|
||||||
|
"update_available": "gpx.studio {version} is available",
|
||||||
|
"update_restart": "Update and restart",
|
||||||
|
"update_download": "Download",
|
||||||
|
"update_installing": "Installing the update...",
|
||||||
|
"update_failed": "The update could not be installed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -598,5 +598,12 @@
|
|||||||
"title": "This page is out of date",
|
"title": "This page is out of date",
|
||||||
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
||||||
"reload": "Reload"
|
"reload": "Reload"
|
||||||
|
},
|
||||||
|
"desktop": {
|
||||||
|
"update_available": "gpx.studio {version} is available",
|
||||||
|
"update_restart": "Update and restart",
|
||||||
|
"update_download": "Download",
|
||||||
|
"update_installing": "Installing the update...",
|
||||||
|
"update_failed": "The update could not be installed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -598,5 +598,12 @@
|
|||||||
"title": "This page is out of date",
|
"title": "This page is out of date",
|
||||||
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
||||||
"reload": "Reload"
|
"reload": "Reload"
|
||||||
|
},
|
||||||
|
"desktop": {
|
||||||
|
"update_available": "gpx.studio {version} is available",
|
||||||
|
"update_restart": "Update and restart",
|
||||||
|
"update_download": "Download",
|
||||||
|
"update_installing": "Installing the update...",
|
||||||
|
"update_failed": "The update could not be installed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -598,5 +598,12 @@
|
|||||||
"title": "This page is out of date",
|
"title": "This page is out of date",
|
||||||
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
||||||
"reload": "Reload"
|
"reload": "Reload"
|
||||||
|
},
|
||||||
|
"desktop": {
|
||||||
|
"update_available": "gpx.studio {version} is available",
|
||||||
|
"update_restart": "Update and restart",
|
||||||
|
"update_download": "Download",
|
||||||
|
"update_installing": "Installing the update...",
|
||||||
|
"update_failed": "The update could not be installed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -598,5 +598,12 @@
|
|||||||
"title": "This page is out of date",
|
"title": "This page is out of date",
|
||||||
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
||||||
"reload": "Reload"
|
"reload": "Reload"
|
||||||
|
},
|
||||||
|
"desktop": {
|
||||||
|
"update_available": "gpx.studio {version} is available",
|
||||||
|
"update_restart": "Update and restart",
|
||||||
|
"update_download": "Download",
|
||||||
|
"update_installing": "Installing the update...",
|
||||||
|
"update_failed": "The update could not be installed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -598,5 +598,12 @@
|
|||||||
"title": "This page is out of date",
|
"title": "This page is out of date",
|
||||||
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
||||||
"reload": "Reload"
|
"reload": "Reload"
|
||||||
|
},
|
||||||
|
"desktop": {
|
||||||
|
"update_available": "gpx.studio {version} is available",
|
||||||
|
"update_restart": "Update and restart",
|
||||||
|
"update_download": "Download",
|
||||||
|
"update_installing": "Installing the update...",
|
||||||
|
"update_failed": "The update could not be installed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user