refactoring and tests

This commit is contained in:
vcoppe committed 2026-10-09 08:58:00 +02:00
1 parent 3bb553d6b5
commit da9abbae5e
45 files changed
+3554 -1560

No files matched your search

+48 -519
View File
@@ -29,7 +29,7 @@
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/enhanced-img": "^0.6.0",
"@sveltejs/kit": "^2.21.2",
"@sveltejs/vite-plugin-svelte": "^5.1.0",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.1.8",
"@types/eslint": "^9.6.1",
"@types/events": "^3.0.3",
@@ -68,7 +68,7 @@
"tw-animate-css": "^1.4.0",
"typescript": "^5.8.3",
"vaul-svelte": "^1.0.0-next.7",
"vite": "^6.3.5",
"vite": "^7.3.7",
"vite-plugin-wasm": "^3.6.0"
}
},
@@ -2313,43 +2313,42 @@
}
},
"node_modules/@sveltejs/vite-plugin-svelte": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz",
"integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==",
"version": "6.2.4",
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz",
"integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@sveltejs/vite-plugin-svelte-inspector": "^4.0.1",
"debug": "^4.4.1",
"@sveltejs/vite-plugin-svelte-inspector": "^5.0.0",
"deepmerge": "^4.3.1",
"kleur": "^4.1.5",
"magic-string": "^0.30.17",
"vitefu": "^1.0.6"
"magic-string": "^0.30.21",
"obug": "^2.1.0",
"vitefu": "^1.1.1"
},
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22"
"node": "^20.19 || ^22.12 || >=24"
},
"peerDependencies": {
"svelte": "^5.0.0",
"vite": "^6.0.0"
"vite": "^6.3.0 || ^7.0.0"
}
},
"node_modules/@sveltejs/vite-plugin-svelte-inspector": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz",
"integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==",
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.2.tgz",
"integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.3.7"
"obug": "^2.1.0"
},
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22"
"node": "^20.19 || ^22.12 || >=24"
},
"peerDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@sveltejs/vite-plugin-svelte": "^6.0.0-next.0",
"svelte": "^5.0.0",
"vite": "^6.0.0"
"vite": "^6.3.0 || ^7.0.0"
}
},
"node_modules/@swc/helpers": {
@@ -4821,6 +4820,20 @@
"dev": true,
"license": "MIT"
},
"node_modules/obug": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
"integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT",
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"dev": true,
@@ -6068,24 +6081,24 @@
}
},
"node_modules/vite": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"version": "7.3.7",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.7.tgz",
"integrity": "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
"picomatch": "^4.0.2",
"postcss": "^8.5.3",
"rollup": "^4.34.9",
"tinyglobby": "^0.2.13"
"esbuild": "^0.27.0 || ^0.28.0",
"fdir": "^6.5.0",
"picomatch": "^4.0.3",
"postcss": "^8.5.6",
"rollup": "^4.43.0",
"tinyglobby": "^0.2.15"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
"node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
@@ -6094,14 +6107,14 @@
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"@types/node": "^20.19.0 || >=22.12.0",
"jiti": ">=1.21.0",
"less": "*",
"less": "^4.0.0",
"lightningcss": "^1.21.0",
"sass": "*",
"sass-embedded": "*",
"stylus": "*",
"sugarss": "*",
"sass": "^1.70.0",
"sass-embedded": "^1.70.0",
"stylus": ">=0.54.8",
"sugarss": "^5.0.0",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
@@ -6167,490 +6180,6 @@
"vite": "^2 || ^3 || ^4 || ^5 || ^6 || ^7 || ^8"
}
},
"node_modules/vite/node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/android-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/android-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/android-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/darwin-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/darwin-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/freebsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/linux-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/linux-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/linux-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/linux-loong64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/linux-mips64el": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/linux-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/linux-riscv64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/linux-s390x": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/linux-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/netbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/openbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/sunos-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/win32-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/win32-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/@esbuild/win32-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/vite/node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.12",
"@esbuild/android-arm": "0.25.12",
"@esbuild/android-arm64": "0.25.12",
"@esbuild/android-x64": "0.25.12",
"@esbuild/darwin-arm64": "0.25.12",
"@esbuild/darwin-x64": "0.25.12",
"@esbuild/freebsd-arm64": "0.25.12",
"@esbuild/freebsd-x64": "0.25.12",
"@esbuild/linux-arm": "0.25.12",
"@esbuild/linux-arm64": "0.25.12",
"@esbuild/linux-ia32": "0.25.12",
"@esbuild/linux-loong64": "0.25.12",
"@esbuild/linux-mips64el": "0.25.12",
"@esbuild/linux-ppc64": "0.25.12",
"@esbuild/linux-riscv64": "0.25.12",
"@esbuild/linux-s390x": "0.25.12",
"@esbuild/linux-x64": "0.25.12",
"@esbuild/netbsd-arm64": "0.25.12",
"@esbuild/netbsd-x64": "0.25.12",
"@esbuild/openbsd-arm64": "0.25.12",
"@esbuild/openbsd-x64": "0.25.12",
"@esbuild/openharmony-arm64": "0.25.12",
"@esbuild/sunos-x64": "0.25.12",
"@esbuild/win32-arm64": "0.25.12",
"@esbuild/win32-ia32": "0.25.12",
"@esbuild/win32-x64": "0.25.12"
}
},
"node_modules/vitefu": {
"version": "1.1.3",
"dev": true,
+3 -3
View File
@@ -9,7 +9,7 @@
"postbuild": "npx tsx src/lib/scripts/sitemap.ts",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test": "node --import tsx --test \"src/**/*.test.ts\"",
"test": "node --import tsx --import ./src/test/register.mjs --experimental-wasm-modules --disable-warning=ExperimentalWarning --test \"src/**/*.test.ts\"",
"generate:example": "node --import tsx src/lib/scripts/example-statistics.ts",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . --config ../.prettierrc --ignore-path ../.prettierignore --ignore-path ./.gitignore && eslint .",
@@ -22,7 +22,7 @@
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/enhanced-img": "^0.6.0",
"@sveltejs/kit": "^2.21.2",
"@sveltejs/vite-plugin-svelte": "^5.1.0",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.1.8",
"@types/eslint": "^9.6.1",
"@types/events": "^3.0.3",
@@ -61,7 +61,7 @@
"tw-animate-css": "^1.4.0",
"typescript": "^5.8.3",
"vaul-svelte": "^1.0.0-next.7",
"vite": "^6.3.5",
"vite": "^7.3.7",
"vite-plugin-wasm": "^3.6.0"
},
"type": "module",
+51
View File
@@ -0,0 +1,51 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
getHighwayColor,
getSlopeColor,
getSurfaceColor,
highwayColors,
surfaceColors,
} from './colors';
describe('colors of the OSM data', () => {
it('has the color of a surface, or the one of the missing ones', () => {
assert.equal(getSurfaceColor('asphalt'), surfaceColors.asphalt);
assert.equal(getSurfaceColor('grass'), '#61b55c');
assert.equal(getSurfaceColor('something new'), surfaceColors.missing);
assert.equal(getSurfaceColor(''), surfaceColors.missing);
});
it('has the color of a highway, or the one of the missing ones', () => {
assert.equal(getHighwayColor('motorway', undefined, undefined), highwayColors.motorway);
assert.equal(getHighwayColor('path', undefined, undefined), highwayColors.path);
assert.equal(getHighwayColor('something new', undefined, undefined), highwayColors.missing);
});
});
describe('slope colors', () => {
const hue = (color: string) => Number(/hsl\(([-\d.e]+),/.exec(color)![1]);
const lightness = (color: string) => Number(/,([\d.]+)%\)$/.exec(color)![1]);
it('is yellow on the flat, redder uphill and greener downhill, symmetrically', () => {
const flat = getSlopeColor(0);
assert.equal(hue(flat), 60);
assert.equal(lightness(flat), 90);
// steeper is further from the flat color, in both directions
assert.ok(hue(getSlopeColor(10)) < 60);
assert.ok(hue(getSlopeColor(-10)) > 60);
assert.ok(lightness(getSlopeColor(10)) < 90);
assert.ok(hue(getSlopeColor(20)) < hue(getSlopeColor(10)));
const up = getSlopeColor(7);
const down = getSlopeColor(-7);
assert.ok(Math.abs(hue(up) + hue(down) - 120) < 1e-9);
assert.ok(Math.abs(lightness(up) - lightness(down)) < 1e-9);
});
it('does not go further than 20 %', () => {
assert.equal(getSlopeColor(20), getSlopeColor(35));
assert.equal(getSlopeColor(1000), getSlopeColor(20));
assert.equal(getSlopeColor(-20), getSlopeColor(-55));
assert.notEqual(getSlopeColor(19), getSlopeColor(20));
});
});
+103
View File
@@ -0,0 +1,103 @@
// The layers are plain data, which the settings and the menus rely on being consistent.
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
basemaps,
basemapTree,
defaultBasemap,
defaultBasemapTree,
defaultOpacities,
defaultOverlays,
defaultOverlayTree,
defaultOverpassQueries,
defaultOverpassTree,
defaultTerrainSource,
overlays,
overlayTree,
overpassQueryData,
overpassTree,
terrainSources,
type LayerTreeType,
} from './layers';
import { getLayers } from '$lib/components/map/layer-control/utils';
const leaves = (tree: LayerTreeType) => Object.keys(getLayers(tree));
describe('layers', () => {
it('has a basemap and an overlay for every leaf of their trees', () => {
for (const id of leaves(basemapTree)) assert.ok(id in basemaps, `basemap ${id}`);
for (const id of leaves(overlayTree)) assert.ok(id in overlays, `overlay ${id}`);
for (const id of leaves(overpassTree)) assert.ok(id in overpassQueryData, `query ${id}`);
});
it('offers every layer and every query in its trees', () => {
const missingFrom = (ids: string[], tree: LayerTreeType) => {
const offered = new Set(leaves(tree));
return ids.filter((id) => !offered.has(id));
};
const basemapIds = Object.keys(basemaps);
assert.deepEqual(missingFrom(basemapIds, basemapTree), [], 'basemapTree');
assert.deepEqual(missingFrom(basemapIds, defaultBasemapTree), [], 'defaultBasemapTree');
const overlayIds = Object.keys(overlays);
assert.deepEqual(missingFrom(overlayIds, overlayTree), [], 'overlayTree');
assert.deepEqual(missingFrom(overlayIds, defaultOverlayTree), [], 'defaultOverlayTree');
const queryIds = Object.keys(overpassQueryData);
assert.deepEqual(missingFrom(queryIds, overpassTree), [], 'overpassTree');
assert.deepEqual(missingFrom(queryIds, defaultOverpassTree), [], 'defaultOverpassTree');
assert.deepEqual(
missingFrom(queryIds, defaultOverpassQueries),
[],
'defaultOverpassQueries'
);
});
it('only offers by default what exists', () => {
assert.ok(defaultBasemap in basemaps);
for (const id of leaves(defaultBasemapTree)) assert.ok(id in basemaps, `basemap ${id}`);
for (const id of leaves(defaultOverlayTree)) assert.ok(id in overlays, `overlay ${id}`);
for (const id of leaves(defaultOverpassTree)) assert.ok(id in overpassQueryData, `${id}`);
for (const id of leaves(defaultOverlays)) assert.ok(id in overlays, `overlay ${id}`);
for (const id of leaves(defaultOverpassQueries)) {
assert.ok(id in overpassQueryData, `query ${id}`);
}
assert.ok(defaultTerrainSource in terrainSources);
});
it('has the default selections inside the default trees', () => {
const inTree = (selection: LayerTreeType, tree: LayerTreeType) => {
const offered = new Set(leaves(tree));
for (const id of leaves(selection)) assert.ok(offered.has(id), `${id}`);
};
inTree(defaultOverlays, defaultOverlayTree);
inTree(defaultOverpassQueries, defaultOverpassTree);
});
it('has the default tree inside the whole tree', () => {
const all = new Set(leaves(basemapTree));
for (const id of leaves(defaultBasemapTree)) assert.ok(all.has(id), `${id}`);
});
it('has no id used by two leaves of a tree', () => {
for (const tree of [basemapTree, overlayTree, overpassTree, defaultBasemapTree]) {
const ids: string[] = [];
const walk = (node: LayerTreeType) =>
Object.entries(node).forEach(([id, value]) =>
typeof value === 'boolean' ? ids.push(id) : walk(value)
);
walk(tree);
assert.equal(new Set(ids).size, ids.length);
}
});
it('has opacities between 0 and 1, for overlays', () => {
for (const [id, opacity] of Object.entries(defaultOpacities)) {
assert.ok(opacity >= 0 && opacity <= 1, id);
}
});
it('has the queries it needs', () => {
for (const [id, data] of Object.entries(overpassQueryData)) {
assert.ok(Object.keys(data).length > 0, id);
}
});
});
+1 -82
View File
@@ -16,27 +16,6 @@ import {
} from '@lucide/svelte';
import type { Component } from 'svelte';
export const guides: Record<string, string[]> = {
'getting-started': [],
menu: ['file', 'edit', 'view', 'settings'],
'files-and-stats': [],
toolbar: [
'routing',
'poi',
'scissors',
'time',
'merge',
'extract',
'elevation',
'minify',
'clean',
],
'map-controls': [],
gpx: [],
integration: [],
faq: [],
};
export const guideIcons: Record<string, string | Component<IconProps>> = {
'getting-started': '🚀',
menu: '📂 ⚙️',
@@ -61,64 +40,4 @@ export const guideIcons: Record<string, string | Component<IconProps>> = {
faq: '🔮',
};
export function getPreviousGuide(currentGuide: string): string | undefined {
let subguides = currentGuide.split('/');
if (subguides.length === 1) {
let keys = Object.keys(guides);
let index = keys.indexOf(currentGuide);
if (index === 0) {
return undefined;
}
let previousGuide = keys[index - 1];
if (previousGuide === undefined) {
return undefined;
} else if (guides[previousGuide].length === 0) {
return previousGuide;
} else {
return `${previousGuide}/${guides[previousGuide][guides[previousGuide].length - 1]}`;
}
} else {
if (guides.hasOwnProperty(subguides[0])) {
let subguideIndex = guides[subguides[0]].indexOf(subguides[1]);
if (subguideIndex > 0) {
return `${subguides[0]}/${guides[subguides[0]][subguideIndex - 1]}`;
} else {
return subguides[0];
}
} else {
return undefined;
}
}
}
export function getNextGuide(currentGuide: string): string | undefined {
let subguides = currentGuide.split('/');
if (subguides.length === 1) {
if (guides.hasOwnProperty(currentGuide)) {
if (guides[currentGuide].length === 0) {
let keys = Object.keys(guides);
let index = keys.indexOf(currentGuide);
return keys[index + 1];
} else {
return `${currentGuide}/${guides[currentGuide][0]}`;
}
} else {
return undefined;
}
} else {
if (guides.hasOwnProperty(subguides[0])) {
let subguideIndex = guides[subguides[0]].indexOf(subguides[1]);
if (subguideIndex < guides[subguides[0]].length - 1) {
return `${subguides[0]}/${guides[subguides[0]][subguideIndex + 1]}`;
} else {
let keys = Object.keys(guides);
let index = keys.indexOf(subguides[0]);
return keys[index + 1];
}
} else {
return undefined;
}
}
}
export { guides, getNextGuide, getPreviousGuide } from './guides';
@@ -0,0 +1,81 @@
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import { describe, it } from 'node:test';
import { languages } from '$lib/languages';
import { getNextGuide, getPreviousGuide, guides } from './guides';
/** Every page of the help in reading order, as the links at the bottom of the pages follow them. */
const pages = Object.entries(guides).flatMap(([guide, subguides]) => [
guide,
...subguides.map((subguide) => `${guide}/${subguide}`),
]);
describe('guides', () => {
it('go from one page to the next, subguides included', () => {
assert.equal(getNextGuide('getting-started'), 'menu');
assert.equal(getNextGuide('menu'), 'menu/file');
assert.equal(getNextGuide('menu/file'), 'menu/edit');
assert.equal(getNextGuide('menu/settings'), 'files-and-stats');
assert.equal(getNextGuide('toolbar/clean'), 'map-controls');
assert.equal(getNextGuide('integration'), 'faq');
});
it('go from one page to the previous one, subguides included', () => {
assert.equal(getPreviousGuide('menu'), 'getting-started');
assert.equal(getPreviousGuide('menu/file'), 'menu');
assert.equal(getPreviousGuide('menu/edit'), 'menu/file');
assert.equal(getPreviousGuide('files-and-stats'), 'menu/settings');
assert.equal(getPreviousGuide('map-controls'), 'toolbar/clean');
assert.equal(getPreviousGuide('faq'), 'integration');
});
it('stop at the ends', () => {
assert.equal(getPreviousGuide('getting-started'), undefined);
assert.equal(getNextGuide('faq'), undefined);
});
it('know nothing of pages that do not exist', () => {
assert.equal(getNextGuide('nope'), undefined);
assert.equal(getNextGuide('nope/sub'), undefined);
assert.equal(getPreviousGuide('nope'), undefined);
assert.equal(getPreviousGuide('nope/sub'), undefined);
});
it('walk through every page in both directions', () => {
const forward = [pages[0]];
for (let next = getNextGuide(pages[0]); next; next = getNextGuide(next)) {
forward.push(next);
}
assert.deepEqual(forward, pages);
const backward = [pages.at(-1)!];
for (
let previous = getPreviousGuide(pages.at(-1)!);
previous;
previous = getPreviousGuide(previous)
) {
backward.push(previous);
}
assert.deepEqual(backward.reverse(), pages);
});
it('are written in every language of the site', () => {
const root = new URL('../../docs/', import.meta.url);
const missing: string[] = [];
for (const language of Object.keys(languages)) {
for (const page of pages) {
if (!existsSync(new URL(`${language}/${page}.mdx`, root))) {
missing.push(`${language}/${page}`);
}
}
}
assert.deepEqual(missing, []);
});
it('have a translation of the interface for every language of the site', () => {
const root = new URL('../../../locales/', import.meta.url);
for (const language of Object.keys(languages)) {
assert.ok(existsSync(new URL(`${language}.json`, root)), language);
}
});
});
+84
View File
@@ -0,0 +1,84 @@
// The guides of the help section, and how they follow one another. The icons are in `docs.ts`.
export const guides: Record<string, string[]> = {
'getting-started': [],
menu: ['file', 'edit', 'view', 'settings'],
'files-and-stats': [],
toolbar: [
'routing',
'poi',
'scissors',
'time',
'merge',
'extract',
'elevation',
'minify',
'clean',
],
'map-controls': [],
gpx: [],
integration: [],
faq: [],
};
export function getPreviousGuide(currentGuide: string): string | undefined {
const subguides = currentGuide.split('/');
if (subguides.length === 1) {
const keys = Object.keys(guides);
const index = keys.indexOf(currentGuide);
if (index === 0) {
return undefined;
}
const previousGuide = keys[index - 1];
if (previousGuide === undefined) {
return undefined;
} else if (guides[previousGuide].length === 0) {
return previousGuide;
} else {
return `${previousGuide}/${guides[previousGuide][guides[previousGuide].length - 1]}`;
}
} else {
if (guides.hasOwnProperty(subguides[0])) {
const subguideIndex = guides[subguides[0]].indexOf(subguides[1]);
if (subguideIndex > 0) {
return `${subguides[0]}/${guides[subguides[0]][subguideIndex - 1]}`;
} else {
return subguides[0];
}
} else {
return undefined;
}
}
}
export function getNextGuide(currentGuide: string): string | undefined {
const subguides = currentGuide.split('/');
if (subguides.length === 1) {
if (guides.hasOwnProperty(currentGuide)) {
if (guides[currentGuide].length === 0) {
const keys = Object.keys(guides);
const index = keys.indexOf(currentGuide);
return keys[index + 1];
} else {
return `${currentGuide}/${guides[currentGuide][0]}`;
}
} else {
return undefined;
}
} else {
if (guides.hasOwnProperty(subguides[0])) {
const subguideIndex = guides[subguides[0]].indexOf(subguides[1]);
if (subguideIndex < guides[subguides[0]].length - 1) {
return `${subguides[0]}/${guides[subguides[0]][subguideIndex + 1]}`;
} else {
const keys = Object.keys(guides);
const index = keys.indexOf(subguides[0]);
return keys[index + 1];
}
} else {
return undefined;
}
}
}
@@ -0,0 +1,148 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
allowedEmbeddingBasemaps,
convertOldEmbeddingOptions,
defaultEmbeddingOptions,
getCleanedEmbeddingOptions,
getFilesFromEmbeddingOptions,
getMergedEmbeddingOptions,
getURLForGoogleDriveFile,
} from './embedding';
describe('embedding options', () => {
it('are the defaults when nothing is given', () => {
assert.deepEqual(getMergedEmbeddingOptions({}), defaultEmbeddingOptions);
// and a copy of them
const merged = getMergedEmbeddingOptions({});
merged.elevation.show = false;
assert.equal(defaultEmbeddingOptions.elevation.show, true);
});
it('override the defaults, also inside the groups of options', () => {
const merged = getMergedEmbeddingOptions({
basemap: 'osm',
distanceUnits: 'imperial',
elevation: { fill: 'slope', hr: true },
files: ['https://example.com/a.gpx'],
});
assert.equal(merged.basemap, 'osm');
assert.equal(merged.distanceUnits, 'imperial');
assert.equal(merged.elevation.fill, 'slope');
assert.equal(merged.elevation.hr, true);
// the options of a group that are not given stay as they are
assert.equal(merged.elevation.height, 170);
assert.equal(merged.elevation.show, true);
assert.deepEqual(merged.files, ['https://example.com/a.gpx']);
assert.equal(merged.velocityUnits, 'speed');
});
it('take a list or a null as a value, not as a group', () => {
const merged = getMergedEmbeddingOptions({ ids: ['a', 'b'], key: null });
assert.deepEqual(merged.ids, ['a', 'b']);
assert.equal(merged.key, null);
});
it('are cleaned of what is a default', () => {
const options = getMergedEmbeddingOptions({ basemap: 'osm', elevation: { hr: true } });
assert.deepEqual(getCleanedEmbeddingOptions(options), {
basemap: 'osm',
elevation: { hr: true },
});
assert.deepEqual(getCleanedEmbeddingOptions(defaultEmbeddingOptions), {});
// merging what was cleaned gives the options back
assert.deepEqual(getMergedEmbeddingOptions(getCleanedEmbeddingOptions(options)), options);
});
it('are cleaned of the key of the site, and keep the key of the user', () => {
assert.deepEqual(
getCleanedEmbeddingOptions({ ...defaultEmbeddingOptions, key: 'test-key' }),
{}
);
assert.deepEqual(getCleanedEmbeddingOptions({ ...defaultEmbeddingOptions, key: 'mine' }), {
key: 'mine',
});
});
it('are not changed by cleaning them', () => {
const options = getMergedEmbeddingOptions({ elevation: { hr: true } });
const before = JSON.stringify(options);
getCleanedEmbeddingOptions(options);
assert.equal(JSON.stringify(options), before);
});
});
describe('embedded files', () => {
it('are the urls, then the files of Google Drive', () => {
const options = getMergedEmbeddingOptions({
files: ['https://example.com/a.gpx'],
ids: ['abc', 'def'],
});
assert.deepEqual(getFilesFromEmbeddingOptions(options), [
'https://example.com/a.gpx',
getURLForGoogleDriveFile('abc'),
getURLForGoogleDriveFile('def'),
]);
assert.ok(getURLForGoogleDriveFile('abc').includes('/files/abc?alt=media'));
assert.deepEqual(getFilesFromEmbeddingOptions(getMergedEmbeddingOptions({})), []);
});
it('can use the basemaps of the site except a few', () => {
assert.ok(allowedEmbeddingBasemaps.includes('libertyTopo'));
assert.ok(allowedEmbeddingBasemaps.includes('osm'));
assert.ok(!allowedEmbeddingBasemaps.includes('ordnanceSurvey'));
assert.ok(allowedEmbeddingBasemaps.includes(defaultEmbeddingOptions.basemap));
});
});
describe('options of the old embedding', () => {
const convert = (query: string) => convertOldEmbeddingOptions(new URLSearchParams(query));
it('are converted to the new ones', () => {
assert.deepEqual(convert(''), { key: 'test-key', files: [], ids: [] });
assert.deepEqual(
convert(
'source=otm&imperial&running&distance&direction&slope&state=' +
encodeURIComponent(JSON.stringify({ ids: ['a'], urls: ['https://x/y.gpx'] }))
),
{
key: 'test-key',
files: ['https://x/y.gpx'],
ids: ['a'],
basemap: 'openTopoMap',
distanceUnits: 'imperial',
velocityUnits: 'pace',
distanceMarkers: true,
directionMarkers: true,
elevation: { fill: 'slope' },
}
);
});
it('convert the names of the sources', () => {
assert.equal(convert('source=satellite').basemap, 'libertySatellite');
assert.equal(convert('source=otm').basemap, 'openTopoMap');
assert.equal(convert('source=ohm').basemap, 'openHikingMap');
assert.equal(convert('source=unknown').basemap, undefined);
});
it('ignore a state that is not valid', () => {
const empty = { key: 'test-key', files: [], ids: [] };
assert.deepEqual(convert('state=%7Bbroken'), empty);
assert.deepEqual(convert('state=null'), empty);
assert.deepEqual(convert('state=42'), empty);
assert.deepEqual(convert(`state=${encodeURIComponent('{"ids":"abc","urls":3}')}`), empty);
assert.deepEqual(convert(`state=${encodeURIComponent('{"ids":["a"]}')}&imperial`), {
...empty,
ids: ['a'],
distanceUnits: 'imperial',
});
});
it('can be merged with the defaults', () => {
const merged = getMergedEmbeddingOptions(convert('slope&source=satellite'));
assert.equal(merged.elevation.fill, 'slope');
assert.equal(merged.elevation.height, 170);
assert.equal(merged.basemap, 'libertySatellite');
});
});
@@ -115,11 +115,15 @@ export function convertOldEmbeddingOptions(options: URLSearchParams): any {
ids: [],
};
if (options.has('state')) {
let state = JSON.parse(options.get('state')!);
if (state.ids) {
// the state comes from the url: it may be anything
let state: any = {};
try {
state = JSON.parse(options.get('state')!) ?? {};
} catch {}
if (Array.isArray(state.ids)) {
newOptions.ids.push(...state.ids);
}
if (state.urls) {
if (Array.isArray(state.urls)) {
newOptions.files.push(...state.urls);
}
}
@@ -185,9 +185,7 @@
{i18n._('quantities.power')}
</Label>
</div>
<div
class="flex flex-row items-center gap-1.5 {hide.osm ? 'hidden' : ''}"
>
<div class="flex flex-row items-center gap-1.5 {hide.osm ? 'hidden' : ''}">
<Checkbox id="export-osm" bind:checked={exportOptions.osm} />
<Label for="export-osm" class="flex flex-row items-center gap-1">
<Earth size="16" />
@@ -0,0 +1,97 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { sanitizeFileName, uniqueFileNames } from './file-names';
describe('sanitizeFileName', () => {
it('keeps the names that are fine', () => {
for (const name of [
'Morning ride',
'route-2024_05',
'Café du coin',
'日本の道',
'a.b.c',
'🚴 tour',
]) {
assert.equal(sanitizeFileName(name), name);
}
});
it('replaces what is not allowed in a name', () => {
assert.equal(sanitizeFileName('a/b'), 'a_b');
assert.equal(sanitizeFileName('a\\b'), 'a_b');
assert.equal(sanitizeFileName('../../etc/passwd'), '_.._etc_passwd');
assert.equal(
sanitizeFileName('what? <this> *is* "it": a|b'),
'what_ _this_ _is_ _it__ a_b'
);
assert.equal(sanitizeFileName('tab\there\nnew\u0000line\u007f'), 'tab_here_new_line_');
});
it('has no dots or spaces around, which systems drop or hide', () => {
assert.equal(sanitizeFileName(' name '), 'name');
assert.equal(sanitizeFileName('.hidden'), 'hidden');
assert.equal(sanitizeFileName('name...'), 'name');
assert.equal(sanitizeFileName('. . name . .'), 'name');
});
it('is never empty', () => {
assert.equal(sanitizeFileName(''), 'file');
assert.equal(sanitizeFileName(' '), 'file');
assert.equal(sanitizeFileName('...'), 'file');
});
it('does not use the names Windows keeps for devices', () => {
assert.equal(sanitizeFileName('con'), '_con');
assert.equal(sanitizeFileName('NUL'), '_NUL');
assert.equal(sanitizeFileName('com1'), '_com1');
assert.equal(sanitizeFileName('LPT9'), '_LPT9');
assert.equal(sanitizeFileName('aux.backup'), '_aux.backup');
assert.equal(sanitizeFileName('console'), 'console');
assert.equal(sanitizeFileName('com10'), 'com10');
});
it('is not too long, whatever the characters', () => {
assert.equal(sanitizeFileName('a'.repeat(500)).length, 100);
const emoji = sanitizeFileName('🚴'.repeat(500));
assert.equal([...emoji].length, 100);
// and does not end with what was left of a cut
assert.equal(sanitizeFileName('a'.repeat(99) + '. b'), 'a'.repeat(99));
});
it('is the same when sanitized again', () => {
for (const name of ['a/b', ' .x ', 'con', '', '🚴'.repeat(200), 'what?']) {
const once = sanitizeFileName(name);
assert.equal(sanitizeFileName(once), once);
}
});
});
describe('uniqueFileNames', () => {
it('keeps different names as they are', () => {
assert.deepEqual(uniqueFileNames(['a', 'b', 'c']), ['a', 'b', 'c']);
assert.deepEqual(uniqueFileNames([]), []);
});
it('numbers the names that are taken', () => {
assert.deepEqual(uniqueFileNames(['ride', 'ride', 'ride']), ['ride', 'ride-1', 'ride-2']);
// a name that looks like a number is taken too
assert.deepEqual(uniqueFileNames(['ride-1', 'ride', 'ride']), ['ride-1', 'ride', 'ride-2']);
});
it('does not tell upper and lower case apart', () => {
assert.deepEqual(uniqueFileNames(['Ride', 'ride', 'RIDE']), ['Ride', 'ride-1', 'RIDE-2']);
});
it('compares the names once they are clean', () => {
assert.deepEqual(uniqueFileNames(['a/b', 'a_b', 'a?b']), ['a_b', 'a_b-1', 'a_b-2']);
assert.deepEqual(uniqueFileNames(['', '', '...']), ['file', 'file-1', 'file-2']);
});
it('keeps the numbered names short enough', () => {
const long = 'x'.repeat(300);
const names = uniqueFileNames([long, long, long]);
assert.equal(new Set(names).size, 3);
assert.ok(names.every((name) => name.length <= 100));
assert.ok(names[1].endsWith('-1') && names[2].endsWith('-2'));
});
});
@@ -0,0 +1,47 @@
// The names of the files that are exported come from the files of the user, which can have any
// text as a name: they have to be made names that every system accepts.
/** Characters that no system accepts in a file name, or that mean something in a path. */
// eslint-disable-next-line no-control-regex
const FORBIDDEN = /[<>:"/\\|?*\u0000-\u001f\u007f]/g;
/** Names that Windows keeps for devices, with or without an extension. */
const RESERVED = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])$/i;
/** Longest name, without the extension, in characters. */
const MAX_LENGTH = 100;
/** A name for a file without its extension, that is allowed on every system. */
export function sanitizeFileName(name: string): string {
let clean = name
.replace(FORBIDDEN, '_')
.normalize('NFC')
// no dots or spaces around the name: Windows drops them, a leading dot hides the file
.replace(/^[\s.]+|[\s.]+$/g, '');
clean = [...clean]
.slice(0, MAX_LENGTH)
.join('')
.replace(/[\s.]+$/, '');
if (clean === '') {
return 'file';
}
return RESERVED.test(clean.split('.')[0]) ? `_${clean}` : clean;
}
/**
* The names of files that go in the same folder (the same zip): clean, and different, even where
* the file system does not tell upper and lower case apart. A name that is taken gets `-1`, `-2`...
*/
export function uniqueFileNames(names: string[]): string[] {
const taken = new Set<string>();
return names.map((name) => {
const clean = sanitizeFileName(name);
let unique = clean;
for (let i = 1; taken.has(unique.toLowerCase()); i++) {
// the suffix is part of the length
unique = `${[...clean].slice(0, MAX_LENGTH - `-${i}`.length).join('')}-${i}`;
}
taken.add(unique.toLowerCase());
return unique;
});
}
@@ -2,6 +2,7 @@ import { engine, type ExportOptions } from '$lib/engine';
import FileSaver from 'file-saver';
import JSZip from 'jszip';
import { get } from 'svelte/store';
import { sanitizeFileName, uniqueFileNames } from './file-names';
export enum ExportState {
NONE,
@@ -38,16 +39,11 @@ async function exportFiles(fileIds: string[], options: ExportOptions) {
if (files.length === 1) {
const blob = new Blob([files[0].gpx], { type: 'application/gpx+xml' });
FileSaver.saveAs(blob, `${files[0].name}.gpx`);
FileSaver.saveAs(blob, `${sanitizeFileName(files[0].name)}.gpx`);
} else if (files.length > 1) {
const zip = new JSZip();
for (const { name, gpx } of files) {
let filename = name;
for (let i = 1; zip.files[filename + '.gpx']; i++) {
filename = name + `-${i}`;
}
zip.file(filename + '.gpx', gpx);
}
const names = uniqueFileNames(files.map((file) => file.name));
files.forEach(({ gpx }, i) => zip.file(`${names[i]}.gpx`, gpx));
const blob = await zip.generateAsync({ type: 'blob' });
FileSaver.saveAs(blob, 'gpx-files.zip');
}
@@ -0,0 +1,149 @@
// The API that browser extensions use to add overlays to the map (`window.gpxstudio`).
import assert from 'node:assert/strict';
import { afterEach, beforeEach, describe, it } from 'node:test';
import { get } from 'svelte/store';
import { overlays, overlayTree, type LayerTreeType } from '$lib/assets/layers';
import { engine } from '$lib/engine';
import { settings } from '$lib/logic/settings';
import { ExtensionAPI, type CustomOverlay } from './extension-api';
import { isSelected } from './utils';
const original = { set: engine.setSetting, delete: engine.deleteSetting };
const overlayTreeBefore = JSON.stringify(overlayTree);
const overlayIdsBefore = Object.keys(overlays).sort();
const overlay = (id: string, extensionName = 'My extension'): CustomOverlay => ({
extensionName,
id,
name: `Overlay ${id}`,
tileUrls: [`https://tiles.example.com/${id}/{z}/{x}/{y}.png`],
});
let api: ExtensionAPI;
beforeEach(() => {
engine.setSetting = () => {};
engine.deleteSetting = () => {};
settings.connect({});
settings.initialize();
api = new ExtensionAPI();
});
afterEach(() => {
// the layers are shared by the whole app: leave them as they were
api.filterOverlays([]);
for (const extension of ['My extension', 'Other extension']) {
delete (overlayTree.overlays as LayerTreeType)[extension];
}
engine.setSetting = original.set;
engine.deleteSetting = original.delete;
assert.equal(JSON.stringify(overlayTree), overlayTreeBefore);
assert.deepEqual(Object.keys(overlays).sort(), overlayIdsBefore);
});
describe('ExtensionAPI', () => {
it('adds an overlay as a layer, in the tree of its extension', () => {
api.addOrUpdateOverlay(overlay('one'));
const layer = overlays['extension-one'];
assert.ok(layer && typeof layer === 'object');
const source = (layer as any).sources['extension-one'];
assert.equal(source.type, 'raster');
assert.deepEqual(source.tiles, ['https://tiles.example.com/one/{z}/{x}/{y}.png']);
assert.equal(source.tileSize, 256);
assert.equal(source.maxzoom, 22);
assert.ok((overlayTree.overlays as LayerTreeType)['My extension']);
assert.equal(isSelected(overlayTree, 'extension-one'), true);
assert.equal(isSelected(get(settings.selectedOverlayTree), 'extension-one'), true);
// not shown until the user chooses to
assert.equal(isSelected(get(settings.currentOverlays)!, 'extension-one'), false);
assert.ok(get(api.isLayerFromExtension)('extension-one'));
assert.equal(get(api.getLayerName)('extension-one'), 'Overlay one');
assert.equal(get(api.getLayerName)('other'), '');
});
it('uses big tiles when the urls say so, and the max zoom it is given', () => {
api.addOrUpdateOverlay({
...overlay('big'),
tileUrls: ['https://tiles.example.com/512/{z}/{x}/{y}.png'],
maxZoom: 15,
});
const source = (overlays['extension-big'] as any).sources['extension-big'];
assert.equal(source.tileSize, 512);
assert.equal(source.maxzoom, 15);
});
it('refuses an overlay that is not complete', () => {
for (const bad of [
{ ...overlay('a'), extensionName: '' },
{ ...overlay('a'), id: '' },
{ ...overlay('a'), name: '' },
{ ...overlay('a'), tileUrls: [] },
{ ...overlay('a'), tileUrls: undefined as unknown as string[] },
]) {
assert.throws(() => api.addOrUpdateOverlay(bad), /must have/);
}
assert.ok(!('extension-a' in overlays));
});
it('updates an overlay that is added again, keeping it shown', () => {
api.addOrUpdateOverlay(overlay('one'));
settings.currentOverlays.update((current) => {
(current.overlays as LayerTreeType)['My extension'] = { 'extension-one': true };
return current;
});
api.addOrUpdateOverlay({
...overlay('one'),
name: 'Renamed',
tileUrls: ['https://other/{z}'],
});
assert.equal(get(api.getLayerName)('extension-one'), 'Renamed');
assert.deepEqual((overlays['extension-one'] as any).sources['extension-one'].tiles, [
'https://other/{z}',
]);
assert.equal(isSelected(get(settings.currentOverlays)!, 'extension-one'), true);
});
it('removes the overlays that are not in the list anymore, everywhere', () => {
api.addOrUpdateOverlay(overlay('one'));
api.addOrUpdateOverlay(overlay('two', 'Other extension'));
api.filterOverlays(['two']);
assert.ok(!('extension-one' in overlays));
assert.ok('extension-two' in overlays);
assert.equal(get(api.isLayerFromExtension)('extension-one'), false);
assert.equal(get(api.isLayerFromExtension)('extension-two'), true);
for (const tree of [
overlayTree,
get(settings.selectedOverlayTree),
get(settings.currentOverlays)!,
]) {
assert.equal(isSelected(tree, 'extension-one'), false);
}
assert.equal(isSelected(overlayTree, 'extension-two'), true);
api.filterOverlays([]);
assert.ok(!('extension-two' in overlays));
});
it('puts the overlays in the order it is given', () => {
api.addOrUpdateOverlay(overlay('one'));
api.addOrUpdateOverlay(overlay('two'));
api.addOrUpdateOverlay(overlay('three'));
api.updateOverlaysOrder(['three', 'one', 'two']);
const extension = (get(settings.selectedOverlayTree).overlays as LayerTreeType)[
'My extension'
];
assert.deepEqual(Object.keys(extension as LayerTreeType), [
'extension-three',
'extension-one',
'extension-two',
]);
// ids it does not know are ignored
api.updateOverlaysOrder(['unknown', 'two']);
assert.equal(Object.keys(extension as LayerTreeType).at(-1), 'extension-two');
});
});
@@ -0,0 +1,74 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import type { LayerTreeType } from '$lib/assets/layers';
import { anySelectedLayer, getLayers, isSelected, remove, removeAll, toggle } from './utils';
// roads: bike (selected), foot
// satellite: esri, ign: fr (selected)
// osm
const tree = (): LayerTreeType => ({
roads: { bike: true, foot: false },
satellite: { esri: false, ign: { fr: true } },
osm: false,
});
describe('layer trees', () => {
it('tells if a layer is selected', () => {
assert.equal(isSelected(tree(), 'bike'), true);
assert.equal(isSelected(tree(), 'foot'), false);
assert.equal(isSelected(tree(), 'fr'), true);
assert.equal(isSelected(tree(), 'osm'), false);
assert.equal(isSelected(tree(), 'unknown'), false);
});
it('tells if anything is selected in a branch', () => {
const t = tree();
assert.equal(anySelectedLayer(t), true);
assert.equal(anySelectedLayer(t.roads as LayerTreeType), true);
assert.equal(anySelectedLayer(t.satellite as LayerTreeType), true);
assert.equal(anySelectedLayer({ a: false, b: { c: false } }), false);
assert.equal(anySelectedLayer({}), false);
});
it('flattens the layers of the leaves', () => {
assert.deepEqual(getLayers(tree()), {
bike: true,
foot: false,
esri: false,
fr: true,
osm: false,
});
assert.deepEqual(getLayers({}), {});
});
it('toggles a layer wherever it is', () => {
const t = toggle(tree(), 'fr');
assert.equal(isSelected(t, 'fr'), false);
assert.equal(isSelected(toggle(t, 'osm'), 'osm'), true);
assert.equal(isSelected(toggle(t, 'osm'), 'bike'), true);
// the tree is edited, and returned
const own = tree();
assert.equal(toggle(own, 'foot'), own);
assert.equal((own.roads as LayerTreeType).foot, true);
// an id that is not there changes nothing
assert.deepEqual(toggle(tree(), 'unknown'), tree());
});
it('removes layers and branches', () => {
assert.deepEqual(remove(tree(), 'bike'), {
roads: { foot: false },
satellite: { esri: false, ign: { fr: true } },
osm: false,
});
assert.deepEqual(remove(tree(), 'ign'), {
roads: { bike: true, foot: false },
satellite: { esri: false },
osm: false,
});
assert.deepEqual(removeAll(tree(), ['bike', 'fr', 'osm']), {
roads: { foot: false },
satellite: { esri: false, ign: {} },
});
assert.deepEqual(removeAll(tree(), []), tree());
});
});
@@ -0,0 +1,282 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { distance } from '$lib/geo';
import {
brouterError,
getTags,
graphHopperError,
graphhopperBlockPrivateCustomModels,
interpolatePoints,
parseBRouterRoute,
parseGraphHopperRoute,
routingProfiles,
} from './routing';
describe('routing profiles', () => {
it('block the private roads of every profile of GraphHopper that has a model', () => {
const profiles = Object.values(routingProfiles)
.filter((profile) => profile.engine === 'graphhopper')
.map((profile) => profile.profile);
assert.ok(profiles.length > 0);
for (const profile of profiles) {
assert.ok(graphhopperBlockPrivateCustomModels[profile], profile);
}
// and the models are about profiles that exist
for (const profile of Object.keys(graphhopperBlockPrivateCustomModels)) {
assert.ok(profiles.includes(profile), profile);
}
});
});
describe('GraphHopper', () => {
const response = (details: Record<string, unknown[]>, coordinates: number[][]) => ({
paths: [{ points: { coordinates }, details }],
});
const coordinates = [
[4.0, 50.0, 100],
[4.1, 50.1, 110],
[4.2, 50.2, 120],
[4.3, 50.3, 130],
[4.4, 50.4],
];
it('gives the points of the route, with the elevation of the previous one when missing', () => {
const route = parseGraphHopperRoute(response({}, coordinates));
assert.deepEqual(route.lng, [4.0, 4.1, 4.2, 4.3, 4.4]);
assert.deepEqual(route.lat, [50.0, 50.1, 50.2, 50.3, 50.4]);
assert.deepEqual(route.ele, [100, 110, 120, 130, 130]);
assert.deepEqual(route.surface, new Array(5).fill(undefined));
});
it('gives the details to the points of their ranges, the last range including the last point', () => {
const route = parseGraphHopperRoute(
response(
{
road_class: [
[0, 2, 'cycleway'],
[2, 4, 'path'],
],
surface: [
[0, 3, 'asphalt'],
[3, 4, 'gravel'],
],
hike_rating: [[0, 4, '2']],
mtb_rating: [
[0, 1, '1'],
[1, 4, '5'],
],
},
coordinates
)
);
assert.deepEqual(route.highway, ['cycleway', 'cycleway', 'path', 'path', 'path']);
assert.deepEqual(route.surface, ['asphalt', 'asphalt', 'asphalt', 'gravel', 'gravel']);
// the ratings are converted to the scales
assert.deepEqual(route.sacScale, new Array(5).fill('mountain_hiking'));
assert.deepEqual(route.mtbScale, ['0', '4', '4', '4', '4']);
});
it('ignores the details that are missing or unknown', () => {
const route = parseGraphHopperRoute(
response(
{
road_class: [
[0, 2, 'missing'],
[2, 4, 'primary'],
],
surface: [[0, 4, 'other']],
hike_rating: [[0, 4, '9']],
},
coordinates
)
);
assert.deepEqual(route.highway, [undefined, undefined, 'primary', 'primary', 'primary']);
assert.deepEqual(route.surface, new Array(5).fill(undefined));
assert.deepEqual(route.sacScale, new Array(5).fill(undefined));
});
it('turns its errors into the messages of the interface', () => {
const error = (message: string, details = '', count = 2) =>
graphHopperError({ message, hints: [{ details }] }, count).message;
assert.equal(error('Cannot find point 0: 1,2'), 'toolbar.routing.error.from');
assert.equal(error('Cannot find point 1: 1,2', '', 2), 'toolbar.routing.error.to');
assert.equal(error('Cannot find point 1: 1,2', '', 3), 'toolbar.routing.error.via');
assert.equal(
error('x', 'com.graphhopper.PointDistanceExceededException'),
'toolbar.routing.error.distance'
);
assert.equal(
error('x', 'ConnectionNotFoundException: no way'),
'toolbar.routing.error.connection'
);
assert.equal(error('something else'), 'something else');
// an error without hints is still an error
assert.equal(graphHopperError({ message: 'no hints' }, 2).message, 'no hints');
assert.equal(graphHopperError({}, 2).message, '');
});
});
describe('BRouter', () => {
const messages = [
[
'Longitude',
'Latitude',
'Elevation',
'Distance',
'CostPerKm',
'ElevCost',
'TurnCost',
'NodeCost',
'InitialCost',
'WayTags',
'NodeTags',
],
[
'4100000',
'50100000',
'100',
'10',
'0',
'0',
'0',
'0',
'0',
'highway=path surface=gravel sac_scale=hiking',
'',
],
[
'4300000',
'50300000',
'120',
'10',
'0',
'0',
'0',
'0',
'0',
'highway=residential surface=asphalt mtb:scale=1',
'',
],
];
const geojson = (coordinates: number[][], messages_: string[][] = messages) => ({
features: [{ geometry: { coordinates }, properties: { messages: messages_ } }],
});
it('reads the tags of a way', () => {
assert.deepEqual(getTags('highway=path surface=gravel'), {
highway: 'path',
surface: 'gravel',
});
assert.deepEqual(getTags('sac_scale=hiking mtb:scale=2'), {
sac_scale: 'hiking',
mtb_scale: '2',
});
assert.deepEqual(getTags('a:b:c=d'), { a_b_c: 'd' });
assert.deepEqual(getTags(''), { '': undefined });
});
it('gives each point the tags of its way', () => {
const route = parseBRouterRoute(
geojson([
[4.0, 50.0, 90],
[4.1, 50.1, 100],
[4.2, 50.2, 110],
[4.3, 50.3, 120],
[4.4, 50.4, 130],
])
);
assert.deepEqual(route.ele, [90, 100, 110, 120, 130]);
// the first way is the one of the first message, whose last point is the start of the next
assert.deepEqual(route.highway, [
'path',
'residential',
'residential',
undefined,
undefined,
]);
assert.deepEqual(route.surface, ['gravel', 'asphalt', 'asphalt', undefined, undefined]);
assert.deepEqual(route.sacScale, ['hiking', undefined, undefined, undefined, undefined]);
assert.deepEqual(route.mtbScale, [undefined, '1', '1', undefined, undefined]);
});
it('has no tags for a route without messages', () => {
const route = parseBRouterRoute(
geojson(
[
[4, 50, 1],
[4.1, 50.1, 2],
],
[messages[0]]
)
);
assert.deepEqual(route.lng, [4, 4.1]);
assert.deepEqual(route.highway, [undefined, undefined]);
});
it('turns its errors into the messages of the interface', () => {
assert.equal(
brouterError('from-position not mapped in existing datafile').message,
'toolbar.routing.error.from'
);
assert.equal(
brouterError('via1-position not mapped in existing datafile').message,
'toolbar.routing.error.via'
);
assert.equal(
brouterError('to-position not mapped in existing datafile').message,
'toolbar.routing.error.to'
);
assert.equal(
brouterError('operation killed by Time-out').message,
'toolbar.routing.error.timeout'
);
assert.equal(brouterError('other').message, 'other');
});
});
describe('straight routes', () => {
it('have a point every 50 m and the last point', () => {
const a = { lng: 4, lat: 50 };
const b = { lng: 4.001, lat: 50 };
const route = interpolatePoints([a, b]);
const length = distance(a, b);
assert.equal(route.lng.length, Math.ceil(length / 50) + 1);
assert.deepEqual([route.lng[0], route.lat[0]], [4, 50]);
assert.deepEqual([route.lng.at(-1), route.lat.at(-1)], [4.001, 50]);
// the points are in order, and evenly spaced
for (let i = 1; i < route.lng.length - 1; i++) {
const step = distance(
{ lng: route.lng[i - 1], lat: route.lat[i - 1] },
{ lng: route.lng[i], lat: route.lat[i] }
);
assert.ok(Math.abs(step - 50) < 1, `${step}`);
}
assert.ok(route.ele.every((ele) => ele === 0));
assert.equal(route.surface.length, route.lng.length);
});
it('go through the intermediate points', () => {
const route = interpolatePoints([
{ lng: 4, lat: 50 },
{ lng: 4.001, lat: 50 },
{ lng: 4.001, lat: 50.001 },
]);
assert.ok(route.lng.includes(4.001) && route.lat.includes(50.001));
const corner = route.lng.findIndex((lng, i) => lng === 4.001 && route.lat[i] === 50);
assert.ok(corner > 0);
});
it('are a point for one point, nothing for none', () => {
const route = interpolatePoints([{ lng: 4, lat: 50 }]);
assert.deepEqual(route.lng, [4]);
assert.deepEqual(interpolatePoints([]).lng, []);
// two points at the same place
assert.deepEqual(
interpolatePoints([
{ lng: 4, lat: 50 },
{ lng: 4, lat: 50 },
]).lng,
[4]
);
});
});
@@ -35,12 +35,12 @@ export type RoutedPoints = {
mtbScale: (string | undefined)[];
};
function emptyRoute(): RoutedPoints {
export function emptyRoute(): RoutedPoints {
return { lng: [], lat: [], ele: [], surface: [], highway: [], sacScale: [], mtbScale: [] };
}
/** Adds a trackpoint, which has the elevation of the previous one if it has none. */
function addPoint(route: RoutedPoints, lng: number, lat: number, ele: number | undefined) {
export function addPoint(route: RoutedPoints, lng: number, lat: number, ele: number | undefined) {
route.lng.push(lng);
route.lat.push(lat);
route.ele.push(ele ?? route.ele[route.ele.length - 1] ?? 0);
@@ -82,7 +82,7 @@ const mtbRatingToScale: { [key: string]: string } = {
'7': '6',
};
const graphhopperBlockPrivateCustomModels: { [key: string]: any } = {
export const graphhopperBlockPrivateCustomModels: { [key: string]: any } = {
bike: {
priority: [
{
@@ -123,7 +123,7 @@ const graphhopperBlockPrivateCustomModels: { [key: string]: any } = {
},
],
},
motorcycle: {
motorbike: {
priority: [
{
if: 'road_access == PRIVATE',
@@ -155,26 +155,39 @@ async function getGraphHopperRoute(
});
if (!response.ok) {
const error = await response.json();
if (error.message.includes('Cannot find point 0')) {
throw new Error('toolbar.routing.error.from');
} else if (error.message.includes('Cannot find point 1')) {
if (points.length == 3) {
throw new Error('toolbar.routing.error.via');
} else {
throw new Error('toolbar.routing.error.to');
}
} else if (error.hints[0].details.includes('PointDistanceExceededException')) {
throw new Error('toolbar.routing.error.distance');
} else if (error.hints[0].details.includes('ConnectionNotFoundException')) {
throw new Error('toolbar.routing.error.connection');
} else {
throw new Error(error.message);
}
throw graphHopperError(await response.json(), points.length);
}
let json = await response.json();
return parseGraphHopperRoute(await response.json());
}
/** The error to throw for the error response of GraphHopper. Its message is a translation key. */
export function graphHopperError(
error: { message?: string; hints?: { details?: string }[] },
pointCount: number
): Error {
const message = error.message ?? '';
const details = error.hints?.[0]?.details ?? '';
if (message.includes('Cannot find point 0')) {
return new Error('toolbar.routing.error.from');
} else if (message.includes('Cannot find point 1')) {
return new Error(
pointCount == 3 ? 'toolbar.routing.error.via' : 'toolbar.routing.error.to'
);
} else if (details.includes('PointDistanceExceededException')) {
return new Error('toolbar.routing.error.distance');
} else if (details.includes('ConnectionNotFoundException')) {
return new Error('toolbar.routing.error.connection');
}
return new Error(message);
}
/**
* The route of a response of GraphHopper. The details of the path (road class, surface...) are
* given as ranges of points: `[from, to, value]`, `to` excluded except for the last range, which
* includes the last point.
*/
export function parseGraphHopperRoute(json: any): RoutedPoints {
const route = emptyRoute();
const coordinates = json.paths[0].points.coordinates;
const details = json.paths[0].details;
@@ -184,7 +197,7 @@ async function getGraphHopperRoute(
}
for (const key of graphhopperDetails) {
const detail = details[key];
const detail = details[key] ?? [];
for (let i = 0; i < detail.length; i++) {
for (let j = detail[i][0]; j < detail[i][1] + (i == detail.length - 1 ? 1 : 0); j++) {
if (detail[i][2] !== undefined && detail[i][2] !== 'missing') {
@@ -214,22 +227,32 @@ async function getBRouterRoute(
let response = await fetch(url);
if (!response.ok) {
const error = await response.text();
throw brouterError(await response.text());
}
return parseBRouterRoute(await response.json());
}
/** The error to throw for the error response of BRouter. Its message is a translation key. */
export function brouterError(error: string): Error {
if (error.includes('from-position not mapped in existing datafile')) {
throw new Error('toolbar.routing.error.from');
return new Error('toolbar.routing.error.from');
} else if (error.includes('via1-position not mapped in existing datafile')) {
throw new Error('toolbar.routing.error.via');
return new Error('toolbar.routing.error.via');
} else if (error.includes('to-position not mapped in existing datafile')) {
throw new Error('toolbar.routing.error.to');
return new Error('toolbar.routing.error.to');
} else if (error.includes('Time-out')) {
throw new Error('toolbar.routing.error.timeout');
} else {
throw new Error(error);
}
return new Error('toolbar.routing.error.timeout');
}
return new Error(error);
}
let geojson = await response.json();
/**
* The route of a response of BRouter (GeoJSON). The tags of the ways are in the `messages`, one
* per way: a way starts at the point whose position the message gives, and applies up to the
* next message.
*/
export function parseBRouterRoute(geojson: any): RoutedPoints {
const route = emptyRoute();
const coordinates = geojson.features[0].geometry.coordinates;
const messages = geojson.features[0].properties.messages;
@@ -263,7 +286,8 @@ async function getBRouterRoute(
return route;
}
function getTags(message: string): { [key: string]: string } {
/** The tags of a way, `key=value` separated by spaces, with the `:` of the keys as `_`. */
export function getTags(message: string): { [key: string]: string } {
const fields = message.split(' ');
let tags: { [key: string]: string } = {};
for (let i = 0; i < fields.length; i++) {
@@ -274,7 +298,11 @@ function getTags(message: string): { [key: string]: string } {
return tags;
}
function getIntermediatePoints(points: Coordinates[]): Promise<RoutedPoints> {
/**
* The points of a straight route through `points`: one every 50 m, and the last one. The
* elevations are 0.
*/
export function interpolatePoints(points: Coordinates[]): RoutedPoints {
const route = emptyRoute();
const step = 0.05;
@@ -289,8 +317,14 @@ function getIntermediatePoints(points: Coordinates[]): Promise<RoutedPoints> {
}
const last = points[points.length - 1];
if (last) {
addPoint(route, last.lng, last.lat, 0);
}
return route;
}
function getIntermediatePoints(points: Coordinates[]): Promise<RoutedPoints> {
const route = interpolatePoints(points);
return getElevation(route.lng.map((lng, i) => ({ lng, lat: route.lat[i] }))).then(
(elevations) => {
route.ele = elevations;
+33
View File
@@ -0,0 +1,33 @@
import type { SelectMode } from './types';
export type Wasm = typeof import('gpx-rs');
/** The enumeration of the engine for a way to combine a selection. */
export function selectMode(wasm: Wasm, mode: SelectMode) {
return {
replace: wasm.SelectMode.Replace,
add: wasm.SelectMode.Add,
toggle: wasm.SelectMode.Toggle,
}[mode];
}
/** A flat `[lng, lat, ...]` array as positions. */
export function toPositions(flat: Float64Array): [number, number][] {
const positions: [number, number][] = new Array(flat.length / 2);
for (let i = 0; i < positions.length; i++) {
positions[i] = [flat[2 * i], flat[2 * i + 1]];
}
return positions;
}
export /** Hyphenated UUID strings (as found in the file structures) to the concatenated 16-byte form. */
function idsToBytes(ids: string[]): Uint8Array {
const bytes = new Uint8Array(ids.length * 16);
ids.forEach((id, i) => {
const hex = id.replaceAll('-', '');
for (let j = 0; j < 16; j++) {
bytes[i * 16 + j] = parseInt(hex.slice(2 * j, 2 * j + 2), 16);
}
});
return bytes;
}
+405
View File
@@ -0,0 +1,405 @@
// The frontend side of the engine, against the real engine (the WASM module of `gpx-rs`, which has
// to be built: `wasm-pack build wasm --target bundler --out-dir ../pkg` in `gpx-rs`).
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { beforeEach, describe, it } from 'node:test';
import { get } from 'svelte/store';
import { isHidden } from '$lib/file-visibility';
import { engine, FileStateCollectionObserver, NO_TIME, type FileState } from '$lib/engine';
function fixture(name: string): Uint8Array {
return new Uint8Array(
readFileSync(new URL(`../../../../gpx-rs/engine/data/${name}.gpx`, import.meta.url))
);
}
async function load(...names: string[]) {
return engine.loadFiles(names.map((name) => ({ data: fixture(name), name })));
}
function fileStates(): FileState[] {
return [...get(engine.files).values()].map((store) => get(store));
}
function lastError() {
return get(engine.lastError);
}
beforeEach(async () => {
await engine.ready;
await engine.deleteAll();
});
describe('files', () => {
it('creates a file, selects it, and can take it back', async () => {
assert.equal(await engine.newFile('route'), true);
const [id] = get(engine.order);
assert.equal(get(engine.order).length, 1);
assert.equal(get(get(engine.files).get(id)!).structure.name, 'route');
assert.deepEqual(get(engine.selection), { type: 'file', fileIds: [id] });
assert.equal(get(engine.canUndo), true);
await engine.undo();
assert.deepEqual(get(engine.order), []);
assert.equal(get(engine.files).size, 0);
assert.equal(get(engine.canRedo), true);
await engine.redo();
assert.deepEqual(get(engine.order), [id]);
});
it('starts a new file with a track when given a trackpoint', async () => {
await engine.newFile('start', { lng: 4.4, lat: 50.8, ele: 120 });
const file = fileStates()[0];
assert.equal(file.structure.tracks.length, 1);
assert.equal(file.segments.features.length, 1);
assert.deepEqual(file.segments.features[0].geometry.coordinates, [[4.4, 50.8]]);
assert.equal(get(engine.statistics).length, 1);
assert.equal(get(engine.statistics).ele[0], 120);
});
it('loads several files in one undo step and selects the first one', async () => {
assert.equal(await load('simple', 'with_tracks'), true);
const ids = get(engine.order);
assert.equal(ids.length, 2);
assert.deepEqual(get(engine.selection), { type: 'file', fileIds: [ids[0]] });
// the names of the files are the ones of the data, or the given ones
assert.deepEqual(
fileStates().map((file) => file.structure.name),
['simple', 'with_tracks']
);
await engine.undo();
assert.equal(get(engine.files).size, 0);
});
it('skips what cannot be read, and tells why when nothing could', async () => {
const broken = { data: new TextEncoder().encode('<gpx><trk></gpx>'), name: 'broken' };
assert.equal(await engine.loadFiles([broken]), false);
assert.match(lastError() ?? '', /^invalid data/);
assert.equal(get(engine.files).size, 0);
assert.equal(
await engine.loadFiles([broken, { data: fixture('simple'), name: 'ok' }]),
true
);
assert.equal(get(engine.files).size, 1);
assert.equal(lastError(), undefined);
});
it('duplicates and deletes the selection', async () => {
await load('simple');
await engine.duplicate();
assert.equal(get(engine.order).length, 2);
const [original, copy] = get(engine.order);
assert.notEqual(original, copy);
await engine.delete();
assert.equal(get(engine.files).size, 1);
assert.equal(await engine.deleteAll(), true);
assert.equal(await engine.deleteAll(), false);
assert.equal(lastError(), 'nothing to do');
});
it('reorders the files', async () => {
await load('simple', 'with_tracks', 'with_waypoint');
const [a, b, c] = get(engine.order);
await engine.reorder([c], 0);
assert.deepEqual(get(engine.order), [c, a, b]);
assert.equal(get(engine.canUndo), true);
// an order is not an edit: only the loading is undone
await engine.undo();
assert.equal(get(engine.files).size, 0);
});
});
describe('errors', () => {
it('keeps the reason of the last call that did nothing', async () => {
assert.equal(lastError(), 'nothing to do');
await engine.newFile('a');
assert.equal(lastError(), undefined);
await engine.select([]);
assert.equal(await engine.reverse(), false);
assert.equal(lastError(), 'nothing to do');
// a call with wrong arguments says so
assert.equal(await engine.crop(5, 1), false);
assert.match(lastError() ?? '', /reversed/);
// the next success clears it
assert.equal(await engine.newFile('b'), true);
assert.equal(lastError(), undefined);
});
it('refuses a stale routing revision', async () => {
await engine.newFile('a', { lng: 4, lat: 50 });
assert.equal(await engine.insertAnchor(-1, 4, 50), false);
assert.match(lastError() ?? '', /selection changed/);
});
});
describe('selection', () => {
it('adds and toggles files', async () => {
await load('simple', 'with_tracks', 'with_waypoint');
const [a, b, c] = get(engine.order);
await engine.select([a]);
await engine.select([b], 'add');
assert.deepEqual(new Set((get(engine.selection) as any).fileIds), new Set([a, b]));
await engine.select([a], 'toggle');
assert.deepEqual((get(engine.selection) as any).fileIds, [b]);
await engine.select([]);
assert.deepEqual(get(engine.selection), { type: 'empty' });
assert.equal(await engine.selectAll(), true);
assert.deepEqual(new Set((get(engine.selection) as any).fileIds), new Set([a, b, c]));
});
it('selects tracks and segments of a file', async () => {
await load('with_tracks_and_segments');
const file = fileStates()[0];
const track = file.structure.tracks[0];
await engine.selectTracks(file.structure.id, [track.id]);
assert.deepEqual(get(engine.selection), {
type: 'track',
fileId: file.structure.id,
trackIds: [track.id],
});
// the statistics follow the selection
const trackLength = get(engine.statistics).length;
assert.ok(trackLength > 0);
await engine.select([file.structure.id]);
assert.ok(get(engine.statistics).length > trackLength);
});
it('ignores what does not exist', async () => {
await load('simple');
const before = get(engine.selection);
assert.equal(await engine.select(['00000000-0000-0000-0000-000000000000'], 'add'), false);
assert.deepEqual(get(engine.selection), before);
});
});
describe('statistics', () => {
it('describes the selection', async () => {
await load('simple');
const statistics = get(engine.statistics);
const file = fileStates()[0];
assert.ok(statistics.length > 1);
assert.equal(statistics.lng.length, statistics.length);
assert.equal(statistics.ele.length, statistics.length);
assert.equal(statistics.totalDistance[0], 0);
assert.ok(statistics.global.totalDistance > 0);
assert.equal(statistics.global.totalDistance, file.statistics.totalDistance);
assert.equal(statistics.timestamps, undefined);
assert.ok(statistics.global.bounds!.west <= statistics.global.bounds!.east);
await engine.select([]);
assert.equal(get(engine.statistics).length, 0);
assert.equal(get(engine.statistics).global.totalDistance, 0);
});
it('has timestamps when the file has some', async () => {
await load('with_time');
const statistics = get(engine.statistics);
assert.ok(statistics.timestamps);
assert.equal(statistics.timestamps!.length, statistics.length);
assert.ok(statistics.timestamps!.some((t) => t !== NO_TIME));
assert.ok(statistics.global.totalTime! > 0);
assert.ok(statistics.global.movingSpeed === undefined || statistics.global.movingSpeed > 0);
});
it('reads the metrics that are requested, and only these', async () => {
await load('with_hr');
assert.equal(get(engine.statistics).hr, undefined);
const request = engine.requestStatistics();
request.set(['hr', 'anchors']);
const statistics = get(engine.statistics);
assert.equal(statistics.hr!.length, statistics.length);
assert.ok(statistics.hr!.some((hr) => !Number.isNaN(hr)));
assert.equal(statistics.cad, undefined);
assert.equal(statistics.anchors!.indices[0], 0);
assert.equal(statistics.anchors!.indices.at(-1), statistics.length - 1);
// they are read again after a change, without being asked again
await engine.select([]);
await engine.select([get(engine.order)[0]]);
assert.ok(get(engine.statistics).hr);
request.release();
await engine.select([]);
await engine.select([get(engine.order)[0]]);
assert.equal(get(engine.statistics).hr, undefined);
});
it('gives the statistics of a part of the selection', async () => {
await load('simple');
const statistics = get(engine.statistics);
const part = statistics.slice(2, 5)!;
assert.ok(part.totalDistance > 0);
assert.ok(part.totalDistance < statistics.global.totalDistance);
assert.equal(statistics.slice(0, statistics.length + 10), undefined);
// a slice is only valid for the selection it was made on
await engine.select([]);
assert.equal(statistics.slice(2, 5), undefined);
});
});
describe('file state', () => {
it('has one feature per segment and keeps the unchanged ones when editing', async () => {
await load('with_tracks_and_segments');
const before = fileStates()[0];
assert.equal(
before.segments.features.length,
before.structure.tracks.reduce((sum, track) => sum + track.segments.length, 0)
);
// renaming does not touch the segments: they are the same objects
await engine.metadata('renamed', 'description');
const renamed = fileStates()[0];
assert.equal(renamed.structure.name, 'renamed');
assert.equal(renamed.structure.desc, 'description');
renamed.segments.features.forEach((feature, i) => {
assert.equal(feature, before.segments.features[i]);
});
// reversing a file reverses its tracks and their points
await engine.reverse();
const reversed = fileStates()[0].segments.features.map((f) => f.geometry.coordinates);
const expected = [...renamed.segments.features]
.reverse()
.map((f) => [...f.geometry.coordinates].reverse());
assert.deepEqual(reversed, expected);
await engine.undo();
fileStates()[0].segments.features.forEach((feature, i) => {
assert.deepEqual(
feature.geometry.coordinates,
renamed.segments.features[i].geometry.coordinates
);
});
});
it('colors the files, and keeps the color of a file', async () => {
await load('simple', 'with_tracks');
const [a, b] = fileStates();
assert.notEqual(a.color, b.color);
await engine.select([a.structure.id]);
await engine.style({ color: 'ff0000' });
assert.equal(get(get(engine.files).get(a.structure.id)!).color, '#ff0000');
const styled = get(get(engine.files).get(a.structure.id)!).segments.features[0];
assert.equal(styled.properties.color, '#ff0000');
await engine.undo();
assert.equal(get(get(engine.files).get(a.structure.id)!).color, a.color);
});
it('has the waypoints as features', async () => {
await load('with_waypoint');
const file = fileStates()[0];
assert.ok(file.waypoints.features.length > 0);
const feature = file.waypoints.features[0];
const details = engine.waypoint(file.structure.id, feature.properties.waypointId)!;
assert.deepEqual(feature.geometry.coordinates, [details.lng, details.lat]);
assert.equal(feature.properties.name, details.name);
assert.equal(details.links?.[0].href, 'https://gpx.studio');
assert.equal(details.links?.[0].text, 'waypoint link text');
});
});
describe('waypoints', () => {
const waypoint = {
lng: 4.4,
lat: 50.8,
ele: 100,
name: 'summit',
desc: 'a nice view',
icon: 'Summit',
link: 'https://example.com',
};
it('creates, edits, moves and deletes a waypoint', async () => {
await engine.newFile('file');
assert.equal(await engine.newWaypoint(waypoint), true);
const file = fileStates()[0];
const id = file.waypoints.features[0].properties.waypointId;
const fileId = file.structure.id;
let details = engine.waypoint(fileId, id)!;
assert.equal(details.name, 'summit');
assert.equal(details.sym, 'Summit');
assert.equal(details.links![0].href, 'https://example.com');
await engine.updateWaypoint(fileId, id, { ...waypoint, name: 'peak', link: '' });
details = engine.waypoint(fileId, id)!;
assert.equal(details.name, 'peak');
assert.equal(details.links, undefined);
await engine.moveWaypoint(fileId, id, 5, 51, 200);
assert.deepEqual(fileStates()[0].waypoints.features[0].geometry.coordinates, [5, 51]);
assert.equal(engine.waypoint(fileId, id)!.ele, 200);
await engine.deleteWaypoint(fileId, id);
assert.equal(fileStates()[0].waypoints.features.length, 0);
assert.equal(engine.waypoint(fileId, id), undefined);
assert.equal(await engine.deleteWaypoint(fileId, id), false);
});
});
describe('clipboard', () => {
it('copies and pastes', async () => {
await load('simple');
assert.equal(get(engine.clipboard), undefined);
assert.equal(get(engine.canPaste), false);
await engine.copy();
assert.equal(get(engine.clipboard)?.type, 'files');
assert.equal(get(engine.canPaste), true);
await engine.paste();
assert.equal(get(engine.files).size, 2);
});
});
describe('observer of the files', () => {
it('tells which files appear and disappear', async () => {
const added: string[] = [];
const removed: string[] = [];
let destroyed = 0;
await load('simple');
const [first] = get(engine.order);
const observer = new FileStateCollectionObserver(
(files) => files.forEach((_, id) => added.push(id)),
(id) => removed.push(id),
() => destroyed++
);
// the files that exist are reported at once
assert.deepEqual(added, [first]);
await load('with_tracks');
const second = get(engine.order).find((id) => id !== first)!;
assert.deepEqual(added, [first, second]);
// editing a file does not make it appear again
await engine.metadata('x', '');
assert.equal(added.length, 2);
await engine.deleteAll();
assert.deepEqual(new Set(removed), new Set([first, second]));
observer.destroy();
assert.equal(destroyed, 1);
});
});
describe('visibility', () => {
it('hides the selection and shows it again', async () => {
await load('simple');
const id = get(engine.order)[0];
const state = () => get(get(engine.files).get(id)!);
assert.equal(isHidden(state(), id), false);
await engine.setSelectionHidden(true);
assert.equal(isHidden(state(), id), true);
// the statistics still count what is hidden
assert.ok(get(engine.statistics).length > 0);
await engine.setSelectionHidden(false);
assert.equal(isHidden(state(), id), false);
});
});
@@ -1,300 +1,38 @@
import { browser } from '$app/environment';
import { LEGACY_IMPORTED_KEY, readLegacyData } from '$lib/legacy-import';
import { get, writable, type Readable, type Writable } from 'svelte/store';
import { FileColorAllocator, normalizeColor } from '$lib/file-colors';
import { FileColorAllocator } from '$lib/file-colors';
import {
parseVisibility,
setHidden,
stringifyVisibility,
VISIBILITY_KEY,
type Visibility,
} from '$lib/file-visibility';
import { categoryIntervals, type CategoryIntervals } from '$lib/trackpoint-categories';
import { categoryIntervals } from '$lib/trackpoint-categories';
import { selectedElementIds, type FileTreeNode } from '$lib/selection-helpers';
import type { Feature, FeatureCollection, LineString, Point } from 'geojson';
import type {
Clipboard,
GlobalStatistics,
FileStructure,
MoveTarget,
Selection,
WaypointDetails,
TrackpointDetails,
RouteAttributes,
Outcome,
FilesUpdate,
StorageOpened,
} from 'gpx-rs';
export type {
Clipboard,
WaypointDetails,
TrackpointDetails,
MoveTarget,
GlobalStatistics,
FileStructure,
FilesUpdate,
Outcome,
Selection,
TrackNode,
SegmentNode,
WaypointNode,
} from 'gpx-rs';
type Wasm = typeof import('gpx-rs');
/** Name of the IndexedDB database that keeps the files and the settings. */
const STORAGE_NAME = 'gpx-studio';
/**
* How a merge puts the selected elements together: `connect` makes a single segment of their
* trackpoints, `group` keeps them as they are, in the first one.
*/
export type MergeType = 'connect' | 'group';
/** What a split cuts in two: the file, the track or the segment of the trackpoint. */
export type SplitType = 'files' | 'tracks' | 'segments';
/**
* How elements combine with the current selection: they `replace` it, are `add`ed to it, or are
* `toggle`d (the ones already selected are removed). Adding and toggling only merge with elements
* of the same kind in the same place (same file, same track for segments), otherwise the new
* elements replace the selection.
*/
export type SelectMode = 'replace' | 'add' | 'toggle';
function selectMode(wasm: Wasm, mode: SelectMode) {
return {
replace: wasm.SelectMode.Replace,
add: wasm.SelectMode.Add,
toggle: wasm.SelectMode.Toggle,
}[mode];
}
export type SegmentProperties = {
fileId: string;
trackId: string;
segmentId: string;
trackIndex: number;
segmentIndex: number;
/** Changes when the trackpoints of the segment change. */
rev: string;
/** Color of the track, or else the base color of the file. */
color: string;
/** Only when the track defines it. */
opacity?: number;
width?: number;
};
export type WaypointProperties = {
fileId: string;
waypointId: string;
index: number;
name?: string;
sym?: string;
};
/** The data of a waypoint to create or to change. Empty strings are fields it does not have. */
export type NewWaypoint = {
lng: number;
lat: number;
ele: number;
name: string;
desc: string;
icon: string;
link: string;
};
/** Everything the UI knows about one file. */
export type FileState = {
structure: FileStructure;
/** Color of the file: the one defined by its tracks if any, otherwise one from the palette. */
color: string;
/** Global statistics of the file. */
statistics: GlobalStatistics;
/** One LineString feature per track segment, in file order. */
segments: FeatureCollection<LineString, SegmentProperties>;
/** One Point feature per waypoint, in file order. */
waypoints: FeatureCollection<Point, WaypointProperties>;
/**
* Visibility the user set explicitly (see `Visibility` and `isHidden`, `isSegmentHidden`,
* `isWaypointHidden` in `file-visibility`). Hidden elements are still part of the statistics.
*/
visibility: Visibility;
};
const EMPTY_STATISTICS: GlobalStatistics = { totalDistance: 0, elevationGain: 0, elevationLoss: 0 };
/**
* New trackpoints for `Engine.route`, as arrays with an entry per trackpoint. The OSM attributes
* are the names of the values (`undefined` when unknown), they are left unknown if missing.
*/
export type RoutePoints = {
lng: ArrayLike<number>;
lat: ArrayLike<number>;
ele: ArrayLike<number>;
surface?: readonly (string | undefined)[];
highway?: readonly (string | undefined)[];
sacScale?: readonly (string | undefined)[];
mtbScale?: readonly (string | undefined)[];
};
/** Which data of the trackpoints a file is exported with (see `Engine.exportFile`). */
export type ExportOptions = {
time: boolean;
hr: boolean;
cad: boolean;
atemp: boolean;
power: boolean;
/** The OpenStreetMap data: surface, highway, SAC scale and MTB scale. */
osm: boolean;
};
export const ALL_EXPORT_OPTIONS: ExportOptions = {
time: true,
hr: true,
cad: true,
atemp: true,
power: true,
osm: true,
};
/**
* Timestamp of the trackpoints that have none, in `SelectionStatistics.timestamps` (the smallest
* 64-bit integer, like `NO_TIME` in the engine).
*/
export const NO_TIME = -(2n ** 63n);
/**
* Statistics of the selection that are only read from the engine when something asks for them
* (see `Engine.requestStatistics`). A metric can bring several arrays.
*/
export type StatisticsMetric =
| 'speed'
| 'hr'
| 'cad'
| 'atemp'
| 'power'
/** `slopeSegmentSlope` and `slopeSegmentDistance`. */
| 'slopeSegment'
| 'surface'
/** `highway`, `sacScale` and `mtbScale`. */
| 'highway'
/** `anchors`. */
| 'anchors';
/**
* The anchors of the routing tool among the trackpoints of the selection: the points that can be
* dragged to reroute a segment, which are few of them. The arrays are as long as the number of
* anchors, in the order of the trackpoints.
*/
export type SelectionAnchors = {
/** Index of the anchor in the trackpoints of the selection. */
indices: Uint32Array;
/** Lowest map zoom level at which the anchor is shown (0 for the ends of the segments). */
zooms: Uint8Array;
/**
* Index of the first trackpoint of each segment of the selection that has some: anchors that
* are between two consecutive starts belong to the same segment.
*/
segmentStarts: Uint32Array;
/** Id of each of these segments. */
segmentIds: string[];
/**
* Changes when the selected segments, or their trackpoints, change. The indices in the
* selection (of trackpoints, of anchors) only mean something for one revision, which commands
* that use them have to give.
*/
revision: number;
};
/**
* The statistics of the selection: its global statistics, and the values at each of its
* trackpoints (all the selected segments, one after the other). The arrays are copies.
*
* Distances are cumulative over the whole selection. Distances are in km, speeds in km/h, times in
* ms (timestamps since the epoch, as 64-bit integers), elevations in m and slopes in %.
*
* The values of the trackpoints that the selection does not have at all (for example the heart
* rates, when no trackpoint has any) are `undefined`, and so are the ones that were not requested
* (see `StatisticsMetric`). Otherwise they have one entry per trackpoint: missing timestamps are
* `NO_TIME`, missing measures are NaN.
*/
export type SelectionStatistics = {
global: GlobalStatistics;
/** Number of trackpoints. */
length: number;
totalDistance: Float64Array;
/** Slope at the trackpoint. */
slope: Float64Array;
lng: Float64Array;
lat: Float64Array;
ele: Float64Array;
timestamps?: BigInt64Array;
// the following need to be requested
speed?: Float64Array;
hr?: Float64Array;
cad?: Float64Array;
atemp?: Float64Array;
power?: Float64Array;
/** Slope and length (km) of the smoothed elevation segment the trackpoint belongs to. */
slopeSegmentSlope?: Float64Array;
slopeSegmentDistance?: Float64Array;
surface?: CategoryIntervals;
highway?: CategoryIntervals;
/** SAC hiking scale. */
sacScale?: CategoryIntervals;
/** Mountain biking scale. */
mtbScale?: CategoryIntervals;
anchors?: SelectionAnchors;
/**
* Global statistics of the trackpoints from `start` to `end` (both included), for example
* the part of the elevation profile that was dragged over. `undefined` if the range is not
* in the selection, or if the selection is not the current one anymore.
*/
slice(start: number, end: number): GlobalStatistics | undefined;
};
const EMPTY_SELECTION_STATISTICS: SelectionStatistics = {
global: EMPTY_STATISTICS,
length: 0,
totalDistance: new Float64Array(),
slope: new Float64Array(),
lng: new Float64Array(),
lat: new Float64Array(),
ele: new Float64Array(),
slice: () => undefined,
};
/** What a consumer of the statistics needs, see `Engine.requestStatistics`. */
export type StatisticsRequest = {
/** Replaces the metrics needed by this consumer. */
set(metrics: Iterable<StatisticsMetric>): void;
/** The consumer does not need any metric anymore. */
release(): void;
};
function toPositions(flat: Float64Array): [number, number][] {
const positions: [number, number][] = new Array(flat.length / 2);
for (let i = 0; i < positions.length; i++) {
positions[i] = [flat[2 * i], flat[2 * i + 1]];
}
return positions;
}
/** Hyphenated UUID strings (as found in the file structures) to the concatenated 16-byte form. */
function idsToBytes(ids: string[]): Uint8Array {
const bytes = new Uint8Array(ids.length * 16);
ids.forEach((id, i) => {
const hex = id.replaceAll('-', '');
for (let j = 0; j < 16; j++) {
bytes[i * 16 + j] = parseInt(hex.slice(2 * j, 2 * j + 2), 16);
}
});
return bytes;
}
import type { RouteAttributes } from 'gpx-rs';
import { idsToBytes, selectMode, type Wasm } from './convert';
import { readFileState } from './file-state';
import { StatisticsReader } from './statistics';
import { openStoredFiles } from './storage';
import {
ALL_EXPORT_OPTIONS,
type Clipboard,
type ExportOptions,
type FileState,
type FilesUpdate,
type MergeType,
type MoveTarget,
type NewWaypoint,
type Outcome,
type RoutePoints,
type Selection,
type SelectionStatistics,
type SelectMode,
type SplitType,
type StatisticsRequest,
type TrackpointDetails,
type WaypointDetails,
} from './types';
/**
* Thin wrapper around the WASM engine.
@@ -323,18 +61,12 @@ class Engine {
private _canRedo = writable(false);
private _canPaste = writable(false);
private _clipboard = writable<Clipboard | undefined>(undefined);
private _statistics = writable<SelectionStatistics>(EMPTY_SELECTION_STATISTICS);
/** Identifies the statistics currently in the engine's buffers. */
private _statisticsVersion = 0;
/** What each consumer of the statistics asked for. */
private _statisticsRequests = new Set<Set<StatisticsMetric>>();
/** The metrics that were read for the current statistics. */
private _loadedMetrics = new Set<StatisticsMetric>();
private _statistics = new StatisticsReader(() => this.wasm);
/** Ids of the files in display order. */
readonly order: Readable<string[]> = { subscribe: this._order.subscribe };
/** Statistics of what is currently selected. */
readonly statistics: Readable<SelectionStatistics> = { subscribe: this._statistics.subscribe };
readonly statistics: Readable<SelectionStatistics> = this._statistics.store;
readonly canUndo: Readable<boolean> = { subscribe: this._canUndo.subscribe };
readonly canRedo: Readable<boolean> = { subscribe: this._canRedo.subscribe };
/** Why the last call to the engine did nothing, `undefined` if it did something. */
@@ -374,73 +106,19 @@ class Engine {
* The first time, what the previous versions of the app kept in the browser is imported.
*/
openStorage(): Promise<Record<string, string>> {
this._storage ??= this.openStorageOnce();
this._storage ??= this.ready.then(() =>
openStoredFiles({
wasm: this.wasm!,
visibility: this._visibility,
sync: (update) => this.sync(this.wasm!, update),
hasFiles: () => get(this._order).length > 0,
loadFiles: (files) => this.loadFiles(files),
deselect: () => this.select([]),
})
);
return this._storage;
}
private async openStorageOnce(): Promise<Record<string, string>> {
await this.ready;
const wasm = this.wasm!;
let opened: StorageOpened;
try {
opened = await wasm.open_storage(STORAGE_NAME);
} catch (error) {
console.error('The files cannot be kept in this browser', error);
return {};
}
if (opened.readOnly) {
console.warn(
'The files were saved by a newer version of the app: they are not saved again by this one'
);
}
if (opened.unreadable > 0) {
console.warn(`${opened.unreadable} stored file(s) could not be read`);
}
let settings = opened.settings;
// what was hidden, which the files that were restored need when they are read
parseVisibility(settings[VISIBILITY_KEY]).forEach((visibility, fileId) =>
this._visibility.set(fileId, visibility)
);
this.sync(wasm, opened.outcome);
if (settings[LEGACY_IMPORTED_KEY] === undefined && get(this._order).length === 0) {
settings = { ...settings, ...(await this.importLegacyData(wasm)) };
await wasm.flush_storage();
}
return settings;
}
/** Puts the files and the settings of the former database in the engine. */
private async importLegacyData(wasm: Wasm): Promise<Record<string, string>> {
const imported: Record<string, string> = {};
try {
const legacy = await readLegacyData();
if (legacy) {
const encoder = new TextEncoder();
if (legacy.files.length > 0) {
await this.loadFiles(
legacy.files.map((file) => ({
data: encoder.encode(file.gpx),
name: file.name,
}))
);
await this.select([]);
}
for (const [key, value] of Object.entries(legacy.settings)) {
wasm.set_setting(key, value);
imported[key] = value;
}
}
} catch (error) {
// it is tried again next time
console.error('The files of the previous version could not be imported', error);
return imported;
}
wasm.set_setting(LEGACY_IMPORTED_KEY, 'true');
imported[LEGACY_IMPORTED_KEY] = 'true';
return imported;
}
/** Keeps a setting, `json` being its JSON. Nothing is kept before `openStorage` is done. */
setSetting(key: string, json: string) {
this.wasm?.set_setting(key, json);
@@ -998,6 +676,17 @@ class Engine {
}
}
/**
* 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
* added to `statistics`: the ones that are missing are read right away, and then after each
* change of the selection or of the files, and the store is updated. Call `release` when the
* consumer goes away.
*/
requestStatistics(): StatisticsRequest {
return this._statistics.request();
}
/** Updates what is kept of the engine with what a call changed. */
private sync(wasm: Wasm, update: FilesUpdate) {
this._canUndo.set(wasm.can_undo());
@@ -1014,7 +703,7 @@ class Engine {
}
const added: [string, FileState][] = [];
for (const id of update.added) {
const state = this.readFile(wasm, id);
const state = readFileState(wasm, id, this._colors, this._visibility);
if (state) {
added.push([id, state]);
}
@@ -1034,7 +723,8 @@ class Engine {
const files = get(this._files);
for (const id of update.modified) {
const store = files.get(id);
const state = store && this.readFile(wasm, id, get(store));
const state =
store && readFileState(wasm, id, this._colors, this._visibility, get(store));
if (store && state) {
store.set(state);
}
@@ -1047,294 +737,9 @@ class Engine {
update.orderChanged ||
update.added.length + update.modified.length + update.removed.length > 0
) {
this._statistics.set(this.readStatistics(wasm));
this._statistics.refresh(wasm);
}
}
/**
* Reads the statistics buffers of the engine (the getters copy them), but only the metrics
* that were requested.
*/
private readStatistics(wasm: Wasm): SelectionStatistics {
const version = ++this._statisticsVersion;
this._loadedMetrics.clear();
const totalDistance = wasm.total_distance();
const statistics: SelectionStatistics = {
global: wasm.selection_statistics() ?? EMPTY_STATISTICS,
length: totalDistance.length,
totalDistance,
slope: wasm.slope(),
lng: wasm.lng(),
lat: wasm.lat(),
ele: wasm.ele(),
timestamps: wasm.timestamps(),
slice: (start, end) =>
version === this._statisticsVersion ? wasm.slice_statistics(start, end) : undefined,
};
this.readMetrics(wasm, statistics, this.requestedMetrics());
return statistics;
}
private requestedMetrics(): Set<StatisticsMetric> {
const metrics = new Set<StatisticsMetric>();
this._statisticsRequests.forEach((request) => request.forEach((m) => metrics.add(m)));
return metrics;
}
/** Reads the metrics that were not read yet into `statistics`, which is the current one. */
private readMetrics(
wasm: Wasm,
statistics: SelectionStatistics,
metrics: Set<StatisticsMetric>
) {
const categories = (
starts: Uint32Array,
values: Uint8Array,
names: () => string[]
): CategoryIntervals => ({
starts,
values,
names: names(),
});
for (const metric of metrics) {
if (this._loadedMetrics.has(metric)) {
continue;
}
this._loadedMetrics.add(metric);
switch (metric) {
case 'speed':
statistics.speed = wasm.speed();
break;
case 'hr':
statistics.hr = wasm.hr();
break;
case 'cad':
statistics.cad = wasm.cad();
break;
case 'atemp':
statistics.atemp = wasm.atemp();
break;
case 'power':
statistics.power = wasm.power();
break;
case 'slopeSegment':
statistics.slopeSegmentSlope = wasm.slope_segment_slope();
statistics.slopeSegmentDistance = wasm.slope_segment_distance();
break;
case 'surface':
statistics.surface = categories(
wasm.surface_starts(),
wasm.surface_values(),
wasm.surfaces
);
break;
case 'anchors':
statistics.anchors = {
indices: wasm.anchor_indices(),
zooms: wasm.anchor_zooms(),
segmentStarts: wasm.segment_starts(),
segmentIds: wasm.segment_ids(),
revision: wasm.routing_revision(),
};
break;
case 'highway':
statistics.highway = categories(
wasm.highway_starts(),
wasm.highway_values(),
wasm.highways
);
statistics.sacScale = categories(
wasm.sac_scale_starts(),
wasm.sac_scale_values(),
wasm.sac_scales
);
statistics.mtbScale = categories(
wasm.mtb_scale_starts(),
wasm.mtb_scale_values(),
wasm.mtb_scales
);
break;
}
}
}
/**
* 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
* added to `statistics`: the ones that are missing are read right away, and then after each
* change of the selection or of the files, and the store is updated. Call `release` when the
* consumer goes away.
*/
requestStatistics(): StatisticsRequest {
const wanted = new Set<StatisticsMetric>();
this._statisticsRequests.add(wanted);
return {
set: (metrics) => {
wanted.clear();
for (const metric of metrics) {
wanted.add(metric);
}
this.loadMissingMetrics();
},
release: () => {
this._statisticsRequests.delete(wanted);
},
};
}
/** Reads the requested metrics that are not in the current statistics yet. */
private loadMissingMetrics() {
const wasm = this.wasm;
const metrics = this.requestedMetrics();
if (!wasm || [...metrics].every((metric) => this._loadedMetrics.has(metric))) {
return;
}
// the buffers of the engine still are the ones of the current statistics, as they are
// read after every action
const statistics = { ...get(this._statistics) };
this.readMetrics(wasm, statistics, metrics);
this._statistics.set(statistics);
}
/** Reads the state of a file, reusing from `previous` what did not change. */
private readFile(wasm: Wasm, id: string, previous?: FileState): FileState | null {
const structure = wasm.file_structure(id);
if (!structure) {
return null;
}
const color = this._colors.resolve(
id,
structure.tracks.map((track) => track.color)
);
const previousSegments = new Map(
previous?.segments.features.map((f) => [f.properties.segmentId, f]) ?? []
);
const segments: Feature<LineString, SegmentProperties>[] = [];
structure.tracks.forEach((track, trackIndex) => {
track.segments.forEach((segment, segmentIndex) => {
const properties: SegmentProperties = {
fileId: id,
trackId: track.id,
segmentId: segment.id,
trackIndex,
segmentIndex,
rev: segment.rev,
color: track.color !== undefined ? normalizeColor(track.color) : color,
opacity: track.opacity,
width: track.width,
};
const old = previousSegments.get(segment.id);
const unchanged =
old !== undefined &&
(Object.keys(properties) as (keyof SegmentProperties)[]).every(
(key) => old.properties[key] === properties[key]
);
segments.push(
unchanged
? old
: {
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: toPositions(wasm.segment_coordinates(segment.id)),
},
properties,
}
);
});
});
const previousStructure = previous?.structure;
const waypointsUnchanged =
previousStructure !== undefined &&
previousStructure.waypointsRev === structure.waypointsRev &&
previousStructure.waypoints.length === structure.waypoints.length &&
previousStructure.waypoints.every(
(w, i) =>
w.id === structure.waypoints[i].id &&
w.name === structure.waypoints[i].name &&
w.sym === structure.waypoints[i].sym
);
let waypoints = previous?.waypoints;
if (!waypoints || !waypointsUnchanged) {
const coordinates = toPositions(wasm.waypoint_coordinates(id));
waypoints = {
type: 'FeatureCollection',
features: structure.waypoints.map((waypoint, index) => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: coordinates[index] },
properties: {
fileId: id,
waypointId: waypoint.id,
index,
name: waypoint.name,
sym: waypoint.sym,
},
})),
};
}
return {
structure,
color,
statistics: wasm.file_statistics(id) ?? EMPTY_STATISTICS,
segments: { type: 'FeatureCollection', features: segments },
waypoints,
visibility: this._visibility.get(id) ?? new Map(),
};
}
}
export const engine = new Engine();
export type FileStatesCallback = (files: Map<string, Readable<FileState>>) => void;
/**
* Tells when file stores appear and disappear, so that their subscribers know when to
* subscribe and unsubscribe. `onFilesAdded` is called with the stores of the new files only
* (including the ones already present when the observer is created).
*/
export class FileStateCollectionObserver {
private _fileIds = new Set<string>();
private _onFilesAdded: FileStatesCallback;
private _onFileRemoved: (fileId: string) => void;
private _onDestroy: () => void;
private _unsubscribe: () => void;
constructor(
onFilesAdded: FileStatesCallback,
onFileRemoved: (fileId: string) => void,
onDestroy: () => void
) {
this._onFilesAdded = onFilesAdded;
this._onFileRemoved = onFileRemoved;
this._onDestroy = onDestroy;
this._unsubscribe = engine.files.subscribe((files) => {
this._fileIds.forEach((fileId) => {
if (!files.has(fileId)) {
this._onFileRemoved(fileId);
this._fileIds.delete(fileId);
}
});
const newFiles = new Map<string, Readable<FileState>>();
files.forEach((store, fileId) => {
if (!this._fileIds.has(fileId)) {
newFiles.set(fileId, store);
this._fileIds.add(fileId);
}
});
if (newFiles.size > 0) {
this._onFilesAdded(newFiles);
}
});
}
destroy() {
this._onDestroy();
this._unsubscribe();
}
}
+101
View File
@@ -0,0 +1,101 @@
import type { Feature, LineString } from 'geojson';
import { normalizeColor, type FileColorAllocator } from '$lib/file-colors';
import type { Visibility } from '$lib/file-visibility';
import { toPositions, type Wasm } from './convert';
import { EMPTY_STATISTICS, type FileState, type SegmentProperties } from './types';
/** Reads the state of a file, reusing from `previous` what did not change. */
export function readFileState(
wasm: Wasm,
id: string,
colors: FileColorAllocator,
visibility: ReadonlyMap<string, Visibility>,
previous?: FileState
): FileState | null {
const structure = wasm.file_structure(id);
if (!structure) {
return null;
}
const color = colors.resolve(
id,
structure.tracks.map((track) => track.color)
);
const previousSegments = new Map(
previous?.segments.features.map((f) => [f.properties.segmentId, f]) ?? []
);
const segments: Feature<LineString, SegmentProperties>[] = [];
structure.tracks.forEach((track, trackIndex) => {
track.segments.forEach((segment, segmentIndex) => {
const properties: SegmentProperties = {
fileId: id,
trackId: track.id,
segmentId: segment.id,
trackIndex,
segmentIndex,
rev: segment.rev,
color: track.color !== undefined ? normalizeColor(track.color) : color,
opacity: track.opacity,
width: track.width,
};
const old = previousSegments.get(segment.id);
const unchanged =
old !== undefined &&
(Object.keys(properties) as (keyof SegmentProperties)[]).every(
(key) => old.properties[key] === properties[key]
);
segments.push(
unchanged
? old
: {
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: toPositions(wasm.segment_coordinates(segment.id)),
},
properties,
}
);
});
});
const previousStructure = previous?.structure;
const waypointsUnchanged =
previousStructure !== undefined &&
previousStructure.waypointsRev === structure.waypointsRev &&
previousStructure.waypoints.length === structure.waypoints.length &&
previousStructure.waypoints.every(
(w, i) =>
w.id === structure.waypoints[i].id &&
w.name === structure.waypoints[i].name &&
w.sym === structure.waypoints[i].sym
);
let waypoints = previous?.waypoints;
if (!waypoints || !waypointsUnchanged) {
const coordinates = toPositions(wasm.waypoint_coordinates(id));
waypoints = {
type: 'FeatureCollection',
features: structure.waypoints.map((waypoint, index) => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: coordinates[index] },
properties: {
fileId: id,
waypointId: waypoint.id,
index,
name: waypoint.name,
sym: waypoint.sym,
},
})),
};
}
return {
structure,
color,
statistics: wasm.file_statistics(id) ?? EMPTY_STATISTICS,
segments: { type: 'FeatureCollection', features: segments },
waypoints,
visibility: visibility.get(id) ?? new Map(),
};
}
+13
View File
@@ -0,0 +1,13 @@
// The engine of the files, as the UI sees it: stores that follow what the WASM engine holds.
//
// - `engine.ts`: the facade, with the actions and the stores
// - `types.ts`: the types of what it gives
// - `file-state.ts`: reads the state of a file (GeoJSON features, statistics...) from the engine
// - `statistics.ts`: reads the statistics of the selection, and only the metrics that are needed
// - `storage.ts`: opens the storage of the browser, and imports what the old versions kept
// - `observer.ts`: tells when the stores of the files appear and disappear
// - `convert.ts`: conversions between the UI's values and the engine's
export * from './types';
export { engine } from './engine';
export { FileStateCollectionObserver } from './observer';
+50
View File
@@ -0,0 +1,50 @@
import type { Readable } from 'svelte/store';
import { engine } from './engine';
import type { FileState, FileStatesCallback } from './types';
/**
* Tells when file stores appear and disappear, so that their subscribers know when to
* subscribe and unsubscribe. `onFilesAdded` is called with the stores of the new files only
* (including the ones already present when the observer is created).
*/
export class FileStateCollectionObserver {
private _fileIds = new Set<string>();
private _onFilesAdded: FileStatesCallback;
private _onFileRemoved: (fileId: string) => void;
private _onDestroy: () => void;
private _unsubscribe: () => void;
constructor(
onFilesAdded: FileStatesCallback,
onFileRemoved: (fileId: string) => void,
onDestroy: () => void
) {
this._onFilesAdded = onFilesAdded;
this._onFileRemoved = onFileRemoved;
this._onDestroy = onDestroy;
this._unsubscribe = engine.files.subscribe((files) => {
this._fileIds.forEach((fileId) => {
if (!files.has(fileId)) {
this._onFileRemoved(fileId);
this._fileIds.delete(fileId);
}
});
const newFiles = new Map<string, Readable<FileState>>();
files.forEach((store, fileId) => {
if (!this._fileIds.has(fileId)) {
newFiles.set(fileId, store);
this._fileIds.add(fileId);
}
});
if (newFiles.size > 0) {
this._onFilesAdded(newFiles);
}
});
}
destroy() {
this._onDestroy();
this._unsubscribe();
}
}
+179
View File
@@ -0,0 +1,179 @@
import { get, writable, type Readable } from 'svelte/store';
import { categoryIntervals, type CategoryIntervals } from '$lib/trackpoint-categories';
import type { Wasm } from './convert';
import {
EMPTY_SELECTION_STATISTICS,
EMPTY_STATISTICS,
type SelectionStatistics,
type StatisticsMetric,
type StatisticsRequest,
} from './types';
/**
* The statistics of the selection, read from the buffers of the engine. Only the metrics that a
* consumer asked for are read (see `request`).
*/
export class StatisticsReader {
private _store = writable<SelectionStatistics>(EMPTY_SELECTION_STATISTICS);
readonly store: Readable<SelectionStatistics> = { subscribe: this._store.subscribe };
/** Identifies the statistics currently in the engine's buffers. */
private _version = 0;
/** What each consumer of the statistics asked for. */
private _requests = new Set<Set<StatisticsMetric>>();
/** The metrics that were read for the current statistics. */
private _loaded = new Set<StatisticsMetric>();
/** @param wasm The engine, `null` before it is loaded. */
constructor(private wasm: () => Wasm | null) {}
/** Reads the statistics of the selection again, after it or the files changed. */
refresh(wasm: Wasm) {
this._store.set(this.read(wasm));
}
/**
* Reads the statistics buffers of the engine (the getters copy them), but only the metrics
* that were requested.
*/
private read(wasm: Wasm): SelectionStatistics {
const version = ++this._version;
this._loaded.clear();
const totalDistance = wasm.total_distance();
const statistics: SelectionStatistics = {
global: wasm.selection_statistics() ?? EMPTY_STATISTICS,
length: totalDistance.length,
totalDistance,
slope: wasm.slope(),
lng: wasm.lng(),
lat: wasm.lat(),
ele: wasm.ele(),
timestamps: wasm.timestamps(),
slice: (start, end) =>
version === this._version ? wasm.slice_statistics(start, end) : undefined,
};
this.readMetrics(wasm, statistics, this.requestedMetrics());
return statistics;
}
private requestedMetrics(): Set<StatisticsMetric> {
const metrics = new Set<StatisticsMetric>();
this._requests.forEach((request) => request.forEach((m) => metrics.add(m)));
return metrics;
}
/** Reads the metrics that were not read yet into `statistics`, which is the current one. */
private readMetrics(
wasm: Wasm,
statistics: SelectionStatistics,
metrics: Set<StatisticsMetric>
) {
const categories = (
starts: Uint32Array,
values: Uint8Array,
names: () => string[]
): CategoryIntervals => ({
starts,
values,
names: names(),
});
for (const metric of metrics) {
if (this._loaded.has(metric)) {
continue;
}
this._loaded.add(metric);
switch (metric) {
case 'speed':
statistics.speed = wasm.speed();
break;
case 'hr':
statistics.hr = wasm.hr();
break;
case 'cad':
statistics.cad = wasm.cad();
break;
case 'atemp':
statistics.atemp = wasm.atemp();
break;
case 'power':
statistics.power = wasm.power();
break;
case 'slopeSegment':
statistics.slopeSegmentSlope = wasm.slope_segment_slope();
statistics.slopeSegmentDistance = wasm.slope_segment_distance();
break;
case 'surface':
statistics.surface = categories(
wasm.surface_starts(),
wasm.surface_values(),
wasm.surfaces
);
break;
case 'anchors':
statistics.anchors = {
indices: wasm.anchor_indices(),
zooms: wasm.anchor_zooms(),
segmentStarts: wasm.segment_starts(),
segmentIds: wasm.segment_ids(),
revision: wasm.routing_revision(),
};
break;
case 'highway':
statistics.highway = categories(
wasm.highway_starts(),
wasm.highway_values(),
wasm.highways
);
statistics.sacScale = categories(
wasm.sac_scale_starts(),
wasm.sac_scale_values(),
wasm.sac_scales
);
statistics.mtbScale = categories(
wasm.mtb_scale_starts(),
wasm.mtb_scale_values(),
wasm.mtb_scales
);
break;
}
}
}
/**
* 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
* added to `statistics`: the ones that are missing are read right away, and then after each
* change of the selection or of the files, and the store is updated. Call `release` when the
* consumer goes away.
*/
request(): StatisticsRequest {
const wanted = new Set<StatisticsMetric>();
this._requests.add(wanted);
return {
set: (metrics) => {
wanted.clear();
for (const metric of metrics) {
wanted.add(metric);
}
this.loadMissing();
},
release: () => {
this._requests.delete(wanted);
},
};
}
/** Reads the requested metrics that are not in the current statistics yet. */
private loadMissing() {
const wasm = this.wasm();
const metrics = this.requestedMetrics();
if (!wasm || [...metrics].every((metric) => this._loaded.has(metric))) {
return;
}
// the buffers of the engine still are the ones of the current statistics, as they are
// read after every action
const statistics = { ...get(this._store) };
this.readMetrics(wasm, statistics, metrics);
this._store.set(statistics);
}
}
+88
View File
@@ -0,0 +1,88 @@
import { LEGACY_IMPORTED_KEY, readLegacyData } from '$lib/legacy-import';
import { parseVisibility, VISIBILITY_KEY, type Visibility } from '$lib/file-visibility';
import type { FilesUpdate, StorageOpened } from 'gpx-rs';
import type { Wasm } from './convert';
/** Name of the IndexedDB database that keeps the files and the settings. */
const STORAGE_NAME = 'gpx-studio';
/** What opening the storage needs of the engine facade. */
export type StorageHost = {
wasm: Wasm;
/** The explicit visibility per file, to fill with the one that was saved. */
visibility: Map<string, Visibility>;
/** Applies what a call changed to what the facade keeps of the files. */
sync(update: FilesUpdate): void;
hasFiles(): boolean;
loadFiles(files: { data: Uint8Array; name: string }[]): Promise<boolean>;
deselect(): Promise<boolean>;
};
/**
* Opens the storage of the browser: the files that it holds are put in the engine, and the
* changes of the files are kept from then on. Resolves to the settings, as JSON strings by key.
*
* 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>> {
const { wasm } = host;
let opened: StorageOpened;
try {
opened = await wasm.open_storage(STORAGE_NAME);
} catch (error) {
console.error('The files cannot be kept in this browser', error);
return {};
}
if (opened.readOnly) {
console.warn(
'The files were saved by a newer version of the app: they are not saved again by this one'
);
}
if (opened.unreadable > 0) {
console.warn(`${opened.unreadable} stored file(s) could not be read`);
}
let settings = opened.settings;
// what was hidden, which the files that were restored need when they are read
parseVisibility(settings[VISIBILITY_KEY]).forEach((visibility, fileId) =>
host.visibility.set(fileId, visibility)
);
host.sync(opened.outcome);
if (settings[LEGACY_IMPORTED_KEY] === undefined && !host.hasFiles()) {
settings = { ...settings, ...(await importLegacyData(host)) };
await wasm.flush_storage();
}
return settings;
}
/** Puts the files and the settings of the former database in the engine. */
async function importLegacyData(host: StorageHost): Promise<Record<string, string>> {
const { wasm } = host;
const imported: Record<string, string> = {};
try {
const legacy = await readLegacyData();
if (legacy) {
const encoder = new TextEncoder();
if (legacy.files.length > 0) {
await host.loadFiles(
legacy.files.map((file) => ({
data: encoder.encode(file.gpx),
name: file.name,
}))
);
await host.deselect();
}
for (const [key, value] of Object.entries(legacy.settings)) {
wasm.set_setting(key, value);
imported[key] = value;
}
}
} catch (error) {
// it is tried again next time
console.error('The files of the previous version could not be imported', error);
return imported;
}
wasm.set_setting(LEGACY_IMPORTED_KEY, 'true');
imported[LEGACY_IMPORTED_KEY] = 'true';
return imported;
}
+247
View File
@@ -0,0 +1,247 @@
import type { Readable } from 'svelte/store';
import type { Feature, FeatureCollection, LineString, Point } from 'geojson';
import type { Visibility } from '$lib/file-visibility';
import type { CategoryIntervals } from '$lib/trackpoint-categories';
import type { FileStructure, GlobalStatistics } from 'gpx-rs';
export type {
Clipboard,
WaypointDetails,
TrackpointDetails,
MoveTarget,
GlobalStatistics,
FileStructure,
FilesUpdate,
Outcome,
Selection,
TrackNode,
SegmentNode,
WaypointNode,
} from 'gpx-rs';
/**
* How a merge puts the selected elements together: `connect` makes a single segment of their
* trackpoints, `group` keeps them as they are, in the first one.
*/
export type MergeType = 'connect' | 'group';
/** What a split cuts in two: the file, the track or the segment of the trackpoint. */
export type SplitType = 'files' | 'tracks' | 'segments';
/**
* How elements combine with the current selection: they `replace` it, are `add`ed to it, or are
* `toggle`d (the ones already selected are removed). Adding and toggling only merge with elements
* of the same kind in the same place (same file, same track for segments), otherwise the new
* elements replace the selection.
*/
export type SelectMode = 'replace' | 'add' | 'toggle';
export type SegmentProperties = {
fileId: string;
trackId: string;
segmentId: string;
trackIndex: number;
segmentIndex: number;
/** Changes when the trackpoints of the segment change. */
rev: string;
/** Color of the track, or else the base color of the file. */
color: string;
/** Only when the track defines it. */
opacity?: number;
width?: number;
};
export type WaypointProperties = {
fileId: string;
waypointId: string;
index: number;
name?: string;
sym?: string;
};
/** The data of a waypoint to create or to change. Empty strings are fields it does not have. */
export type NewWaypoint = {
lng: number;
lat: number;
ele: number;
name: string;
desc: string;
icon: string;
link: string;
};
/** Everything the UI knows about one file. */
export type FileState = {
structure: FileStructure;
/** Color of the file: the one defined by its tracks if any, otherwise one from the palette. */
color: string;
/** Global statistics of the file. */
statistics: GlobalStatistics;
/** One LineString feature per track segment, in file order. */
segments: FeatureCollection<LineString, SegmentProperties>;
/** One Point feature per waypoint, in file order. */
waypoints: FeatureCollection<Point, WaypointProperties>;
/**
* Visibility the user set explicitly (see `Visibility` and `isHidden`, `isSegmentHidden`,
* `isWaypointHidden` in `file-visibility`). Hidden elements are still part of the statistics.
*/
visibility: Visibility;
};
export const EMPTY_STATISTICS: GlobalStatistics = {
totalDistance: 0,
elevationGain: 0,
elevationLoss: 0,
};
/**
* New trackpoints for `Engine.route`, as arrays with an entry per trackpoint. The OSM attributes
* are the names of the values (`undefined` when unknown), they are left unknown if missing.
*/
export type RoutePoints = {
lng: ArrayLike<number>;
lat: ArrayLike<number>;
ele: ArrayLike<number>;
surface?: readonly (string | undefined)[];
highway?: readonly (string | undefined)[];
sacScale?: readonly (string | undefined)[];
mtbScale?: readonly (string | undefined)[];
};
/** Which data of the trackpoints a file is exported with (see `Engine.exportFile`). */
export type ExportOptions = {
time: boolean;
hr: boolean;
cad: boolean;
atemp: boolean;
power: boolean;
/** The OpenStreetMap data: surface, highway, SAC scale and MTB scale. */
osm: boolean;
};
export const ALL_EXPORT_OPTIONS: ExportOptions = {
time: true,
hr: true,
cad: true,
atemp: true,
power: true,
osm: true,
};
/**
* Timestamp of the trackpoints that have none, in `SelectionStatistics.timestamps` (the smallest
* 64-bit integer, like `NO_TIME` in the engine).
*/
export const NO_TIME = -(2n ** 63n);
/**
* Statistics of the selection that are only read from the engine when something asks for them
* (see `Engine.requestStatistics`). A metric can bring several arrays.
*/
export type StatisticsMetric =
| 'speed'
| 'hr'
| 'cad'
| 'atemp'
| 'power'
/** `slopeSegmentSlope` and `slopeSegmentDistance`. */
| 'slopeSegment'
| 'surface'
/** `highway`, `sacScale` and `mtbScale`. */
| 'highway'
/** `anchors`. */
| 'anchors';
/**
* The anchors of the routing tool among the trackpoints of the selection: the points that can be
* dragged to reroute a segment, which are few of them. The arrays are as long as the number of
* anchors, in the order of the trackpoints.
*/
export type SelectionAnchors = {
/** Index of the anchor in the trackpoints of the selection. */
indices: Uint32Array;
/** Lowest map zoom level at which the anchor is shown (0 for the ends of the segments). */
zooms: Uint8Array;
/**
* Index of the first trackpoint of each segment of the selection that has some: anchors that
* are between two consecutive starts belong to the same segment.
*/
segmentStarts: Uint32Array;
/** Id of each of these segments. */
segmentIds: string[];
/**
* Changes when the selected segments, or their trackpoints, change. The indices in the
* selection (of trackpoints, of anchors) only mean something for one revision, which commands
* that use them have to give.
*/
revision: number;
};
/**
* The statistics of the selection: its global statistics, and the values at each of its
* trackpoints (all the selected segments, one after the other). The arrays are copies.
*
* Distances are cumulative over the whole selection. Distances are in km, speeds in km/h, times in
* ms (timestamps since the epoch, as 64-bit integers), elevations in m and slopes in %.
*
* The values of the trackpoints that the selection does not have at all (for example the heart
* rates, when no trackpoint has any) are `undefined`, and so are the ones that were not requested
* (see `StatisticsMetric`). Otherwise they have one entry per trackpoint: missing timestamps are
* `NO_TIME`, missing measures are NaN.
*/
export type SelectionStatistics = {
global: GlobalStatistics;
/** Number of trackpoints. */
length: number;
totalDistance: Float64Array;
/** Slope at the trackpoint. */
slope: Float64Array;
lng: Float64Array;
lat: Float64Array;
ele: Float64Array;
timestamps?: BigInt64Array;
// the following need to be requested
speed?: Float64Array;
hr?: Float64Array;
cad?: Float64Array;
atemp?: Float64Array;
power?: Float64Array;
/** Slope and length (km) of the smoothed elevation segment the trackpoint belongs to. */
slopeSegmentSlope?: Float64Array;
slopeSegmentDistance?: Float64Array;
surface?: CategoryIntervals;
highway?: CategoryIntervals;
/** SAC hiking scale. */
sacScale?: CategoryIntervals;
/** Mountain biking scale. */
mtbScale?: CategoryIntervals;
anchors?: SelectionAnchors;
/**
* Global statistics of the trackpoints from `start` to `end` (both included), for example
* the part of the elevation profile that was dragged over. `undefined` if the range is not
* in the selection, or if the selection is not the current one anymore.
*/
slice(start: number, end: number): GlobalStatistics | undefined;
};
export const EMPTY_SELECTION_STATISTICS: SelectionStatistics = {
global: EMPTY_STATISTICS,
length: 0,
totalDistance: new Float64Array(),
slope: new Float64Array(),
lng: new Float64Array(),
lat: new Float64Array(),
ele: new Float64Array(),
slice: () => undefined,
};
export type StatisticsRequest = {
/** Replaces the metrics needed by this consumer. */
set(metrics: Iterable<StatisticsMetric>): void;
/** The consumer does not need any metric anymore. */
release(): void;
};
export type FileStatesCallback = (files: Map<string, Readable<FileState>>) => void;
+63
View File
@@ -0,0 +1,63 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { FileColorAllocator, normalizeColor } from './file-colors';
describe('normalizeColor', () => {
it('adds the # of hex colors that have none', () => {
assert.equal(normalizeColor('ff0000'), '#ff0000');
assert.equal(normalizeColor('FF00aa'), '#FF00aa');
assert.equal(normalizeColor('#ff0000'), '#ff0000');
assert.equal(normalizeColor('red'), 'red');
assert.equal(normalizeColor('fff'), 'fff');
assert.equal(normalizeColor('hsl(0, 70%, 50%)'), 'hsl(0, 70%, 50%)');
});
});
describe('FileColorAllocator', () => {
it('uses the color the tracks define', () => {
const colors = new FileColorAllocator();
assert.equal(colors.resolve('a', [undefined, 'ff9900', '0000ff']), '#ff9900');
assert.equal(colors.resolve('b', ['#123456']), '#123456');
});
it('gives the files different palette colors while some are unused', () => {
const colors = new FileColorAllocator();
const given = Array.from({ length: 11 }, (_, i) => colors.resolve(`file-${i}`, []));
assert.equal(new Set(given).size, 11);
// then the least used ones again
const twelfth = colors.resolve('file-11', []);
assert.equal(twelfth, given[0]);
});
it('keeps the color of a file when its style comes and goes', () => {
const colors = new FileColorAllocator();
const palette = colors.resolve('a', []);
colors.resolve('b', []);
// a style is defined, then removed (edit, undo): the file gets its color back
assert.equal(colors.resolve('a', ['ff0000']), '#ff0000');
assert.equal(colors.resolve('a', []), palette);
assert.equal(colors.resolve('a', [undefined]), palette);
});
it('does not count the colors files define as used palette colors twice', () => {
const colors = new FileColorAllocator();
const first = colors.resolve('a', []);
// a file with the color of the palette uses it
colors.resolve('b', [first]);
const next = colors.resolve('c', []);
assert.notEqual(next, first);
});
it('frees the colors of the files that go away', () => {
const colors = new FileColorAllocator();
const a = colors.resolve('a', []);
const b = colors.resolve('b', []);
assert.notEqual(a, b);
colors.release('a');
// a color that nobody uses is picked first again
assert.equal(colors.resolve('c', []), a);
// and a file that comes back gets a color again
colors.release('b');
colors.resolve('b', []);
});
});
+31
View File
@@ -0,0 +1,31 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { distance } from './geo';
describe('distance', () => {
it('is zero between a point and itself', () => {
assert.equal(distance({ lng: 4, lat: 50 }, { lng: 4, lat: 50 }), 0);
});
it('measures a degree of latitude and longitude', () => {
const meridian = distance({ lng: 0, lat: 0 }, { lng: 0, lat: 1 });
assert.ok(Math.abs(meridian - 111195) < 10, `${meridian}`);
const equator = distance({ lng: 0, lat: 0 }, { lng: 1, lat: 0 });
assert.ok(Math.abs(equator - 111195) < 10, `${equator}`);
// degrees of longitude get shorter away from the equator
const north = distance({ lng: 0, lat: 60 }, { lng: 1, lat: 60 });
assert.ok(Math.abs(north - 111195 / 2) < 100, `${north}`);
});
it('is symmetric and handles the antipodes and the antimeridian', () => {
const a = { lng: 4.35, lat: 50.85 };
const b = { lng: -74, lat: 40.7 };
assert.equal(distance(a, b), distance(b, a));
assert.ok(Math.abs(distance(a, b) - 5900_000) < 100_000);
const half = Math.PI * 6371008.8;
assert.ok(Math.abs(distance({ lng: 0, lat: 0 }, { lng: 180, lat: 0 }) - half) < 1);
assert.ok(Math.abs(distance({ lng: 0, lat: 90 }, { lng: 0, lat: -90 }) - half) < 1);
const across = distance({ lng: 179.5, lat: 0 }, { lng: -179.5, lat: 0 });
assert.ok(Math.abs(across - 111195) < 10, `${across}`);
});
});
+3 -11
View File
@@ -1,19 +1,11 @@
import maplibregl from 'maplibre-gl';
export type Coordinates = {
lat: number;
lng: number;
};
const EARTH_RADIUS = 6371008.8;
/** The distance in meters between two coordinates (haversine formula). */
export function distance(a: Coordinates, b: Coordinates): number {
const rad = Math.PI / 180;
const lat1 = a.lat * rad;
const lat2 = b.lat * rad;
const dLat = lat2 - lat1;
const dLng = (b.lng - a.lng) * rad;
const h =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) * Math.sin(dLng / 2);
return EARTH_RADIUS * 2 * Math.asin(Math.sqrt(Math.min(h, 1)));
return new maplibregl.LngLat(a.lng, a.lat).distanceTo(new maplibregl.LngLat(b.lng, b.lat));
}
+79
View File
@@ -0,0 +1,79 @@
// The keys of the translations that the code asks for have to exist, which nothing else checks:
// a missing key shows as the key itself.
import assert from 'node:assert/strict';
import { readdirSync, readFileSync } from 'node:fs';
import { describe, it } from 'node:test';
import { fileURLToPath } from 'node:url';
import { join } from 'node:path';
import { languages } from './languages';
const src = fileURLToPath(new URL('..', import.meta.url));
function sources(dir: string): string[] {
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const path = join(dir, entry.name);
if (entry.isDirectory()) {
return ['locales', 'docs'].includes(entry.name) ? [] : sources(path);
}
return /\.(ts|svelte)$/.test(entry.name) && !entry.name.endsWith('.test.ts') ? [path] : [];
});
}
function has(dictionary: any, key: string): boolean {
let value = dictionary;
for (const part of key.split('.')) {
if (value === null || typeof value !== 'object' || !(part in value)) {
return false;
}
value = value[part];
}
return typeof value === 'string';
}
function keysOf(dictionary: any, prefix = ''): string[] {
return Object.entries(dictionary).flatMap(([key, value]) =>
typeof value === 'object' && value !== null
? keysOf(value, `${prefix}${key}.`)
: [`${prefix}${key}`]
);
}
const english = JSON.parse(readFileSync(join(src, 'locales/en.json'), 'utf-8'));
describe('translations', () => {
it('exist in english for every key that the code asks for', () => {
const missing: string[] = [];
let checked = 0;
for (const file of sources(src)) {
const code = readFileSync(file, 'utf-8');
for (const match of code.matchAll(/i18n\._\(\s*(['"])([a-z0-9_.-]+)\1\s*[,)]/gi)) {
checked++;
if (!has(english, match[2])) {
missing.push(`${match[2]} (${file.replace(src, '')})`);
}
}
}
// the pattern does find the keys
assert.ok(checked > 100, `${checked}`);
assert.deepEqual(missing, []);
});
it('have no empty text in english', () => {
const empty = keysOf(english).filter((key) => {
const value = key.split('.').reduce((v: any, part) => v[part], english);
return typeof value === 'string' && value.trim() === '';
});
assert.deepEqual(empty, []);
});
it('are valid JSON for every language of the site, with only keys that english has', () => {
const known = new Set(keysOf(english));
for (const language of Object.keys(languages)) {
const dictionary = JSON.parse(
readFileSync(join(src, `locales/${language}.json`), 'utf-8')
);
const unknown = keysOf(dictionary).filter((key) => !known.has(key));
assert.deepEqual(unknown, [], language);
}
});
});
+189
View File
@@ -0,0 +1,189 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { legacyFileToGPX, LEGACY_IMPORTED_KEY } from './legacy-import';
// A file as the former `gpx` library kept it.
const point = (lat: number, lon: number, extra: Record<string, unknown> = {}) => ({
attributes: { lat, lon },
...extra,
});
describe('legacyFileToGPX', () => {
it('writes a GPX file with its metadata, waypoints and tracks', () => {
const gpx = legacyFileToGPX({
metadata: {
name: 'Morning ride',
desc: 'Around the lake',
author: {
name: 'someone',
link: { attributes: { href: 'https://gpx.studio' }, text: 'site' },
},
link: { attributes: { href: 'https://example.com' } },
time: '2024-05-01T08:00:00.000Z',
},
wpt: [
point(50.1, 4.2, {
ele: 100,
name: 'Café',
sym: 'Restaurant',
link: { attributes: { href: 'https://cafe.example' }, text: 'menu' },
}),
],
trk: [
{
name: 'Track 1',
type: 'Cycling',
trkseg: [
{
trkpt: [
point(50, 4, { ele: 10, time: '2024-05-01T08:00:00.000Z' }),
point(50.001, 4.001, { ele: 12 }),
],
},
{ trkpt: [point(51, 5)] },
],
},
],
});
assert.ok(gpx.startsWith('<?xml version="1.0" encoding="UTF-8"?>'));
assert.match(
gpx,
/<gpx version="1.1" creator="https:\/\/gpx.studio" xmlns="http:\/\/www.topografix.com\/GPX\/1\/1"/
);
assert.match(gpx, /<metadata><name>Morning ride<\/name><desc>Around the lake<\/desc>/);
assert.match(
gpx,
/<author><name>someone<\/name><link href="https:\/\/gpx.studio"><text>site<\/text><\/link><\/author>/
);
assert.match(
gpx,
/<link href="https:\/\/example.com"><\/link><time>2024-05-01T08:00:00.000Z<\/time><\/metadata>/
);
assert.match(
gpx,
/<wpt lat="50.1" lon="4.2"><ele>100<\/ele><name>Café<\/name><link href="https:\/\/cafe.example"><text>menu<\/text><\/link><sym>Restaurant<\/sym><\/wpt>/
);
assert.match(gpx, /<trk><name>Track 1<\/name><type>Cycling<\/type><trkseg>/);
assert.match(
gpx,
/<trkpt lat="50" lon="4"><ele>10<\/ele><time>2024-05-01T08:00:00.000Z<\/time><\/trkpt>/
);
assert.equal((gpx.match(/<trkseg>/g) ?? []).length, 2);
assert.equal((gpx.match(/<trkpt /g) ?? []).length, 3);
});
it('writes an empty file for an empty object', () => {
const gpx = legacyFileToGPX({});
assert.ok(gpx.includes('<metadata></metadata></gpx>'));
assert.ok(!gpx.includes('<trk>') && !gpx.includes('<wpt'));
});
it('escapes the text and the attributes', () => {
const gpx = legacyFileToGPX({
metadata: { name: 'Tom & Jerry <3', desc: 'a > b' },
wpt: [
point(1, 2, {
name: '"quoted" & <tagged>',
link: { attributes: { href: 'https://example.com/?a="1"&b=2' } },
}),
],
});
assert.ok(gpx.includes('<name>Tom &amp; Jerry &lt;3</name>'));
assert.ok(gpx.includes('<desc>a &gt; b</desc>'));
assert.ok(gpx.includes('<name>"quoted" &amp; &lt;tagged&gt;</name>'));
assert.ok(gpx.includes('href="https://example.com/?a=&quot;1&quot;&amp;b=2"'));
});
it('leaves out what is missing or empty', () => {
const gpx = legacyFileToGPX({
metadata: { name: '', desc: null, time: 'not a date' },
wpt: [point(1, 2, { name: '', ele: undefined, link: { attributes: {} } })],
trk: [{ trkseg: [{ trkpt: [point(1, 2, { time: 'garbage' })] }] }],
});
assert.ok(!gpx.includes('<name>') && !gpx.includes('<desc>'));
assert.ok(!gpx.includes('<time>'));
assert.ok(!gpx.includes('<link'));
assert.ok(gpx.includes('<wpt lat="1" lon="2"></wpt>'));
assert.ok(gpx.includes('<trkpt lat="1" lon="2"></trkpt>'));
// a track with a segment that has no points is still a segment
assert.ok(legacyFileToGPX({ trk: [{ trkseg: [{}] }] }).includes('<trkseg></trkseg>'));
});
it('writes the style of the tracks', () => {
const gpx = legacyFileToGPX({
trk: [
{
extensions: {
'gpx_style:line': {
'gpx_style:color': 'ff0000',
'gpx_style:opacity': 0.5,
'gpx_style:width': 6,
},
},
trkseg: [],
},
],
});
assert.ok(
gpx.includes(
'<extensions><gpx_style:line><gpx_style:color>ff0000</gpx_style:color><gpx_style:opacity>0.5</gpx_style:opacity><gpx_style:width>6</gpx_style:width></gpx_style:line></extensions>'
)
);
});
it('writes the extensions of the trackpoints', () => {
const gpx = legacyFileToGPX({
trk: [
{
trkseg: [
{
trkpt: [
point(1, 2, {
extensions: {
'gpxtpx:TrackPointExtension': {
'gpxtpx:atemp': 17.5,
'gpxtpx:hr': 142,
'gpxtpx:cad': 85,
'gpxtpx:Extensions': {
surface: 'asphalt',
highway: 'cycleway',
},
},
'gpxpx:PowerExtension': { 'gpxpx:PowerInWatts': 230 },
},
}),
point(3, 4, {
extensions: {
'gpxtpx:TrackPointExtension': { 'gpxtpx:hr': 100 },
},
}),
point(5, 6, {
extensions: {
'gpxpx:PowerExtension': { 'gpxpx:PowerInWatts': 0 },
},
}),
],
},
],
},
],
});
assert.ok(
gpx.includes(
'<extensions><gpxtpx:TrackPointExtension><gpxtpx:atemp>17.5</gpxtpx:atemp><gpxtpx:hr>142</gpxtpx:hr><gpxtpx:cad>85</gpxtpx:cad><gpxtpx:Extensions><surface>asphalt</surface><highway>cycleway</highway></gpxtpx:Extensions></gpxtpx:TrackPointExtension><gpxpx:PowerExtension><gpxpx:PowerInWatts>230</gpxpx:PowerInWatts></gpxpx:PowerExtension></extensions>'
)
);
assert.ok(
gpx.includes(
'<extensions><gpxtpx:TrackPointExtension><gpxtpx:hr>100</gpxtpx:hr></gpxtpx:TrackPointExtension></extensions>'
)
);
// a power of 0 W is a value
assert.ok(gpx.includes('<gpxpx:PowerInWatts>0</gpxpx:PowerInWatts>'));
});
it('knows the setting that tells the import is done', () => {
assert.equal(LEGACY_IMPORTED_KEY, 'legacyImported');
});
});
+220
View File
@@ -0,0 +1,220 @@
import assert from 'node:assert/strict';
import { afterEach, beforeEach, describe, it } from 'node:test';
import { get } from 'svelte/store';
import type { LayerTreeType } from '$lib/assets/layers';
import {
filterLayerTree,
getArrayValidator,
getLayerTreeValidator,
getLayerValidator,
getValueValidator,
Setting,
SettingInitOnFirstRead,
storedValue,
useSettingsStorage,
} from './setting';
/** What the settings were told to keep. */
let saved: Record<string, string | undefined>;
beforeEach(() => {
saved = {};
useSettingsStorage({
save: (key, json) => {
saved[key] = json;
},
remove: (key) => {
saved[key] = undefined;
},
});
});
afterEach(() => useSettingsStorage(undefined));
describe('reading saved values', () => {
it('reads JSON, and nothing when it cannot', () => {
assert.deepEqual(storedValue({ a: '3' }, 'a'), { value: 3 });
assert.deepEqual(storedValue({ a: '"text"' }, 'a'), { value: 'text' });
assert.deepEqual(storedValue({ a: 'null' }, 'a'), { value: null });
assert.equal(storedValue({ a: '{broken' }, 'a'), undefined);
assert.equal(storedValue({}, 'a'), undefined);
// a key of Object.prototype is not a saved key
assert.equal(storedValue({}, 'toString'), undefined);
});
});
describe('validators', () => {
it('keeps an allowed value, else the fallback', () => {
const validate = getValueValidator(['metric', 'imperial'], 'metric');
assert.equal(validate('imperial'), 'imperial');
assert.equal(validate('nautical'), 'metric');
assert.equal(validate(undefined as any), 'metric');
const optional = getValueValidator<string | undefined>(['slope', undefined], undefined);
assert.equal(optional('slope'), 'slope');
assert.equal(optional('other'), undefined);
});
it('keeps the allowed values of a list', () => {
const validate = getArrayValidator(['hr', 'cad', 'power']);
assert.deepEqual(validate(['power', 'speed', 'hr', 'hr']), ['power', 'hr', 'hr']);
assert.deepEqual(validate([]), []);
});
it('accepts the known layers, and the custom and extension ones', () => {
const validate = getLayerValidator({ libertyTopo: 'url', osm: 'url' }, 'libertyTopo');
assert.equal(validate('osm'), 'osm');
assert.equal(validate('custom-1234'), 'custom-1234');
assert.equal(validate('extension-some-id'), 'extension-some-id');
assert.equal(validate('gone'), 'libertyTopo');
assert.equal(validate('toString'), 'libertyTopo');
});
});
describe('layer trees', () => {
const allowed: LayerTreeType = {
world: { a: true, b: false },
countries: { france: { c: false } },
};
it('keeps the choices made on the layers that are allowed', () => {
const filtered = filterLayerTree({ world: { a: false, b: true } }, allowed);
assert.deepEqual(filtered.world as LayerTreeType, { a: false, b: true });
});
it('adds the layers that are new, with their default', () => {
const filtered = filterLayerTree({ world: { a: false } }, allowed);
assert.deepEqual(filtered, {
world: { a: false, b: false },
countries: { france: { c: false } },
});
assert.deepEqual(filterLayerTree({}, allowed), allowed);
});
it('drops the layers that are not allowed anymore, but not the custom ones', () => {
const filtered = filterLayerTree(
{
world: { a: true, gone: true, 'custom-1': true, 'extension-x': false },
old: { x: true },
},
allowed
);
assert.deepEqual(filtered.world, {
a: true,
b: false,
'custom-1': true,
'extension-x': false,
});
assert.equal('old' in filtered, true);
assert.deepEqual(filtered.old, {});
});
it('does not take a branch for a layer, or a layer for a branch', () => {
const filtered = filterLayerTree({ world: true, countries: { france: false } }, allowed);
assert.deepEqual(filtered.world, { a: true, b: false });
assert.deepEqual((filtered.countries as LayerTreeType).france, { c: false });
assert.deepEqual(getLayerTreeValidator(allowed)({ world: { a: false } }).world, {
a: false,
b: false,
});
});
});
describe('Setting', () => {
it('has its initial value until it is connected', () => {
const setting = new Setting('key', 'initial');
assert.equal(get(setting), 'initial');
setting.connect({});
assert.equal(get(setting), 'initial');
setting.connect({ other: '"x"' });
assert.equal(get(setting), 'initial');
});
it('takes the saved value, validated', () => {
const setting = new Setting(
'key',
'metric',
getValueValidator(['metric', 'imperial'], 'metric')
);
setting.connect({ key: '"imperial"' });
assert.equal(get(setting), 'imperial');
setting.connect({ key: '"furlongs"' });
assert.equal(get(setting), 'metric');
setting.connect({ key: '{broken' });
assert.equal(get(setting), 'metric');
// connecting does not save anything
assert.deepEqual(saved, {});
});
it('saves what it is set to, when it changes', () => {
const setting = new Setting('key', 1);
setting.set(2);
assert.equal(get(setting), 2);
assert.deepEqual(saved, { key: '2' });
saved = {};
setting.set(2);
assert.deepEqual(saved, {});
setting.update((value) => value + 1);
assert.equal(get(setting), 3);
assert.deepEqual(saved, { key: '3' });
});
it('always saves objects, which may have been changed in place', () => {
const value = { a: 1 };
const setting = new Setting('key', value);
setting.set(value);
assert.deepEqual(saved, { key: '{"a":1}' });
});
it('forgets what it saved when it has no value', () => {
const setting = new Setting<string | undefined>('key', 'x');
setting.set(undefined);
assert.equal(get(setting), undefined);
assert.ok('key' in saved);
assert.equal(saved.key, undefined);
});
it('tells its subscribers', () => {
const setting = new Setting('key', 1);
const seen: number[] = [];
const unsubscribe = setting.subscribe((value) => seen.push(value));
setting.set(2);
setting.set(2);
unsubscribe();
setting.set(3);
assert.deepEqual(seen, [1, 2]);
});
});
describe('SettingInitOnFirstRead', () => {
it('has no value until it is connected', () => {
const setting = new SettingInitOnFirstRead('key', { a: true });
assert.equal(get(setting), undefined);
setting.connect({});
assert.deepEqual(get(setting), { a: true });
});
it('takes the saved value, validated, instead of the initial one', () => {
const setting = new SettingInitOnFirstRead('key', ['hr'], getArrayValidator(['hr', 'cad']));
setting.connect({ key: '["cad","nope"]' });
assert.deepEqual(get(setting), ['cad']);
// nothing is saved when it is connected, that is what `initialize` is for
assert.deepEqual(saved, {});
});
it('can be initialized, which saves the initial value', () => {
const setting = new SettingInitOnFirstRead('key', 5);
setting.initialize();
assert.equal(get(setting), 5);
assert.deepEqual(saved, { key: '5' });
});
});
describe('Setting without storage', () => {
it('changes, but keeps nothing', () => {
useSettingsStorage(undefined);
const setting = new Setting('key', 1);
setting.set(2);
assert.equal(get(setting), 2);
assert.deepEqual(saved, {});
});
});
+173
View File
@@ -0,0 +1,173 @@
import { get, writable, type Writable } from 'svelte/store';
import type { LayerTreeType } from '$lib/assets/layers';
/** What the settings were saved as, by key: JSON strings (see `Engine.openStorage`). */
export type StoredSettings = Record<string, string>;
/** Where the settings are kept. */
export type SettingsStorage = {
/** Keeps the JSON of a setting. */
save(key: string, json: string): void;
/** Forgets a setting. */
remove(key: string): void;
};
let storage: SettingsStorage | undefined;
/** Sets where the settings are kept: until then, changes are not saved. */
export function useSettingsStorage(newStorage: SettingsStorage | undefined) {
storage = newStorage;
}
/** Reads the saved value of a setting, `undefined` if there is none or if it cannot be read. */
export function storedValue(stored: StoredSettings, key: string): { value: any } | undefined {
if (!Object.hasOwn(stored, key)) {
return undefined;
}
try {
return { value: JSON.parse(stored[key]) };
} catch {
return undefined;
}
}
/** Keeps a value; `undefined` is not something JSON can hold, it means no value. */
function save(key: string, value: unknown) {
const json = JSON.stringify(value);
if (json === undefined) {
storage?.remove(key);
} else {
storage?.save(key, json);
}
}
/**
* A setting: a store whose value is kept when it changes. `S` is what the store holds, which is
* `V` or, for a setting that has no value until it is read, `V | undefined`.
*/
abstract class SettingBase<V, S extends V | undefined> {
protected _key: string;
protected _value: Writable<S>;
protected _validator?: (value: V) => V;
protected constructor(key: string, initial: S, validator?: (value: V) => V) {
this._key = key;
this._value = writable(initial);
this._validator = validator;
}
/** The saved value, validated, `undefined` if there is none. */
protected saved(stored: StoredSettings): { value: V } | undefined {
const saved = storedValue(stored, this._key);
return saved && { value: this._validator ? this._validator(saved.value) : saved.value };
}
/** Takes the saved value, if there is one. */
abstract connect(stored: StoredSettings): void;
subscribe(run: (value: S) => void, invalidate?: (value?: S) => void) {
return this._value.subscribe(run, invalidate);
}
/** Sets the value and keeps it. An object is always kept: it may have been changed in place. */
set(value: V) {
if (typeof value === 'object' || value !== get(this._value)) {
this._value.set(value as S);
save(this._key, value);
}
}
update(callback: (value: any) => any) {
this.set(callback(get(this._value)));
}
}
export class Setting<V> extends SettingBase<V, V> {
constructor(key: string, initial: V, validator?: (value: V) => V) {
super(key, initial, validator);
}
connect(stored: StoredSettings) {
const saved = this.saved(stored);
if (saved) {
this._value.set(saved.value);
}
}
}
/** A setting that has no value until it is connected: then the saved one, or the initial one. */
export class SettingInitOnFirstRead<V> extends SettingBase<V, V | undefined> {
private _initial: V;
constructor(key: string, initial: V, validator?: (value: V) => V) {
super(key, undefined, validator);
this._initial = initial;
}
connect(stored: StoredSettings) {
this._value.set(this.saved(stored)?.value ?? this._initial);
}
/** Takes the initial value and keeps it, for a setting that was never saved. */
initialize() {
this.set(this._initial);
}
}
export function getValueValidator<V>(allowed: V[], fallback: V) {
const dict = new Set<V>(allowed);
return (value: V) => (dict.has(value) ? value : fallback);
}
export function getArrayValidator<V>(allowed: V[]) {
const dict = new Set<V>(allowed);
return (value: V[]) => value.filter((v) => dict.has(v));
}
export function getLayerValidator(allowed: Record<string, any>, fallback: string) {
return (layer: string) =>
allowed.hasOwnProperty(layer) ||
layer.startsWith('custom-') ||
layer.startsWith('extension-')
? layer
: fallback;
}
export function filterLayerTree(
t: LayerTreeType,
allowed: LayerTreeType | undefined
): LayerTreeType {
const filtered: LayerTreeType = {};
if (allowed) {
Object.entries(allowed).forEach(([key, value]) => {
if (Object.hasOwn(t, key)) {
if (typeof value === 'boolean') {
filtered[key] = t[key];
} else if (typeof value === 'object') {
filtered[key] = filterLayerTree(
typeof t[key] === 'object' ? t[key] : {},
value
);
}
} else {
filtered[key] = value;
}
});
}
Object.entries(t).forEach(([key, value]) => {
if (!Object.hasOwn(filtered, key)) {
if (typeof value === 'boolean') {
if (key.startsWith('custom-') || key.startsWith('extension-')) {
filtered[key] = value;
}
} else if (typeof value === 'object') {
filtered[key] = filterLayerTree(value, undefined);
}
}
});
return filtered;
}
export function getLayerTreeValidator(allowed: LayerTreeType) {
return (value: LayerTreeType) => filterLayerTree(value, allowed);
}
+75
View File
@@ -0,0 +1,75 @@
import assert from 'node:assert/strict';
import { afterEach, beforeEach, describe, it } from 'node:test';
import { get } from 'svelte/store';
import { engine } from '$lib/engine';
import { settings } from './settings';
/** What the engine was told to keep. */
let saved: Record<string, string | undefined>;
/** What was kept, seen without what the assertions made of its type. */
const kept = (): Record<string, string | undefined> => saved;
const original = { set: engine.setSetting, delete: engine.deleteSetting };
beforeEach(() => {
saved = {};
engine.setSetting = (key, json) => {
saved[key] = json;
};
engine.deleteSetting = (key) => {
saved[key] = undefined;
};
});
afterEach(() => {
engine.setSetting = original.set;
engine.deleteSetting = original.delete;
});
describe('settings', () => {
it('connects every setting', () => {
settings.connect({
distanceUnits: '"imperial"',
velocityUnits: '"pace"',
temperatureUnits: '"fahrenheit"',
routingProfile: '"foot"',
currentOverlays: '{"cyclOSM":true}',
additionalDatasets: '["hr","bogus"]',
});
assert.equal(get(settings.distanceUnits), 'imperial');
assert.equal(get(settings.velocityUnits), 'pace');
assert.equal(get(settings.temperatureUnits), 'fahrenheit');
assert.equal(get(settings.routingProfile), 'foot');
assert.deepEqual(get(settings.additionalDatasets), ['hr']);
assert.ok(get(settings.currentOverlays));
// values that are not valid anymore are replaced
settings.connect({ distanceUnits: '"cubits"', routingProfile: '"hovercraft"' });
assert.equal(get(settings.distanceUnits), 'metric');
assert.equal(get(settings.routingProfile), 'bike');
});
it('initializes the settings that have no value until they are read', () => {
settings.connect({});
saved = {};
settings.initialize();
assert.ok('currentOverlays' in saved);
assert.ok('currentOverpassQueries' in saved);
assert.equal(Object.keys(saved).length, 2);
});
it('are kept by the engine when they change', () => {
settings.connect({});
settings.distanceUnits.set('nautical');
assert.deepEqual(saved, { distanceUnits: '"nautical"' });
// a setting that has no value is forgotten
settings.elevationFill.set('slope');
assert.equal(kept().elevationFill, '"slope"');
settings.elevationFill.set(undefined);
assert.ok('elevationFill' in kept() && kept().elevationFill === undefined);
saved = {};
// the same value is not kept again
settings.distanceUnits.set('nautical');
assert.deepEqual(saved, {});
});
});
+18 -162
View File
@@ -13,170 +13,21 @@ import {
type LayerTreeType,
} from '$lib/assets/layers';
import { browser } from '$app/environment';
import type { DistanceUnits, TemperatureUnits, VelocityUnits } from '$lib/unit-conversions';
import { engine } from '$lib/engine';
import { get, writable, type Writable } from 'svelte/store';
import {
getArrayValidator,
getLayerTreeValidator,
getLayerValidator,
getValueValidator,
Setting,
SettingInitOnFirstRead,
useSettingsStorage,
type StoredSettings,
} from './setting';
/** What the settings were saved as, by key: JSON strings (see `Engine.openStorage`). */
export type StoredSettings = Record<string, string>;
export type { StoredSettings };
/** Reads the saved value of a setting, `undefined` if there is none or if it cannot be read. */
function storedValue(stored: StoredSettings, key: string): { value: any } | undefined {
if (!Object.hasOwn(stored, key)) {
return undefined;
}
try {
return { value: JSON.parse(stored[key]) };
} catch {
return undefined;
}
}
/** Keeps a value; `undefined` is not something JSON can hold, it means no value. */
function save(key: string, value: unknown) {
const json = JSON.stringify(value);
if (json === undefined) {
engine.deleteSetting(key);
} else {
engine.setSetting(key, json);
}
}
export class Setting<V> {
private _key: string;
private _value: Writable<V>;
private _validator?: (value: V) => V;
constructor(key: string, initial: V, validator?: (value: V) => V) {
this._key = key;
this._value = writable(initial);
this._validator = validator;
}
/** Takes the saved value, if there is one. */
connect(stored: StoredSettings) {
const saved = storedValue(stored, this._key);
if (saved) {
this._value.set(this._validator ? this._validator(saved.value) : saved.value);
}
}
subscribe(run: (value: V) => void, invalidate?: (value?: V) => void) {
return this._value.subscribe(run, invalidate);
}
set(value: V) {
if (typeof value === 'object' || value !== get(this._value)) {
this._value.set(value);
save(this._key, value);
}
}
update(callback: (value: any) => any) {
this.set(callback(get(this._value)));
}
}
/** A setting that has no value until it is connected: then the saved one, or the initial one. */
export class SettingInitOnFirstRead<V> {
private _key: string;
private _value: Writable<V | undefined>;
private _initial: V;
private _validator?: (value: V) => V;
constructor(key: string, initial: V, validator?: (value: V) => V) {
this._key = key;
this._value = writable(undefined);
this._initial = initial;
this._validator = validator;
}
connect(stored: StoredSettings) {
const saved = storedValue(stored, this._key);
if (saved) {
this._value.set(this._validator ? this._validator(saved.value) : saved.value);
} else {
this._value.set(this._initial);
}
}
initialize() {
this.set(this._initial);
}
subscribe(run: (value: V | undefined) => void, invalidate?: (value?: V | undefined) => void) {
return this._value.subscribe(run, invalidate);
}
set(value: V) {
if (typeof value === 'object' || value !== get(this._value)) {
this._value.set(value);
save(this._key, value);
}
}
update(callback: (value: any) => any) {
this.set(callback(get(this._value)));
}
}
function getValueValidator<V>(allowed: V[], fallback: V) {
const dict = new Set<V>(allowed);
return (value: V) => (dict.has(value) ? value : fallback);
}
function getArrayValidator<V>(allowed: V[]) {
const dict = new Set<V>(allowed);
return (value: V[]) => value.filter((v) => dict.has(v));
}
function getLayerValidator(allowed: Record<string, any>, fallback: string) {
return (layer: string) =>
allowed.hasOwnProperty(layer) ||
layer.startsWith('custom-') ||
layer.startsWith('extension-')
? layer
: fallback;
}
function filterLayerTree(t: LayerTreeType, allowed: LayerTreeType | undefined): LayerTreeType {
const filtered: LayerTreeType = {};
if (allowed) {
Object.entries(allowed).forEach(([key, value]) => {
if (Object.hasOwn(t, key)) {
if (typeof value === 'boolean') {
filtered[key] = t[key];
} else if (typeof value === 'object') {
filtered[key] = filterLayerTree(
typeof t[key] === 'object' ? t[key] : {},
value
);
}
} else {
filtered[key] = value;
}
});
}
Object.entries(t).forEach(([key, value]) => {
if (!Object.hasOwn(filtered, key)) {
if (typeof value === 'boolean') {
if (key.startsWith('custom-') || key.startsWith('extension-')) {
filtered[key] = value;
}
} else if (typeof value === 'object') {
filtered[key] = filterLayerTree(value, undefined);
}
}
});
return filtered;
}
function getLayerTreeValidator(allowed: LayerTreeType) {
return (value: LayerTreeType) => filterLayerTree(value, allowed);
}
type DistanceUnits = 'metric' | 'imperial' | 'nautical';
type VelocityUnits = 'speed' | 'pace';
type TemperatureUnits = 'celsius' | 'fahrenheit';
type AdditionalDataset = 'speed' | 'hr' | 'cad' | 'atemp' | 'power';
type ElevationFill = 'slope' | 'surface' | 'highway' | undefined;
type RoutingProfile =
@@ -191,6 +42,12 @@ type RoutingProfile =
type TerrainSource = 'mapterhorn';
type StreetViewSource = 'mapillary' | 'google';
// The settings are kept by the engine, in the storage of the browser.
useSettingsStorage({
save: (key, json) => engine.setSetting(key, json),
remove: (key) => engine.deleteSetting(key),
});
export const settings = {
distanceUnits: new Setting<DistanceUnits>(
'distanceUnits',
@@ -295,7 +152,6 @@ export const settings = {
'mapillary',
getValueValidator<StreetViewSource>(['mapillary', 'google'], 'mapillary')
),
fileOrder: new Setting<string[]>('fileOrder', []),
defaultOpacity: new Setting('defaultOpacity', 0.7),
defaultWidth: new Setting('defaultWidth', browser && window.innerWidth < 600 ? 8 : 5),
bottomPanelSize: new Setting('bottomPanelSize', 170),
+75
View File
@@ -0,0 +1,75 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
celsiusToFahrenheit,
convertDistance,
convertDistanceToKilometers,
convertElevation,
convertTemperature,
convertVelocity,
distancePerHourToSecondsPerDistance,
kilometersToMiles,
kilometersToNauticalMiles,
metersToFeet,
milesToKilometers,
nauticalMilesToKilometers,
secondsToHHMMSS,
} from './unit-conversions';
const close = (actual: number, expected: number, epsilon = 1e-9) =>
assert.ok(Math.abs(actual - expected) < epsilon, `${actual} is not ${expected}`);
describe('unit conversions', () => {
it('converts distances both ways', () => {
close(kilometersToMiles(10), 6.21371);
close(milesToKilometers(kilometersToMiles(42.195)), 42.195, 1e-3);
close(kilometersToNauticalMiles(1.852), 1.852 * 0.539957);
close(nauticalMilesToKilometers(kilometersToNauticalMiles(100)), 100, 0.01);
close(metersToFeet(100), 328.084);
close(celsiusToFahrenheit(0), 32);
close(celsiusToFahrenheit(100), 212);
close(celsiusToFahrenheit(-40), -40);
});
it('converts to the units that are asked for', () => {
assert.equal(convertDistance(5, 'metric'), 5);
close(convertDistance(5, 'imperial'), kilometersToMiles(5));
close(convertDistance(5, 'nautical'), kilometersToNauticalMiles(5));
assert.equal(convertDistanceToKilometers(5, 'metric'), 5);
close(convertDistanceToKilometers(5, 'imperial'), milesToKilometers(5));
close(convertDistanceToKilometers(5, 'nautical'), nauticalMilesToKilometers(5));
// elevations are in feet only with the imperial units
assert.equal(convertElevation(100, 'metric'), 100);
assert.equal(convertElevation(100, 'nautical'), 100);
close(convertElevation(100, 'imperial'), metersToFeet(100));
assert.equal(convertTemperature(20, 'celsius'), 20);
close(convertTemperature(20, 'fahrenheit'), 68);
});
it('converts speeds and paces', () => {
assert.equal(convertVelocity(12, 'speed', 'metric'), 12);
close(convertVelocity(12, 'speed', 'imperial'), kilometersToMiles(12));
// a pace is the time it takes to go one unit of distance
assert.equal(convertVelocity(12, 'pace', 'metric'), 300);
close(convertVelocity(12, 'pace', 'imperial'), 3600 / kilometersToMiles(12));
close(convertVelocity(12, 'pace', 'nautical'), 3600 / kilometersToNauticalMiles(12));
// no speed is no pace, not infinity
assert.equal(distancePerHourToSecondsPerDistance(0), 0);
assert.equal(convertVelocity(0, 'pace', 'metric'), 0);
});
it('formats durations, without the hours when there are none', () => {
assert.equal(secondsToHHMMSS(0), '00:00');
assert.equal(secondsToHHMMSS(5), '00:05');
assert.equal(secondsToHHMMSS(59), '00:59');
assert.equal(secondsToHHMMSS(60), '01:00');
assert.equal(secondsToHHMMSS(300), '05:00');
assert.equal(secondsToHHMMSS(3599), '59:59');
assert.equal(secondsToHHMMSS(3600), '01:00:00');
assert.equal(secondsToHHMMSS(3725), '01:02:05');
assert.equal(secondsToHHMMSS(36000 + 61), '10:01:01');
// the seconds are rounded, and never reach 60
assert.equal(secondsToHHMMSS(61.4), '01:01');
assert.equal(secondsToHHMMSS(119.9), '01:59');
});
});
+94
View File
@@ -0,0 +1,94 @@
// The conversions between units, with the units given: they do not depend on the settings (see
// `units.ts`, which converts to the units of the settings).
export type DistanceUnits = 'metric' | 'imperial' | 'nautical';
export type VelocityUnits = 'speed' | 'pace';
export type TemperatureUnits = 'celsius' | 'fahrenheit';
export function kilometersToMiles(value: number) {
return value * 0.621371;
}
export function milesToKilometers(value: number) {
return value * 1.60934;
}
export function metersToFeet(value: number) {
return value * 3.28084;
}
export function kilometersToNauticalMiles(value: number) {
return value * 0.539957;
}
export function nauticalMilesToKilometers(value: number) {
return value * 1.852;
}
export function celsiusToFahrenheit(value: number) {
return value * 1.8 + 32;
}
/** The pace (seconds per distance unit) of a speed (distance units per hour). 0 for no speed. */
export function distancePerHourToSecondsPerDistance(value: number) {
if (value === 0) {
return 0;
}
return 3600 / value;
}
/** A duration as `HH:MM:SS`, or `MM:SS` when it has no hours. */
export function secondsToHHMMSS(value: number) {
const hours = Math.floor(value / 3600);
const minutes = Math.floor(value / 60) % 60;
const seconds = Math.min(59, Math.round(value % 60));
return [hours, minutes, seconds]
.map((v) => (v < 10 ? '0' + v : v))
.filter((v, i) => v !== '00' || i > 0)
.join(':');
}
/** A distance in kilometers, in the given units. */
export function convertDistance(value: number, units: DistanceUnits) {
switch (units) {
case 'metric':
return value;
case 'imperial':
return kilometersToMiles(value);
case 'nautical':
return kilometersToNauticalMiles(value);
}
}
/** A distance in the given units, in kilometers. */
export function convertDistanceToKilometers(value: number, units: DistanceUnits) {
switch (units) {
case 'metric':
return value;
case 'imperial':
return milesToKilometers(value);
case 'nautical':
return nauticalMilesToKilometers(value);
}
}
/** An elevation in meters, in the units of the distances (nautical miles go with meters). */
export function convertElevation(value: number, units: DistanceUnits) {
return units === 'imperial' ? metersToFeet(value) : value;
}
/** A speed in km/h, in the given units: a speed or a pace, per kilometer, mile or nautical mile. */
export function convertVelocity(
value: number,
velocityUnits: VelocityUnits,
distanceUnits: DistanceUnits
) {
const speed = convertDistance(value, distanceUnits);
return velocityUnits === 'speed' ? speed : distancePerHourToSecondsPerDistance(speed);
}
/** A temperature in degrees Celsius, in the given units. */
export function convertTemperature(value: number, units: TemperatureUnits) {
return units === 'celsius' ? value : celsiusToFahrenheit(value);
}
+16 -87
View File
@@ -1,51 +1,19 @@
import { settings } from '$lib/logic/settings';
import { i18n } from '$lib/i18n.svelte';
import { get } from 'svelte/store';
import {
convertDistance,
convertDistanceToKilometers,
convertElevation,
convertTemperature,
convertVelocity,
secondsToHHMMSS,
} from '$lib/unit-conversions';
export * from '$lib/unit-conversions';
const { distanceUnits, velocityUnits, temperatureUnits } = settings;
export function kilometersToMiles(value: number) {
return value * 0.621371;
}
export function milesToKilometers(value: number) {
return value * 1.60934;
}
export function metersToFeet(value: number) {
return value * 3.28084;
}
export function kilometersToNauticalMiles(value: number) {
return value * 0.539957;
}
export function nauticalMilesToKilometers(value: number) {
return value * 1.852;
}
export function celsiusToFahrenheit(value: number) {
return value * 1.8 + 32;
}
export function distancePerHourToSecondsPerDistance(value: number) {
if (value === 0) {
return 0;
}
return 3600 / value;
}
export function secondsToHHMMSS(value: number) {
var hours = Math.floor(value / 3600);
var minutes = Math.floor(value / 60) % 60;
var seconds = Math.min(59, Math.round(value % 60));
return [hours, minutes, seconds]
.map((v) => (v < 10 ? '0' + v : v))
.filter((v, i) => v !== '00' || i > 0)
.join(':');
}
// Get a string representation of the value with units
export function getDistanceWithUnits(value: number, convert: boolean = true) {
if (convert) {
@@ -166,41 +134,20 @@ export function getTemperatureUnits() {
: i18n._('units.fahrenheit');
}
// Convert only the value
// Convert only the value, to the units of the settings by default
export function getConvertedDistance(value: number, targetDistanceUnits = get(distanceUnits)) {
switch (targetDistanceUnits) {
case 'metric':
return value;
case 'imperial':
return kilometersToMiles(value);
case 'nautical':
return kilometersToNauticalMiles(value);
}
return convertDistance(value, targetDistanceUnits);
}
export function getConvertedDistanceToKilometers(
value: number,
sourceDistanceUnits = get(distanceUnits)
) {
switch (sourceDistanceUnits) {
case 'metric':
return value;
case 'imperial':
return milesToKilometers(value);
case 'nautical':
return nauticalMilesToKilometers(value);
}
return convertDistanceToKilometers(value, sourceDistanceUnits);
}
export function getConvertedElevation(value: number, targetDistanceUnits = get(distanceUnits)) {
switch (targetDistanceUnits) {
case 'metric':
return value;
case 'imperial':
return metersToFeet(value);
case 'nautical':
return value;
}
return convertElevation(value, targetDistanceUnits);
}
export function getConvertedVelocity(
@@ -208,30 +155,12 @@ export function getConvertedVelocity(
targetVelocityUnits = get(velocityUnits),
targetDistanceUnits = get(distanceUnits)
) {
if (targetVelocityUnits === 'speed') {
switch (targetDistanceUnits) {
case 'metric':
return value;
case 'imperial':
return kilometersToMiles(value);
case 'nautical':
return kilometersToNauticalMiles(value);
}
} else {
switch (targetDistanceUnits) {
case 'metric':
return distancePerHourToSecondsPerDistance(value);
case 'imperial':
return distancePerHourToSecondsPerDistance(kilometersToMiles(value));
case 'nautical':
return distancePerHourToSecondsPerDistance(kilometersToNauticalMiles(value));
}
}
return convertVelocity(value, targetVelocityUnits, targetDistanceUnits);
}
export function getConvertedTemperature(
value: number,
targetTemperatureUnits = get(temperatureUnits)
) {
return targetTemperatureUnits === 'celsius' ? value : celsiusToFahrenheit(value);
return convertTemperature(value, targetTemperatureUnits);
}
+46
View File
@@ -0,0 +1,46 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { cn, getURLForLanguage } from './utils';
describe('getURLForLanguage', () => {
it('keeps english pages as they are', () => {
assert.equal(getURLForLanguage('en', '/'), '/');
assert.equal(getURLForLanguage('en', '/app'), '/app');
assert.equal(getURLForLanguage('en', '/help/toolbar/poi'), '/help/toolbar/poi');
});
it('puts the language before the path of english pages', () => {
assert.equal(getURLForLanguage('fr', '/app'), '/fr/app');
assert.equal(getURLForLanguage('de', '/help/toolbar/poi'), '/de/help/toolbar/poi');
// the home page of a language has no trailing slash
assert.equal(getURLForLanguage('fr', '/'), '/fr');
});
it('replaces the language of translated pages', () => {
assert.equal(getURLForLanguage('de', '/fr/app'), '/de/app');
assert.equal(getURLForLanguage('es', '/fr/help/menu'), '/es/help/menu');
assert.equal(getURLForLanguage('fr', '/fr/app'), '/fr/app');
assert.equal(getURLForLanguage('de', '/fr'), '/de');
});
it('removes the language to go back to english', () => {
assert.equal(getURLForLanguage('en', '/fr/app'), '/app');
assert.equal(getURLForLanguage('en', '/fr/help/menu'), '/help/menu');
assert.equal(getURLForLanguage('en', '/fr'), '/');
});
it('does not take a page for a language', () => {
// `help` is not a language: the page is english
assert.equal(getURLForLanguage('fr', '/help'), '/fr/help');
// a language that is not offered is not one either
assert.equal(getURLForLanguage('en', '/xx/app'), '/xx/app');
});
});
describe('cn', () => {
it('merges class names, the last tailwind class winning', () => {
assert.equal(cn('p-2', 'p-4'), 'p-4');
assert.equal(cn('text-sm', false && 'hidden', undefined, 'font-bold'), 'text-sm font-bold');
assert.equal(cn(['a', { b: true, c: false }]), 'a b');
});
});
+27
View File
@@ -0,0 +1,27 @@
// Resolves the virtual modules of SvelteKit to stubs, so that the code that imports them can be
// tested outside of the framework.
import { fileURLToPath, pathToFileURL } from 'node:url';
const stubs = {
'$app/environment': 'app-environment.ts',
'$app/paths': 'app-paths.ts',
'$app/state': 'app-state.ts',
'$env/static/public': 'env-static-public.ts',
};
export async function resolve(specifier, context, nextResolve) {
const stub = stubs[specifier];
if (stub) {
const url = pathToFileURL(fileURLToPath(new URL(`./stubs/${stub}`, import.meta.url)));
return nextResolve(url.href, context);
}
return nextResolve(specifier, context);
}
// Stylesheets are for the browser: importing one gives nothing.
export async function load(url, context, nextLoad) {
if (url.endsWith('.css')) {
return { format: 'module', source: '', shortCircuit: true };
}
return nextLoad(url, context);
}
+12
View File
@@ -0,0 +1,12 @@
import { register } from 'node:module';
register('./hooks.mjs', import.meta.url);
// The runes of Svelte are compiled away in the app. In the tests, the modules that use them are
// only loaded, never relied on for reactivity: a rune is its value.
globalThis.$state = (value) => value;
globalThis.$state.raw = (value) => value;
globalThis.$derived = (value) => value;
globalThis.$derived.by = (compute) => compute();
globalThis.$effect = () => {};
globalThis.$effect.pre = () => {};
@@ -0,0 +1,8 @@
// The tests run like the browser does, which is where the engine is available.
export const browser = true;
export const dev = false;
export const building = false;
export const version = 'test';
// what the code that checks `browser` reads of the window
(globalThis as { window?: unknown }).window ??= { innerWidth: 1280, innerHeight: 800, URL };
+5
View File
@@ -0,0 +1,5 @@
export const base = '';
export const assets = '';
export function resolve(path: string) {
return path;
}
+2
View File
@@ -0,0 +1,2 @@
export const page = { url: new URL('http://localhost/') };
export const navigating = { from: null, to: null };
@@ -0,0 +1 @@
export const PUBLIC_MAPTILER_KEY = 'test-key';