From b6b409e85598bdd01eb3e04d323055c56836da83 Mon Sep 17 00:00:00 2001 From: Christian Beutel <> Date: Tue, 26 Nov 2024 00:19:41 +0100 Subject: [PATCH] starts transition to maplibre --- web/package-lock.json | 372 +++++- web/package.json | 3 + web/src/css/theme.css | 6 + .../summit_log/summit_log_table.svelte | 13 +- .../trail/map_with_elevation_maplibre.svelte | 166 +++ .../components/trail/trail_info_panel.svelte | 25 +- web/src/lib/models/gpx/gpx.ts | 4 +- web/src/lib/models/gpx/utils.ts | 4 +- web/src/lib/util/format_util.ts | 1 - web/src/lib/util/geojson_util.ts | 216 ++++ web/src/lib/util/gpx_util.ts | 22 +- web/src/lib/util/maplibre_util.ts | 44 + .../elevationprofile-control.ts | 218 ++++ .../elevationprofile.ts | 1006 +++++++++++++++++ .../maplibre-elevation-profile/tools.ts | 47 + web/src/routes/+error.svelte | 2 +- web/src/routes/map/trail/[id]/+page.svelte | 8 +- web/src/routes/tmp/+page.svelte | 8 + web/src/routes/tmp/+page.ts | 9 + web/src/routes/trail/edit/[id]/+page.svelte | 1 - web/tailwind.config.js | 2 + 21 files changed, 2143 insertions(+), 34 deletions(-) create mode 100644 web/src/lib/components/trail/map_with_elevation_maplibre.svelte create mode 100644 web/src/lib/util/geojson_util.ts create mode 100644 web/src/lib/util/maplibre_util.ts create mode 100644 web/src/lib/vendor/maplibre-elevation-profile/elevationprofile-control.ts create mode 100644 web/src/lib/vendor/maplibre-elevation-profile/elevationprofile.ts create mode 100644 web/src/lib/vendor/maplibre-elevation-profile/tools.ts create mode 100644 web/src/routes/tmp/+page.svelte create mode 100644 web/src/routes/tmp/+page.ts diff --git a/web/package-lock.json b/web/package-lock.json index c4457dbe..34c71143 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "wanderer", - "version": "0.10.1", + "version": "0.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wanderer", - "version": "0.10.1", + "version": "0.11.0", "dependencies": { "@fortawesome/fontawesome-free": "^6.5.1", "@sveltejs/adapter-node": "^4.0.1", @@ -18,6 +18,8 @@ "@types/xmldom": "^0.1.34", "canvg": "^4.0.1", "chart.js": "^4.4.6", + "chartjs-plugin-crosshair": "^2.0.0", + "chartjs-plugin-zoom": "^2.1.0", "crypto-random-string": "^5.0.0", "heic2any": "^0.0.4", "instead": "^1.0.3", @@ -27,6 +29,7 @@ "leaflet": "^1.9.4", "leaflet-gpx": "^1.7.0", "leaflet.awesome-markers": "^2.0.5", + "maplibre-gl": "^4.7.1", "meilisearch": "^0.37.0", "nouislider": "^15.7.1", "pdfkit": "^0.15.0", @@ -568,6 +571,92 @@ "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz", "integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==" }, + "node_modules/@mapbox/geojson-rewind": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz", + "integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==", + "dependencies": { + "get-stream": "^6.0.1", + "minimist": "^1.2.6" + }, + "bin": { + "geojson-rewind": "geojson-rewind" + } + }, + "node_modules/@mapbox/geojson-rewind/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", + "integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@mapbox/point-geometry": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz", + "integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.6.tgz", + "integrity": "sha512-qMqa27TLw+ZQz5Jk+RcwZGH7BQf5G/TrutJhspsca/3SHwmgKQ1iq+d3Jxz5oysPVYTGP6aXxCo5Lk9Er6YBAA==" + }, + "node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==" + }, + "node_modules/@mapbox/vector-tile": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz", + "integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==", + "dependencies": { + "@mapbox/point-geometry": "~0.1.0" + } + }, + "node_modules/@mapbox/whoots-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "20.4.0", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-20.4.0.tgz", + "integrity": "sha512-AzBy3095fTFPjDjmWpR2w6HVRAZJ6hQZUCwk5Plz6EyfnfuQW1odeW5i2Ai47Y6TBA2hQnC+azscjBSALpaWgw==", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^0.0.1", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^2.0.0", + "rw": "^1.3.3", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/quickselect": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz", + "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1089,9 +1178,22 @@ "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==" }, "node_modules/@types/geojson": { - "version": "7946.0.13", - "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.13.tgz", - "integrity": "sha512-bmrNrgKMOhM3WsafmbGmC+6dsF2Z308vLFsQ3a/bT8X8Sv5clVYpPars/UPq+sAaJP+5OoLAYgwbkS5QEJdLUQ==" + "version": "7946.0.14", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.14.tgz", + "integrity": "sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==" + }, + "node_modules/@types/geojson-vt": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz", + "integrity": "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/hammerjs": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz", + "integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==" }, "node_modules/@types/leaflet": { "version": "1.9.8", @@ -1118,6 +1220,21 @@ "@types/leaflet": "*" } }, + "node_modules/@types/mapbox__point-geometry": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@types/mapbox__point-geometry/-/mapbox__point-geometry-0.1.4.tgz", + "integrity": "sha512-mUWlSxAmYLfwnRBmgYV86tgYmMIICX4kza8YnE/eIlywGe2XoOxlpVnXWwir92xRLjwyarqwpu2EJKD2pk0IUA==" + }, + "node_modules/@types/mapbox__vector-tile": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@types/mapbox__vector-tile/-/mapbox__vector-tile-1.3.4.tgz", + "integrity": "sha512-bpd8dRn9pr6xKvuEBQup8pwQfD4VUyqO/2deGjfpe6AwC8YRlyEipvefyRJUSiCJTZuCb8Pl1ciVV5ekqJ96Bg==", + "dependencies": { + "@types/geojson": "*", + "@types/mapbox__point-geometry": "*", + "@types/pbf": "*" + } + }, "node_modules/@types/node": { "version": "20.11.25", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.25.tgz", @@ -1131,6 +1248,11 @@ "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==" }, + "node_modules/@types/pbf": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz", + "integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==" + }, "node_modules/@types/pug": { "version": "2.0.10", "resolved": "https://registry.npmjs.org/@types/pug/-/pug-2.0.10.tgz", @@ -1152,6 +1274,14 @@ "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.3.tgz", "integrity": "sha512-pXNfAD3KHOdif9EQXZ9deK82HVNaXP5ZIF5RP2QG6OQFNTaY2YIetfrE9t528vEreGQvEPRDDc8muaoYeK0SxQ==" }, + "node_modules/@types/supercluster": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", + "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/three": { "version": "0.161.2", "resolved": "https://registry.npmjs.org/@types/three/-/three-0.161.2.tgz", @@ -1704,6 +1834,26 @@ "pnpm": ">=8" } }, + "node_modules/chartjs-plugin-crosshair": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chartjs-plugin-crosshair/-/chartjs-plugin-crosshair-2.0.0.tgz", + "integrity": "sha512-Vs6strRvYpzL92v40r/rAWhh+7au8VnfyC/m2ZHXcXeLEWeFjDgMvXjWdX+eARhbgHQo+uGIKyq9fmkS79GIyQ==", + "peerDependencies": { + "chart.js": "^4.0.1" + } + }, + "node_modules/chartjs-plugin-zoom": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/chartjs-plugin-zoom/-/chartjs-plugin-zoom-2.1.0.tgz", + "integrity": "sha512-7lMimfQCUaIJLhPJaWSAA4gw+1m8lyR3Wn+M3MxjHbM/XxRUnOxN7cM5RR9jUmxmyW0h7L2hZ8KhvUsqrFxy/Q==", + "dependencies": { + "@types/hammerjs": "^2.0.45", + "hammerjs": "^2.0.8" + }, + "peerDependencies": { + "chart.js": ">=3.2.0" + } + }, "node_modules/check-error": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", @@ -2263,6 +2413,11 @@ "integrity": "sha512-5RXhAXSCrKTqt9pSbobT9PVRX+oPpENplTZqCiK1l0ya+ZOzwo9kqsGLbYRsAhzIiLCwKEy99XKSSrqnRTLVcw==", "optional": true }, + "node_modules/earcut": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.0.tgz", + "integrity": "sha512-41Fs7Q/PLq1SDbqjsgcY7GA42T0jvaCNGXgGtsNdvg+Yv8eIu06bxv4/PoREkZ9nMDNwnUSG9OFB9+yv8eKhDg==" + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -2669,6 +2824,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/geojson-vt": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.2.tgz", + "integrity": "sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==" + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -2716,6 +2876,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gl-matrix": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.3.tgz", + "integrity": "sha512-wcCp8vu8FT22BnvKVPjXa/ICBWRq/zjFfdofZy1WSpQZpphblv12/bOQLBC1rMM7SGOFS9ltVmKOHil5+Ml7gA==" + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -2748,6 +2913,41 @@ "node": ">= 6" } }, + "node_modules/global-prefix": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-4.0.0.tgz", + "integrity": "sha512-w0Uf9Y9/nyHinEk5vMJKRie+wa4kR5hmDbEhGGds/kG1PwGLLHKRoNMeJOyCQjjBkANlnScqgzcFwGHgmgLkVA==", + "dependencies": { + "ini": "^4.1.3", + "kind-of": "^6.0.3", + "which": "^4.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/global-prefix/node_modules/isexe": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", + "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "engines": { + "node": ">=16" + } + }, + "node_modules/global-prefix/node_modules/which": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^16.13.0 || >=18.0.0" + } + }, "node_modules/globalyzer": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/globalyzer/-/globalyzer-0.1.0.tgz", @@ -2775,6 +2975,14 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true }, + "node_modules/hammerjs": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/hammerjs/-/hammerjs-2.0.8.tgz", + "integrity": "sha512-tSQXBXS/MWQOn/RKckawJ61vvsDpCom87JgxiYdGwHdOa0ht0vzUWDlfioofFCRU0L+6NGDt6XzbgoJvZkMeRQ==", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/has-bigints": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", @@ -2926,6 +3134,25 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/immediate": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", @@ -2970,6 +3197,14 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, + "node_modules/ini": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", + "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/instead": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/instead/-/instead-1.0.3.tgz", @@ -3457,6 +3692,11 @@ "node": ">=18" } }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==" + }, "node_modules/jsonc-parser": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.1.tgz", @@ -3526,6 +3766,19 @@ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" }, + "node_modules/kdbush": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", + "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==" + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -3658,6 +3911,46 @@ "node": ">=12" } }, + "node_modules/maplibre-gl": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.7.1.tgz", + "integrity": "sha512-lgL7XpIwsgICiL82ITplfS7IGwrB1OJIw/pCvprDp2dhmSSEBgmPzYRvwYYYvJGJD7fxUv1Tvpih4nZ6VrLuaA==", + "dependencies": { + "@mapbox/geojson-rewind": "^0.5.2", + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/point-geometry": "^0.1.0", + "@mapbox/tiny-sdf": "^2.0.6", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^1.3.1", + "@mapbox/whoots-js": "^3.1.0", + "@maplibre/maplibre-gl-style-spec": "^20.3.1", + "@types/geojson": "^7946.0.14", + "@types/geojson-vt": "3.2.5", + "@types/mapbox__point-geometry": "^0.1.4", + "@types/mapbox__vector-tile": "^1.3.4", + "@types/pbf": "^3.0.5", + "@types/supercluster": "^7.1.3", + "earcut": "^3.0.0", + "geojson-vt": "^4.0.2", + "gl-matrix": "^3.4.3", + "global-prefix": "^4.0.0", + "kdbush": "^4.0.2", + "murmurhash-js": "^1.0.0", + "pbf": "^3.3.0", + "potpack": "^2.0.0", + "quickselect": "^3.0.0", + "supercluster": "^8.0.1", + "tinyqueue": "^3.0.0", + "vt-pbf": "^3.1.3" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, "node_modules/mdn-data": { "version": "2.0.30", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", @@ -3781,7 +4074,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -3853,6 +4145,11 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==" + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -4199,6 +4496,18 @@ "node": "*" } }, + "node_modules/pbf": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz", + "integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==", + "dependencies": { + "ieee754": "^1.1.12", + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, "node_modules/pdfkit": { "version": "0.15.0", "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.15.0.tgz", @@ -4480,6 +4789,11 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "dev": true }, + "node_modules/potpack": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.0.0.tgz", + "integrity": "sha512-Q+/tYsFU9r7xoOJ+y/ZTtdVQwTWfzjbiXBDMM/JKUux3+QPP02iUuIoeBQ+Ot6oEDlC+/PGjB/5A3K7KKb7hcw==" + }, "node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -4504,6 +4818,11 @@ "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==" }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", + "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==" + }, "node_modules/psl": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", @@ -4568,6 +4887,11 @@ } ] }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==" + }, "node_modules/raf": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", @@ -4698,6 +5022,14 @@ "node": ">=4" } }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, "node_modules/restructure": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/restructure/-/restructure-2.0.1.tgz", @@ -4795,6 +5127,11 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==" + }, "node_modules/sade": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", @@ -5226,6 +5563,14 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/supercluster": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", + "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", + "dependencies": { + "kdbush": "^4.0.2" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -5545,6 +5890,11 @@ "node": ">=14.0.0" } }, + "node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==" + }, "node_modules/tinyspy": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.0.tgz", @@ -5941,6 +6291,16 @@ } } }, + "node_modules/vt-pbf": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz", + "integrity": "sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==", + "dependencies": { + "@mapbox/point-geometry": "0.1.0", + "@mapbox/vector-tile": "^1.3.1", + "pbf": "^3.2.1" + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", diff --git a/web/package.json b/web/package.json index b1188024..9fd3113b 100644 --- a/web/package.json +++ b/web/package.json @@ -42,6 +42,8 @@ "@types/xmldom": "^0.1.34", "canvg": "^4.0.1", "chart.js": "^4.4.6", + "chartjs-plugin-crosshair": "^2.0.0", + "chartjs-plugin-zoom": "^2.1.0", "crypto-random-string": "^5.0.0", "heic2any": "^0.0.4", "instead": "^1.0.3", @@ -51,6 +53,7 @@ "leaflet": "^1.9.4", "leaflet-gpx": "^1.7.0", "leaflet.awesome-markers": "^2.0.5", + "maplibre-gl": "^4.7.1", "meilisearch": "^0.37.0", "nouislider": "^15.7.1", "pdfkit": "^0.15.0", diff --git a/web/src/css/theme.css b/web/src/css/theme.css index 01515bf6..175c1747 100644 --- a/web/src/css/theme.css +++ b/web/src/css/theme.css @@ -4,7 +4,10 @@ --secondary-hover: 243, 244, 246; --background: 255, 255, 255; + --background-inverse: 7, 10, 36; + --content: 0, 0, 0; + --content-inverse: 255, 255, 255; --input-background: 249, 250, 251; --input-background-error: 254, 242, 242; @@ -30,7 +33,10 @@ --secondary-hover: 36, 39, 52; --background: 7, 10, 36; + --background-inverse: 255, 255, 255; + --content: 255, 255, 255; + --content-inverse: 0, 0, 0; --input-background: 36, 39, 52; --input-background-error: 69, 10, 10; diff --git a/web/src/lib/components/summit_log/summit_log_table.svelte b/web/src/lib/components/summit_log/summit_log_table.svelte index 64fe25d6..5bb20b25 100644 --- a/web/src/lib/components/summit_log/summit_log_table.svelte +++ b/web/src/lib/components/summit_log/summit_log_table.svelte @@ -7,9 +7,9 @@ import type { Trail } from "$lib/models/trail"; import { gpx2trail } from "$lib/util/gpx_util"; - import type { Map } from "leaflet"; + import * as M from "maplibre-gl"; import { _ } from "svelte-i18n"; - import MapWithElevation from "../trail/map_with_elevation.svelte"; + import MapWithElevationMaplibre from "../trail/map_with_elevation_maplibre.svelte"; export let summitLogs: SummitLog[] = []; export let showCategory: boolean = false; @@ -22,7 +22,7 @@ let openTextModal: () => void; let closeTextModal: () => void; - let map: Map; + let map: M.Map; let trail: Trail | null = null; @@ -40,8 +40,6 @@ openMapModal(); await tick(); - - map.invalidateSize(); return; } @@ -103,7 +101,10 @@ bind:closeModal={closeMapModal} >
- + {#if trail} + + {/if}
+ import { page } from "$app/stores"; + import type { Trail } from "$lib/models/trail"; + import { theme } from "$lib/stores/theme_store"; + import { findStartAndEndPoints } from "$lib/util/geojson_util"; + import { toGeoJson } from "$lib/util/gpx_util"; + import { + createMarkerFromWaypoint, + FontawesomeMarker, + } from "$lib/util/maplibre_util"; + import type { ElevationProfileControl } from "$lib/vendor/maplibre-elevation-profile/elevationprofile-control"; + import type { GeoJsonObject } from "geojson"; + import * as M from "maplibre-gl"; + import "maplibre-gl/dist/maplibre-gl.css"; + import { onDestroy, onMount } from "svelte"; + + export let trail: Trail; + export let markers: M.Marker[] = []; + export let map: M.Map | null = null; + export let crosshairCursor: boolean = false; + + let mapContainer: HTMLDivElement; + + let epc: ElevationProfileControl; + + $: data = toGeoJson(trail.expand.gpx_data!) as GeoJsonObject; + + $: if ($theme == "dark") { + epc?.toggleTheme({ + profileBackgroundColor: "#191b24", + elevationGridColor: "#ddd2", + labelColor: "#ddd8", + crosshairColor: "#fff5", + }); + } else { + epc?.toggleTheme({ + profileBackgroundColor: "#242734", + elevationGridColor: "#0002", + labelColor: "#0009", + crosshairColor: "#0005", + }); + } + + onMount(async () => { + const initialState = { + lng: 0, + lat: 0, + zoom: 14, + }; + const ElevationProfileControl = ( + await import( + "$lib/vendor/maplibre-elevation-profile/elevationprofile-control" + ) + ).ElevationProfileControl; + + map = new M.Map({ + container: mapContainer, + style: `https://basemaps.cartocdn.com/gl/positron-gl-style/style.json`, + center: [initialState.lng, initialState.lat], + zoom: initialState.zoom, + }); + + for (const waypoint of trail?.expand.waypoints ?? []) { + const marker = createMarkerFromWaypoint(waypoint); + marker.addTo(map); + markers.push(marker); + } + + const startEndPoint = findStartAndEndPoints(data); + + const startMarker = new FontawesomeMarker( + { icon: "fa fa-bullseye" }, + {}, + ) + .setLngLat(startEndPoint[0] as M.LngLatLike) + .addTo(map); + + const endMarker = new FontawesomeMarker( + { icon: "fa fa-flag-checkered" }, + {}, + ) + .setLngLat(startEndPoint[1] as M.LngLatLike) + .addTo(map); + + const elevationMarker = new FontawesomeMarker( + { + icon: "fa-regular fa-circle", + fontSize: "xs", + width: 4, + backgroundColor: "primary", + fontColor: "white", + }, + {}, + ); + elevationMarker.setLngLat([0, 0]).addTo(map); + elevationMarker.setOpacity("0"); + + epc = new ElevationProfileControl({ + visible: true, + profileBackgroundColor: $theme == "light" ? "#242734" : "#191b24", + backgroundColor: "bg-menu-background/90", + unit: $page.data.settings?.unit ?? "metric", + profileLineWidth: 3, + displayDistanceGrid: true, + tooltipDisplayDPlus: false, + onEnter: () => { + elevationMarker.setOpacity("1"); + }, + onLeave: () => { + elevationMarker.setOpacity("0"); + }, + onMove: (data) => { + elevationMarker.setLngLat(data.position as M.LngLatLike); + }, + }); + map.addControl(new M.NavigationControl()); + map.addControl( + new M.ScaleControl({ + maxWidth: 120, + unit: $page.data.settings?.unit ?? "metric", + }), + "top-left", + ); + map.addControl(epc); + epc.setData(data, trail.expand.waypoints); + + map.on("load", () => { + map!.addSource("trail-source", { + type: "geojson", + data: data as any, + }); + + map!.addLayer({ + id: "uploaded-polygons", + type: "line", + source: "trail-source", + paint: { + "line-color": "#648ad5", + "line-width": 5, + }, + }); + map!.fitBounds(data.bbox as any, { + animate: false, + padding: { + top: 16, + left: 16, + right: 16, + bottom: map!.getContainer().clientHeight * 0.3 + 16, + }, + }); + }); + }); + + onDestroy(() => { + map?.remove(); + }); + + +
+ + diff --git a/web/src/lib/components/trail/trail_info_panel.svelte b/web/src/lib/components/trail/trail_info_panel.svelte index 2dea8a60..8aba00e2 100644 --- a/web/src/lib/components/trail/trail_info_panel.svelte +++ b/web/src/lib/components/trail/trail_info_panel.svelte @@ -39,10 +39,11 @@ import ShareInfo from "../share_info.svelte"; import SummitLogTable from "../summit_log/summit_log_table.svelte"; import { pb } from "$lib/pocketbase"; + import * as M from "maplibre-gl"; export let trail: Trail; export let mode: "overview" | "map" | "list" = "map"; - export let markers: Marker[] = []; + export let markers: (Marker | M.Marker)[] = []; const tabs = [ $_("description"), @@ -118,11 +119,19 @@ }); function openMarkerPopup(i: number) { - markers[i].openPopup(); + if (typeof (markers[i] as Marker).openPopup === "function") { + (markers[i] as Marker).openPopup(); + } else { + (markers[i] as M.Marker).togglePopup(); + } } function closeMarkerPopup(i: number) { - markers[i].closePopup(); + if (typeof (markers[i] as Marker).closePopup === "function") { + (markers[i] as Marker).closePopup(); + } else { + (markers[i] as M.Marker).togglePopup(); + } } async function toggleMapFullScreen() { @@ -239,7 +248,11 @@ `https://api.dicebear.com/7.x/initials/svg?seed=${trail.expand.author.username}&backgroundType=gradientLinear`} alt="avatar" /> - {trail.expand.author.username} + {trail.expand.author.username}

{/if}
@@ -358,7 +371,9 @@ {/if} {#if activeTab == 3}
-
{/if} diff --git a/web/src/lib/models/gpx/gpx.ts b/web/src/lib/models/gpx/gpx.ts index 13a2a2c6..01dfa786 100644 --- a/web/src/lib/models/gpx/gpx.ts +++ b/web/src/lib/models/gpx/gpx.ts @@ -2,7 +2,7 @@ import * as xml2js from 'isomorphic-xml2js'; import Metadata from './metadata'; import Route from './route'; import Track from './track'; -import { allDatesToISOString, calculateDistance, removeEmpty } from './utils'; +import { allDatesToISOString, haversineDistance, removeEmpty } from './utils'; import Waypoint from './waypoint'; const defaultAttributes = { @@ -104,7 +104,7 @@ export default class GPX { totalElevationLoss += Math.abs(elevationDiff) } - const distance = calculateDistance( + const distance = haversineDistance( prevPoint.$.lat ?? 0, prevPoint.$.lon ?? 0, point.$.lat ?? 0, diff --git a/web/src/lib/models/gpx/utils.ts b/web/src/lib/models/gpx/utils.ts index 55057b23..8ad8cadb 100644 --- a/web/src/lib/models/gpx/utils.ts +++ b/web/src/lib/models/gpx/utils.ts @@ -20,7 +20,7 @@ function allDatesToISOString(obj: Record) { }); } -function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number { +function haversineDistance(lat1: number, lon1: number, lat2: number, lon2: number): number { const R = 6371; // Radius of the Earth in km const dLat = (lat2 - lat1) * (Math.PI / 180); // Convert degrees to radians const dLon = (lon2 - lon1) * (Math.PI / 180); @@ -33,4 +33,4 @@ function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: numbe return distance; } -export { removeEmpty, allDatesToISOString, calculateDistance }; +export { removeEmpty, allDatesToISOString, haversineDistance }; diff --git a/web/src/lib/util/format_util.ts b/web/src/lib/util/format_util.ts index ddd0134c..77bbc3ec 100644 --- a/web/src/lib/util/format_util.ts +++ b/web/src/lib/util/format_util.ts @@ -1,5 +1,4 @@ import { page } from "$app/stores"; -import { currentUser } from "$lib/stores/user_store"; import { get } from "svelte/store"; export function formatTimeHHMM(minutes?: number) { diff --git a/web/src/lib/util/geojson_util.ts b/web/src/lib/util/geojson_util.ts new file mode 100644 index 00000000..b7510d01 --- /dev/null +++ b/web/src/lib/util/geojson_util.ts @@ -0,0 +1,216 @@ +import type { BBox, Feature, FeatureCollection, GeoJSON, GeoJsonObject, GeometryCollection, Position } from "geojson"; + +export function bbox( + geojson: GeoJSON, +): BBox { + if (geojson.bbox != null) { + return geojson.bbox; + } + const result: BBox = [Infinity, Infinity, -Infinity, -Infinity]; + let startPoint: Position, endPoint: Position; + coordEach(geojson, (coord) => { + startPoint ??= [coord[0], coord[1]]; + endPoint = coord; + if (result[0] > coord[0]) { + result[0] = coord[0]; + } + if (result[1] > coord[1]) { + result[1] = coord[1]; + } + if (result[2] < coord[0]) { + result[2] = coord[0]; + } + if (result[3] < coord[1]) { + result[3] = coord[1]; + } + }); + + return result; +} + +export function findStartAndEndPoints(geojson: GeoJsonObject): Position[] { + const startEndPoints: Position[] = []; + + (geojson as FeatureCollection).features.forEach((feature) => { + const geometry = feature.geometry; + + if (geometry.type === "LineString") { + const coords = geometry.coordinates as number[][]; + const start: [number, number] = [coords[0][0], coords[0][1]]; // First point + const end: [number, number] = [ + coords[coords.length - 1][0], + coords[coords.length - 1][1], + ]; // Last point + startEndPoints.push(start); + startEndPoints.push(end) + } else if (geometry.type === "MultiLineString") { + const coords = geometry.coordinates as number[][][]; + const start: [number, number] = [ + coords[0][0][0], + coords[0][0][1], + ]; // First point of the first line + const lastLine = coords[coords.length - 1]; + const end: [number, number] = [ + lastLine[lastLine.length - 1][0], + lastLine[lastLine.length - 1][1], + ]; // Last point of the last line + startEndPoints.push(start); + startEndPoints.push(end) + } else { + console.warn( + `Geometry type ${geometry.type} is not supported for start/end point extraction.` + ); + } + }); + + return startEndPoints; +} + +function coordEach(geojson: GeoJSON, callback: ( + currentCoord: number[], + coordIndex: number, + featureIndex: number, + multiFeatureIndex: number, + geometryIndex: number +) => void | false, + excludeWrapCoord?: boolean,) { + // Handles null Geometry -- Skips this GeoJSON + if (geojson === null) return; + var j, + k, + l, + geometry, + stopG, + coords, + geometryMaybeCollection, + wrapShrink = 0, + coordIndex = 0, + isGeometryCollection, + type = geojson.type, + isFeatureCollection = type === "FeatureCollection", + isFeature = type === "Feature", + stop = isFeatureCollection ? (geojson as FeatureCollection).features.length : 1; + + for (var featureIndex = 0; featureIndex < stop; featureIndex++) { + geometryMaybeCollection = isFeatureCollection + ? (geojson as FeatureCollection).features[featureIndex].geometry + : isFeature + ? (geojson as Feature).geometry + : geojson; + isGeometryCollection = geometryMaybeCollection + ? geometryMaybeCollection.type === "GeometryCollection" + : false; + stopG = isGeometryCollection + ? (geometryMaybeCollection as GeometryCollection).geometries.length + : 1; + + for (var geomIndex = 0; geomIndex < stopG; geomIndex++) { + var multiFeatureIndex = 0; + var geometryIndex = 0; + geometry = isGeometryCollection + ? (geometryMaybeCollection as GeometryCollection).geometries[geomIndex] + : geometryMaybeCollection; + + // Handles null Geometry -- Skips this geometry + if (geometry === null) continue; + coords = (geometry as any).coordinates; + var geomType = geometry.type; + + wrapShrink = + excludeWrapCoord && + (geomType === "Polygon" || geomType === "MultiPolygon") + ? 1 + : 0; + + switch (geomType) { + case null: + break; + case "Point": + if ( + callback( + coords, + coordIndex, + featureIndex, + multiFeatureIndex, + geometryIndex + ) === false + ) + return false; + coordIndex++; + multiFeatureIndex++; + break; + case "LineString": + case "MultiPoint": + for (j = 0; j < coords.length; j++) { + if ( + callback( + coords[j], + coordIndex, + featureIndex, + multiFeatureIndex, + geometryIndex + ) === false + ) + return false; + coordIndex++; + if (geomType === "MultiPoint") multiFeatureIndex++; + } + if (geomType === "LineString") multiFeatureIndex++; + break; + case "Polygon": + case "MultiLineString": + for (j = 0; j < coords.length; j++) { + for (k = 0; k < coords[j].length - wrapShrink; k++) { + if ( + callback( + coords[j][k], + coordIndex, + featureIndex, + multiFeatureIndex, + geometryIndex + ) === false + ) + return false; + coordIndex++; + } + if (geomType === "MultiLineString") multiFeatureIndex++; + if (geomType === "Polygon") geometryIndex++; + } + if (geomType === "Polygon") multiFeatureIndex++; + break; + case "MultiPolygon": + for (j = 0; j < coords.length; j++) { + geometryIndex = 0; + for (k = 0; k < coords[j].length; k++) { + for (l = 0; l < coords[j][k].length - wrapShrink; l++) { + if ( + callback( + coords[j][k][l], + coordIndex, + featureIndex, + multiFeatureIndex, + geometryIndex + ) === false + ) + return false; + coordIndex++; + } + geometryIndex++; + } + multiFeatureIndex++; + } + break; + case "GeometryCollection": + for (j = 0; j < (geometry as GeometryCollection).geometries.length; j++) + if ( + coordEach((geometry as GeometryCollection).geometries[j], callback, excludeWrapCoord) === + false + ) + return false; + break; + default: + throw new Error("Unknown Geometry Type"); + } + } + } +} \ No newline at end of file diff --git a/web/src/lib/util/gpx_util.ts b/web/src/lib/util/gpx_util.ts index 24c34570..7b5192d1 100644 --- a/web/src/lib/util/gpx_util.ts +++ b/web/src/lib/util/gpx_util.ts @@ -2,21 +2,22 @@ import GPX from "$lib/models/gpx/gpx"; import { Trail } from "$lib/models/trail"; import { Waypoint } from "$lib/models/waypoint"; import { currentUser } from "$lib/stores/user_store"; -import { kml, tcx } from "$lib/vendor/toGeoJSON/toGeoJSON"; +import { gpx, kml, tcx } from "$lib/vendor/toGeoJSON/toGeoJSON"; import cryptoRandomString from "crypto-random-string"; import { get } from "svelte/store"; //@ts-ignore -import EasyFit from "$lib/vendor/easy-fit/easy-fit" +import { browser } from "$app/environment"; import Track from "$lib/models/gpx/track"; import TrackSegment from "$lib/models/gpx/track-segment"; import GPXWaypoint from "$lib/models/gpx/waypoint"; -import { browser } from "$app/environment"; -import * as xmldom from 'xmldom' -import type { Feature, FeatureCollection, GeoJsonProperties, Position } from 'geojson'; +import EasyFit from "$lib/vendor/easy-fit/easy-fit"; +import type { GeoJSON, Feature, FeatureCollection, GeoJsonProperties, Position } from 'geojson'; +import * as xmldom from 'xmldom'; +import { bbox } from "./geojson_util"; export async function gpx2trail(gpxString: string, fallbackName?: string) { - + const gpx = await GPX.parse(gpxString); if (gpx instanceof Error) { @@ -281,4 +282,13 @@ export function isFITFile(buffer: ArrayBuffer) { } return true; +} + +export function toGeoJson(gpxData: string) { + const parser = browser ? new DOMParser() : new xmldom.DOMParser(); + const geojson = gpx( + parser.parseFromString(gpxData, "text/xml"), + ) as GeoJSON; + geojson.bbox = bbox(geojson) + return geojson } \ No newline at end of file diff --git a/web/src/lib/util/maplibre_util.ts b/web/src/lib/util/maplibre_util.ts new file mode 100644 index 00000000..b86fc15a --- /dev/null +++ b/web/src/lib/util/maplibre_util.ts @@ -0,0 +1,44 @@ +import type { Waypoint } from "$lib/models/waypoint"; +import M from "maplibre-gl"; + +export class FontawesomeMarker extends M.Marker { + constructor(options: { icon: string, fontSize?: string, width?: number, backgroundColor?: string, fontColor?: string }, markerOptions?: M.MarkerOptions) { + const element = document.createElement('div') + element.className = `cursor-pointer flex items-center justify-center w-${options.width ?? 7} aspect-square bg-${options.backgroundColor ?? "gray-500"} rounded-full text-${options.fontSize ?? "normal"}` + super({ element: element, ...markerOptions }); + + const { + icon, + } = options + + const iconElementString = `` + this._element.insertAdjacentHTML('beforeend', iconElementString) + } +} + +export function createMarkerFromWaypoint(waypoint: Waypoint, onDragEnd?: (marker: M.Marker) => void): FontawesomeMarker { + const marker = new FontawesomeMarker({ + icon: `fa fa-${waypoint.icon}`, + }, { + draggable: onDragEnd != null, + color: "#6b7280" + + }) + const popup = new M.Popup({ offset: 25, closeButton: false }).setHTML( + "" + + waypoint.name + + "" + + (waypoint.description && waypoint.description.length > 0 + ? "
" + waypoint.description + : ""), + ); + marker + .setLngLat([waypoint.lon, waypoint.lat]) + .setPopup(popup) + + if (onDragEnd) { + marker.on("dragend", () => onDragEnd(marker)); + } + + return marker; +} \ No newline at end of file diff --git a/web/src/lib/vendor/maplibre-elevation-profile/elevationprofile-control.ts b/web/src/lib/vendor/maplibre-elevation-profile/elevationprofile-control.ts new file mode 100644 index 00000000..dae71368 --- /dev/null +++ b/web/src/lib/vendor/maplibre-elevation-profile/elevationprofile-control.ts @@ -0,0 +1,218 @@ +import { type ControlPosition, type IControl } from "maplibre-gl"; +import * as M from "maplibre-gl"; +import { ElevationProfile, type ElevationProfileOptions } from "./elevationprofile"; +// @ts-ignore +import type { GeoJsonObject } from "geojson"; +import type { Waypoint } from "$lib/models/waypoint"; + +/** + * Elevation profile control options + */ +export type ElevationProfileControlOptions = ElevationProfileOptions & { + /** + * If `true`, the elevation profile control will be visible as soon as it's ready. + * If `false`, a click on the control button (or a programmatic call to `.showProfile()`) + * will be neccesary to show the profile. + * + * Default: `false` + */ + visible?: boolean; + /** + * Size of the profile as a CSS rule. + * This `size` will be the `width` if the `.position` is "left" or "right", + * and will be the `height` if the `.position` is "top" or "bottom". + * + * Default: `"30%"` + */ + size?: string; + /** + * Position of the elevation profile chart when shown. + * + * Default: `"botton"` + */ + position?: "top" | "left" | "right" | "bottom"; + /** + * Show the control button. If can be handy to hide it, especially if the profile is displayed + * in a custom container and that its visiblity is managed by logic external to this control. + * + * Default: `true` + */ + showButton?: boolean; + /** + * A CSS class to add to the container. This is especially relevant when the options `.container` is not provided. + * Important: if provided, no styling is added by this control and even placement will have to be managed by external CSS. + * + * Default: `""` + */ + containerClass?: string; + + /** + * DIV element to contain the control. + * Important: if provided, no styling is added by this control. + * Default: automatically created inside the map container + */ + container?: string | HTMLDivElement; +}; + +export class ElevationProfileControl implements IControl { + private map?: M.Map; + private buttonContainer?: HTMLDivElement; + private toggleButton?: HTMLButtonElement; + private isProfileShown = false; + private iconSpan?: HTMLSpanElement; + + private profileContainer?: HTMLDivElement; + private settings: ElevationProfileControlOptions; + private data: GeoJsonObject | null = null; + private elevationProfileChart?: ElevationProfile; + + constructor(options: ElevationProfileControlOptions = {}) { + if (typeof window === "undefined") + throw new Error("This pluggin must be mounted client-side"); + this.settings = { ...options }; + } + + toggleTheme(options: ElevationProfileControlOptions) { + this.settings = { ...this.settings, ...options }; + this.elevationProfileChart?.toggleTheme(this.settings) + } + + getContainer(): HTMLDivElement | undefined { + return this.profileContainer; + } + + onAdd(map: M.Map): HTMLElement { + this.map = map; + + this.buttonContainer = document.createElement("div"); + + if (this.settings.showButton === false) { + this.buttonContainer.style.setProperty("display", "none"); + } + + this.buttonContainer.classList.add( + "maplibregl-ctrl", + "maplibregl-ctrl-group" + ); + this.toggleButton = document.createElement("button"); + this.buttonContainer.appendChild(this.toggleButton); + this.iconSpan = document.createElement("i"); + this.iconSpan.classList.add("fa", "fa-chart-line", "text-black"); + this.toggleButton.appendChild(this.iconSpan); + this.toggleButton.addEventListener("click", this.toggleProfile.bind(this)); + const mapContainer = map.getContainer(); + const size = this.settings.size ?? "30%"; + + if (this.settings.container) { + const tmpContainer = + typeof this.settings.container === "string" + ? document.getElementById(this.settings.container) + : this.settings.container; + if (!tmpContainer) throw new Error("The provided container is invalid"); + this.profileContainer = tmpContainer as HTMLDivElement; + } else { + this.profileContainer = document.createElement("div"); + this.profileContainer.style.setProperty("display", "none"); + + if (!this.settings.containerClass) { + this.profileContainer.classList.add(this.settings.backgroundColor ?? "white") + + this.profileContainer.style.setProperty("position", "absolute"); + + if (this.settings.position === "bottom" || !this.settings.position) { + // To prevent clashing with MapTiler logo and attribution control + this.settings.paddingBottom = this.settings.paddingBottom ?? 35; + this.profileContainer.style.setProperty("width", "100%"); + this.profileContainer.style.setProperty("height", size); + this.profileContainer.style.setProperty("bottom", "0"); + } else if (this.settings.position === "top") { + this.profileContainer.style.setProperty("width", "100%"); + this.profileContainer.style.setProperty("height", size); + this.profileContainer.style.setProperty("top", "0"); + } else if (this.settings.position === "left") { + // To prevent clashing with MapTiler logo and attribution control + this.settings.paddingBottom = this.settings.paddingBottom ?? 35; + this.profileContainer.style.setProperty("width", size); + this.profileContainer.style.setProperty("height", "100%"); + this.profileContainer.style.setProperty("left", "0"); + } else if (this.settings.position === "right") { + // To prevent clashing with MapTiler logo and attribution control + this.settings.paddingBottom = this.settings.paddingBottom ?? 35; + this.profileContainer.style.setProperty("width", size); + this.profileContainer.style.setProperty("height", "100%"); + this.profileContainer.style.setProperty("right", "0"); + } + } + + const waypointContainer = document.createElement("div") + waypointContainer.className = "absolute w-full" + waypointContainer.id = "waypoint-container" + + this.profileContainer.append(waypointContainer); + + mapContainer.appendChild(this.profileContainer); + } + + if (this.settings.containerClass) { + this.profileContainer.classList.add(this.settings.containerClass); + } + + this.elevationProfileChart = new ElevationProfile( + this.profileContainer, + this.settings + ); + + if (this.settings.visible) { + this.showProfile(); + } + + return this.buttonContainer; + } + + private toggleProfile() { + if (!this.profileContainer) return; + + if (this.isProfileShown) { + this.hideProfile(); + } else { + this.showProfile(); + } + } + + showProfile() { + this.profileContainer?.style.setProperty("display", "inherit"); + this.isProfileShown = true; + } + + hideProfile() { + this.profileContainer?.style.setProperty("display", "none"); + this.isProfileShown = false; + } + + onRemove(): void { + // remove button + if (this.buttonContainer?.parentNode) { + this.buttonContainer.parentNode.removeChild(this.buttonContainer); + } + this.map = undefined; + this.buttonContainer = undefined; + this.toggleButton = undefined; + this.isProfileShown = false; + } + + getDefaultPosition?: (() => ControlPosition) | undefined; + + async setData(data: GeoJsonObject, waypoints?: Waypoint[]) { + if (!this.map || !this.elevationProfileChart) { + throw new Error( + "The Elevation Profile Control needs to be mounted on a map instance before setting any data." + ); + } + + this.data = data; + + if (!this.data) return; + + this.elevationProfileChart.setData(this.data, waypoints); + } +} \ No newline at end of file diff --git a/web/src/lib/vendor/maplibre-elevation-profile/elevationprofile.ts b/web/src/lib/vendor/maplibre-elevation-profile/elevationprofile.ts new file mode 100644 index 00000000..04310213 --- /dev/null +++ b/web/src/lib/vendor/maplibre-elevation-profile/elevationprofile.ts @@ -0,0 +1,1006 @@ +import type { + Feature, + FeatureCollection, + GeoJsonObject, + GeometryObject, + LineString, + MultiLineString, + Position, +} from "geojson"; + +import { Chart, registerables } from "chart.js"; +import zoomPlugin from "chartjs-plugin-zoom"; +// @ts-ignore +import { CrosshairPlugin } from "chartjs-plugin-crosshair"; + +import type { Waypoint } from "$lib/models/waypoint"; +import { haversineCumulatedDistanceWgs84, smoothElevations } from "./tools"; +import { haversineDistance } from "$lib/models/gpx/utils"; +import { formatTimeHHMM } from "$lib/util/format_util"; + +const FEET_PER_METER = 3.28084; +const MILES_PER_METER = 0.000621371; +const KILOMETERS_HOUR_PER_METER_SECOND = 3.6 +const MILES_HOUR_PER_METER_SECOND = 2.23694 + +function extractLineStrings( + geoJson: GeoJsonObject +): { lineStrings: Array, times: Date[] } { + const lineStrings: Array = []; + const times: Date[] = []; + + function extractFromGeometry(geometry: GeometryObject) { + if (geometry.type === "LineString" || geometry.type === "MultiLineString") { + lineStrings.push(geometry as LineString | MultiLineString); + } + } + + function extractFromFeature(feature: Feature) { + if (feature.geometry) { + extractFromGeometry(feature.geometry); + } + if (feature.properties?.coordinateProperties?.times) { + const coordinateTimes = feature.properties?.coordinateProperties?.times.map((t: string) => new Date(t)) + times.push(...coordinateTimes) + } + } + + function extractFromFeatureCollection(collection: FeatureCollection) { + for (const feature of collection.features) { + if (feature.type === "Feature") { + extractFromFeature(feature); + } else if (feature.type === "FeatureCollection") { + extractFromFeatureCollection(feature as unknown as FeatureCollection); // had to add unknown + } + } + } + + if (geoJson.type === "Feature") { + extractFromFeature(geoJson as Feature); + } else if (geoJson.type === "FeatureCollection") { + extractFromFeatureCollection(geoJson as FeatureCollection); + } else { + // It's a single geometry + extractFromGeometry(geoJson as GeometryObject); + } + + return { lineStrings, times }; +} + +function geoJsonObjectToPositionsAndTimes(geoJson: GeoJsonObject): { positions: Position[], times: Date[] } { + const { lineStrings, times } = extractLineStrings(geoJson); + const positionsGroups: Position[][] = []; + + for (let i = 0; i < lineStrings.length; i += 1) { + const feature = lineStrings[i]; + if (feature.type === "LineString") { + positionsGroups.push(feature.coordinates); + } else if (feature.type === "MultiLineString") { + positionsGroups.push(feature.coordinates.flat()); + } + } + return { positions: positionsGroups.flat(), times }; +} + +/** + * Event data to `onMove` and `onClick` callback + */ +export type CallbackData = { + /** + * The position as `[lon, lat, elevation]`. + * Elevation will be in meters if the component has been set with the unit "metric" (default) + * of in feet if the unit is "imperial". + */ + position: Position; + /** + * The distance from the start of the route. In km if the component has been set with the unit "metric" (default) + * of in miles if the unit is "imperial". + */ + distance: number; + /** + * Cumulated positive elevation from the begining of the route up to this location. + * In meters if the component has been set with the unit "metric" (default) + * of in feet if the unit is "imperial". + */ + dPlus: number; + /** + * Slope grade in percentage (1% being a increase of 1m on a 100m distance) + */ + gradePercent: number; +}; + +export type ElevationProfileOptions = { + /** + * Color of the background of the chart + */ + backgroundColor?: string | null; + /** + * Unit system to use. + * If "metric", elevation and D+ will be in meters, distances will be in km. + * If "imperial", elevation and D+ will be in feet, distances will be in miles. + * + * Default: "metric" + */ + unit?: "metric" | "imperial"; + /** + * Font size applied to axes labels and tooltip. + * + * Default: `12` + */ + fontSize?: number; + /** + * If `true`, will force the computation of the elevation of the GeoJSON data provided to the `.setData()` method, + * even if they already contain elevation (possibly from GPS while recording). If `false`, the elevation will only + * be computed if missing from the positions. + * + * Default: `false` + */ + forceComputeElevation?: boolean; + /** + * Display the elevation label along the vertical axis. + * + * Default: `true` + */ + displayElevationLabels?: boolean; + /** + * Display the distance labels alon the horizontal axis. + * + * Default: `true` + */ + displayDistanceLabels?: boolean; + /** + * Display the distance and elevation units alongside the labels. + * + * Default: `true` + */ + displayUnits?: boolean; + /** + * Color of the elevation and distance labels. + * + * Default: `"#0009"` (partially transparent black) + */ + labelColor?: string; + /** + * Color of the elevation profile line. + * Can be `null` to not display the line and rely on the background color only. + * + * Default: `"#66ccff"` + */ + profileLineColor?: string | null; + /** + * Width of the elevation profile line. + * + * Default: `1.5` + */ + profileLineWidth?: number; + /** + * Color of the elevation profile background (below the profile line) + * Can be `null` to not display any backgound color. + * + * Default: `"#66ccff22"` + */ + profileBackgroundColor?: string | null; + /** + * Display the tooltip folowing the pointer. + * + * Default: `true` + */ + displayTooltip?: boolean; + /** + * Color of the text inside the tooltip. + * + * Default: `"#fff"` + */ + tooltipTextColor?: string; + /** + * Color of the tooltip background. + * + * Default: `"#000A"` (partially transparent black) + */ + tooltipBackgroundColor?: string; + /** + * Display the distance information inside the tooltip if `true`. + * + * Default: `true` + */ + tooltipDisplayDistance?: boolean; + /** + * Display the elevation information inside the tooltip if `true`. + * + * Default: `true` + */ + tooltipDisplayElevation?: boolean; + /** + * Display the D+ (cumulated positive ascent) inside the tooltip if `true`. + * + * Default: `true` + */ + tooltipDisplayDPlus?: boolean; + /** + * Display the slope grade in percentage inside the tooltip if `true`. + * + * Default: `true` + */ + tooltipDisplayGrade?: boolean; + /** + * Display the slope grade in percentage inside the tooltip if `true`. + * + * Default: `true` + */ + tooltipDisplaySpeed?: boolean; + /** + * Display the distance grid lines (vertical lines matching the distance labels) if `true`. + * + * Default: `false` + */ + displayDistanceGrid?: boolean; + /** + * Display the elevation grid lines (horizontal lines matching the elevation labels) if `true`. + * + * Default: `true` + */ + displayElevationGrid?: boolean; + /** + * Color of the distance grid lines. + * + * Default: `"#0001"` (partially transparent black) + */ + distanceGridColor?: string; + /** + * Color of the elevation drig lines. + * + * Default: `"#0001"` (partially transparent black) + */ + elevationGridColor?: string; + /** + * Padding at the top of the chart, in number of pixels. + * + * Default: `30` + */ + paddingTop?: number; + /** + * Padding at the bottom of the chart, in number of pixels. + * + * Default: `10` + */ + paddingBottom?: number; + /** + * Padding at the left of the chart, in number of pixels. + * + * Default: `10` + */ + paddingLeft?: number; + /** + * Padding at the right of the chart, in number of pixels. + * + * Default: `10` + */ + paddingRight?: number; + /** + * Display the crosshair, a vertical line that follows the pointer, if `true`. + * + * Default: `true` + */ + displayCrosshair?: boolean; + /** + * Color of the crosshair. + * + * Default: `"#0005"` (partially transparent black) + */ + crosshairColor?: string; + /** + * Callback function to call when the chart is zoomed or panned. + * The argument `windowedLineString` is the GeoJSON LineString corresponding + * to the portion of the route visible in the elevation chart. + * + * Default: `null` + */ + onChangeView?: ((windowedLineString: LineString) => void) | null; + /** + * Callback function to call when the the elevation chart is clicked. + * + * Default: `null` + */ + onClick?: ((data: CallbackData) => void) | null; + /** + * Callback function to call when the pointer is moving on the elevation chart. + * + * Default: `null` + */ + onMove?: ((data: CallbackData) => void) | null; + + onEnter?: (() => void) | null; + + onLeave?: (() => void) | null; + +}; + +const elevationProfileDefaultOptions: ElevationProfileOptions = { + backgroundColor: null, + unit: "metric", + fontSize: 12, + forceComputeElevation: false, + displayElevationLabels: true, + displayDistanceLabels: true, + displayUnits: true, + labelColor: "#0009", + profileLineColor: "#66ccff", + profileLineWidth: 1.5, + profileBackgroundColor: "#66ccff22", + displayTooltip: true, + tooltipTextColor: "#fff", + tooltipBackgroundColor: "#000A", + tooltipDisplayDistance: true, + tooltipDisplayElevation: true, + tooltipDisplayDPlus: true, + tooltipDisplayGrade: true, + tooltipDisplaySpeed: true, + displayDistanceGrid: false, + displayElevationGrid: true, + distanceGridColor: "#0001", + elevationGridColor: "#0001", + displayCrosshair: true, + crosshairColor: "#0005", + onChangeView: null, + paddingTop: 30, + paddingBottom: 10, + paddingLeft: 10, + paddingRight: 10, + onClick: null, + onMove: null, +}; + +/** + * Elevation profile chart + */ +export class ElevationProfile { + private canvas: HTMLCanvasElement; + private settings: ElevationProfileOptions; + private chart: Chart<"line", Array, number>; + private elevatedPositions: Position[] = []; + private elevatedPositionsAdjustedUnit: Position[] = []; + private cumulatedDistance: number[] = []; + private cumulatedDistanceAdjustedUnit: number[] = []; + private cumulatedDPlus: number[] = []; + private grade: number[] = []; + private waypointPositions: number[] = [] + private waypoints: Waypoint[] = []; + private times: Date[] = []; + private cumulatedTime: number[] = [] + private speed: number[] = []; + + constructor( + /** + * DIV element to place the chart into + */ + container: HTMLDivElement | string, + /** + * Options + */ + options: ElevationProfileOptions = {} + ) { + const appContainer = + typeof container === "string" + ? document.getElementById(container) + : container; + if (!appContainer) { + throw new Error("The container does not exist."); + } + + this.canvas = document.createElement("canvas"); + appContainer.appendChild(this.canvas); + + Chart.register(...registerables); + Chart.register(zoomPlugin); + Chart.register(CrosshairPlugin); + + this.settings = { + ...elevationProfileDefaultOptions, + ...options, + }; + + const gradeColor = [ + "#0d0887", // 0% and less + "#3e049c", // 1 + "#6300a7", // 2 + "#8606a6", // 3 + "#a62098", // 4 + "#c03a83", // 5 + "#d5546e", // 6 + "#e76f5a", // 7 + "#f68d45", // 8 + "#fdae32", // 9 + "#fcd225", // 10 + "#f0f921", // more than 10% + ] + + const distanceUnit = this.settings.unit === "imperial" ? "mi" : "km"; + const elevationUnit = this.settings.unit === "imperial" ? "ft" : "m"; + Chart.defaults.font.size = this.settings.fontSize; + + let width: number, height: number, gradient: CanvasGradient; + this.chart = new Chart<"line", Array, number>(this.canvas, { + type: "line", + + data: { + labels: [], + datasets: [ + { + label: "Elevation", + yAxisID: "y", + data: [], + pointRadius: 0, + fill: !!this.settings.profileBackgroundColor, + borderColor: (context) => { + const { ctx, chartArea } = context.chart; + + if (!chartArea) { + return; + } + + const chartWidth = chartArea.right - chartArea.left; + const chartHeight = chartArea.bottom - chartArea.top; + if (!gradient || width !== chartWidth || height !== chartHeight) { + width = chartWidth; + height = chartHeight; + + gradient = ctx.createLinearGradient(chartArea.left, 0, chartArea.right, 0); + + gradient.addColorStop(0, gradeColor[0]) + + let prevColor = gradeColor[0]; + for (let i = 0; i < this.grade.length; i++) { + const grade = ~~(Math.abs(this.grade[i]) / 2.5); + + let color; + if (grade < 1) { + color = gradeColor[0] + } + else if (grade > 10) { + color = gradeColor[11]; + } else { + color = gradeColor[grade]; + } + + + if (color !== prevColor) { + const percentDone = this.cumulatedDistance[i] / this.cumulatedDistance[this.cumulatedDistance.length - 1] + gradient.addColorStop(percentDone, color); + prevColor = color; + } + + } + } + + return gradient; + }, + // borderColor: this.settings.profileLineColor ?? "#0000", + backgroundColor: this.settings.profileBackgroundColor ?? "#0000", + tension: 0.1, + spanGaps: true, + + // If line color is null, the line width is set to 0 + borderWidth: this.settings.profileLineColor + ? this.settings.profileLineWidth + : 0, + } + ], + }, + + options: { + layout: { + padding: { + left: this.settings.paddingLeft, + right: this.settings.paddingRight, + bottom: this.settings.paddingBottom, + top: this.settings.paddingTop, + }, + }, + onClick: (_e, item) => { + if (typeof this.settings.onClick !== "function") return; + + try { + const i = item[0].index; + + this.settings.onClick.apply(this, [ + { + position: this.elevatedPositionsAdjustedUnit[i], + distance: this.cumulatedDistanceAdjustedUnit[i], + dPlus: this.cumulatedDPlus[i], + gradePercent: this.grade[i], + }, + ]); + } catch (e) { + // Nothing to do + } + }, + onHover: (_e, item) => { + if (typeof this.settings.onMove !== "function") return; + + try { + const i = item[0].index; + + this.settings.onMove.apply(this, [ + { + position: this.elevatedPositionsAdjustedUnit[i], + distance: this.cumulatedDistanceAdjustedUnit[i], + dPlus: this.cumulatedDPlus[i], + gradePercent: this.grade[i], + }, + ]); + } catch (e) { + // Nothing to do + } + }, + animation: false, + maintainAspectRatio: false, + scales: { + x: { + min: 0, + max: 0, + type: "linear", + grid: { + display: this.settings.displayDistanceGrid, + drawOnChartArea: false, + color: this.settings.distanceGridColor, + drawTicks: true, + tickLength: 5, + tickColor: "#0002" + }, + ticks: { + stepSize: 0.1, + align: "inner", + display: this.settings.displayDistanceLabels, + color: this.settings.labelColor, + maxRotation: 0, + callback: (value, index) => { + if (index % 10 !== 0) { + return ""; + } + const roundedValue = ~~((value as number) * 100) / 100; + return this.settings.displayUnits + ? `${roundedValue} ${distanceUnit}` + : roundedValue; + }, + }, + }, + y: { + min: 0, + max: 0, + type: "linear", + afterTickToLabelConversion: (scaleInstance) => { + scaleInstance.ticks.pop(); + scaleInstance.ticks.shift(); + }, + ticks: { + mirror: true, + maxTicksLimit: 10, + align: "end", + display: this.settings.displayElevationLabels, + color: this.settings.labelColor, + + callback: (value) => { + const roundedValue = ~~((value as number) * 100) / 100; + return this.settings.displayUnits + ? `${roundedValue} ${elevationUnit}` + : roundedValue; + }, + }, + border: { + dash: [5, 5], + display: true, + color: this.settings.elevationGridColor, + }, + grid: { + display: this.settings.displayElevationGrid, + color: this.settings.elevationGridColor, + drawTicks: false, + }, + }, + }, + + interaction: { + intersect: false, + mode: "index", + }, + + plugins: { + zoom: { + zoom: { + wheel: { + enabled: true, + }, + pinch: { + enabled: true, + }, + mode: "x", + }, + pan: { + enabled: true, + mode: "x", + }, + limits: { + x: { + min: "original", + max: "original", + }, + }, + }, + title: { + display: false, + }, + legend: { + display: false, + }, + tooltip: { + enabled: this.settings.displayTooltip, + yAlign: "center", + cornerRadius: 3, + displayColors: false, + backgroundColor: this.settings.tooltipBackgroundColor, + bodyColor: this.settings.tooltipTextColor, + callbacks: { + title: () => { + return ""; + }, + + label: (tooltipItem) => { + if (tooltipItem.datasetIndex != 0) { + return ""; + } + const tooltipInfo = []; + if (this.settings.tooltipDisplayDistance) { + tooltipInfo.push( + `After: ${this.cumulatedDistanceAdjustedUnit[ + tooltipItem.dataIndex + ].toFixed(4)} ${distanceUnit} ${this.cumulatedTime.length ? '(' + formatTimeHHMM(this.cumulatedTime[tooltipItem.dataIndex]) + ')' : ''}` + ); + } + + if (this.settings.tooltipDisplayElevation) { + tooltipInfo.push( + `Elevation: ${this.elevatedPositionsAdjustedUnit[ + tooltipItem.dataIndex + ][2].toFixed(2)} ${elevationUnit}` + ); + } + + if (this.settings.tooltipDisplayDPlus) { + tooltipInfo.push( + `D+: ${this.cumulatedDPlus[tooltipItem.dataIndex].toFixed( + 0 + )} ${elevationUnit}` + ); + } + + if (this.settings.tooltipDisplayGrade) { + tooltipInfo.push( + `Grade: ${this.grade[tooltipItem.dataIndex].toFixed(1)}%` + ); + } + + if (this.settings.tooltipDisplaySpeed && this.speed.length) { + tooltipInfo.push(`Speed: ${this.speed[ + tooltipItem.dataIndex + ].toFixed(2)} ${distanceUnit}/h` + ); + } + + return tooltipInfo; + }, + }, + }, + + // The crosshair plugin does not have types + // @ts-ignore + crosshair: { + zoom: { + enabled: false, + }, + line: { + color: this.settings.displayCrosshair + ? this.settings.crosshairColor + : "#0000", + width: 1, + }, + }, + }, + }, + + plugins: [ + { + id: "waypointPlugin", + afterDraw: (chart, args, options) => { + const waypointContainer = document.getElementById("waypoint-container") as HTMLDivElement; + waypointContainer.innerHTML = ""; // Clear previous ticks + + const xScale = chart.scales.x; // Get X-axis scale + const chartRect = chart.canvas.getBoundingClientRect(); // Canvas position + + this.waypointPositions.forEach((position: number, index: number) => { + + const xPos = xScale.getPixelForValue(position); // X-axis pixel for tick + console.log(xPos); + + // Create custom HTML tick + const wpDiv = document.createElement("div"); + wpDiv.className = "wp-marker absolute -translate-x-1/2 w-6 aspect-square bg-background-inverse rounded-full flex justify-center items-center text-content-inverse cursor-pointer hover:scale-110"; + wpDiv.style.left = `${xPos}px`; // Position horizontally + wpDiv.style.top = `8px`; // Position horizontally + + // Add custom HTML content (e.g., icon + label) + wpDiv.innerHTML = ``; + + waypointContainer.appendChild(wpDiv); // Add to container + }); + } + }, + { + id: "customZoomEvent", + afterDataLimits: () => { + if (typeof this.settings.onChangeView !== "function") return; + try { + this.settings.onChangeView.apply(this, [ + this.createWindowExtractLineString(), + ]); + } catch (e) { + // nothing to do + } + }, + }, + ], + }); + if (typeof this.settings.onLeave === "function") { + this.chart.canvas.addEventListener("mouseout", this.settings.onLeave) + } + + if (typeof this.settings.onEnter === "function") { + this.chart.canvas.addEventListener("mouseenter", this.settings.onEnter) + } + + // If the tooltip is shown, then we hide it when panning the chart + if (this.settings.displayTooltip) { + let mouseDown = false; + this.chart.canvas.addEventListener("mousedown", () => { + mouseDown = true; + }); + + this.chart.canvas.addEventListener("mousemove", () => { + if ( + mouseDown && + this.chart.options.plugins && + this.chart.options.plugins.tooltip + ) { + this.chart.options.plugins.tooltip.enabled = false; + this.chart.update(); + } + }); + + window.addEventListener("mouseup", () => { + if (this.chart.options.plugins && this.chart.options.plugins.tooltip) { + this.chart.options.plugins.tooltip.enabled = true; + this.chart.update(); + mouseDown = false; + } + }); + } + } + + createWindowExtractLineString(): LineString { + const scaleMin = this.chart.scales.x.min; + const scaleMax = this.chart.scales.x.max; + + const cda = this.cumulatedDistanceAdjustedUnit; + const nbElem = cda.length; + + let indexStart = 0; + let indexEnd = nbElem - 1; + + // find the start index + for (let i = 0; i < nbElem; i += 1) { + if (cda[i] >= scaleMin) { + indexStart = i; + break; + } + } + + // find the end index + for (let i = nbElem - 1; i >= indexStart; i -= 1) { + if (cda[i] <= scaleMax) { + indexEnd = i; + break; + } + } + + return this.createExtractLineString(indexStart, indexEnd); + } + + createExtractLineString(fromIndex: number, toIndex: number): LineString { + const elevatedPositionsWindow: Position[] = this.elevatedPositions.slice( + fromIndex, + toIndex + ); + + return { + type: "LineString", + coordinates: elevatedPositionsWindow, + }; + } + + toggleTheme(options: ElevationProfileOptions) { + this.settings = { + ...this.settings, + ...options, + }; + this.chart.data.datasets[0].backgroundColor = this.settings.profileBackgroundColor ?? "#0000"; + this.chart.options.scales!.x!.ticks!.color = this.settings.labelColor; + + this.chart.options.scales!.y!.grid!.color = this.settings.elevationGridColor; + this.chart.options.scales!.y!.border!.color = this.settings.elevationGridColor; + this.chart.options.scales!.y!.ticks!.color = this.settings.labelColor; + + (this.chart.options.plugins as any).crosshair!.line.color = this.settings.crosshairColor; + + this.chart.update(); + } + + async setData(data: GeoJsonObject, waypoints?: Waypoint[]) { + // Concatenates the positions that may come from multiple LineStrings or MultiLineString + const { positions, times } = geoJsonObjectToPositionsAndTimes(data); + + this.times = times; + + this.elevatedPositions = smoothElevations(positions, positions.length / 100); + + this.cumulatedDistance = haversineCumulatedDistanceWgs84( + this.elevatedPositions + ); + + // Conversion of distance to miles and elevation to feet + if (this.settings.unit === "imperial") { + this.cumulatedDistanceAdjustedUnit = this.cumulatedDistance.map( + (dist) => dist * MILES_PER_METER + ); + this.elevatedPositionsAdjustedUnit = this.elevatedPositions.map((pos) => [ + pos[0], + pos[1], + pos[2] * FEET_PER_METER, + ]); + this.cumulatedDPlus = this.cumulatedDPlus.map( + (ele) => ele * FEET_PER_METER + ); + } else { + this.cumulatedDistanceAdjustedUnit = this.cumulatedDistance.map( + (dist) => dist / 1000 + ); // we still need to convert distance to km + this.elevatedPositionsAdjustedUnit = this.elevatedPositions; + } + + this.cumulatedDPlus = []; + this.grade = []; + this.waypoints = waypoints ?? []; + // this.waypointPositions = []; + + let cumulatedDPlus = 0; + let cumulatedTime = 0; + + const minSegmentDistance = (this.cumulatedDistance.at(-1) ?? 1000) / 100; + let segmentStartIndex = 0; + + // Initialize an array to store the minimum distance for each waypoint + const minDistances = new Array(waypoints?.length).fill(Infinity); + + for (let i = 0; i < this.elevatedPositions.length; i++) { + + // Check if a waypoint is closest to this point + this.waypoints.forEach((waypoint, waypointIndex) => { + const distance = haversineDistance(this.elevatedPositions[i][1], this.elevatedPositions[i][0], waypoint.lat, waypoint.lon); + + // Update if the current route coordinate is closer to the waypoint + if (distance < minDistances[waypointIndex]) { + minDistances[waypointIndex] = distance; + this.waypointPositions[waypointIndex] = this.cumulatedDistanceAdjustedUnit[i]; + } + }); + + const elevation = this.elevatedPositions[i][2]; + const time = this.times[i] + if (i > 1) { + const elevationPrevious = this.elevatedPositions[i - 1][2]; + const elevationDelta = elevation - elevationPrevious; + const segmentDistance = + this.cumulatedDistance[i] - this.cumulatedDistance[segmentStartIndex]; + cumulatedDPlus += Math.max(0, elevationDelta); + this.cumulatedDPlus.push(cumulatedDPlus); + + if (time) { + const timePrevious = this.times[i - 1]; + const timeDelta = (time.getTime() - timePrevious.getTime()) / (1000 * 60); + cumulatedTime += timeDelta; + this.cumulatedTime.push(cumulatedTime) + } + + + // Check if the segment distance is greater than or equal to the minimum threshold + if (segmentDistance >= minSegmentDistance || i === this.elevatedPositions.length - 1) { + // Calculate the grade for the consolidated segment + const elevationStart = this.elevatedPositions[segmentStartIndex][2]; + const elevationEnd = this.elevatedPositions[i][2]; + const elevationDelta = elevationEnd - elevationStart; + const gradePercent = (elevationDelta / segmentDistance) * 100; // Grade as a percentage + + let speed; + if (this.times.length) { + const distanceStart = this.cumulatedDistance[segmentStartIndex] + const distanceEnd = this.cumulatedDistance[i] + const distanceDelta = distanceEnd - distanceStart + + const timeStart = this.times[segmentStartIndex] + const timeEnd = this.times[i] + const timeDelta = (timeEnd.getTime() - timeStart.getTime()) / 1000 + + speed = (distanceDelta / timeDelta) + if (this.settings.unit === "imperial") { + speed = speed * MILES_HOUR_PER_METER_SECOND + } else { + speed = speed * KILOMETERS_HOUR_PER_METER_SECOND; + } + } + + + // Apply the same grade to all positions within this segment + for (let j = segmentStartIndex; j <= i; j++) { + this.grade.push(gradePercent); + if (speed) { + this.speed.push(speed) + } + } + + // Move to the next segment + segmentStartIndex = i + 1; + } + } + } + + this.grade.push(this.grade.at(-1) ?? 0); + this.cumulatedDPlus.push(cumulatedDPlus); + + + let minElevation = +Infinity; + let maxElevation = -Infinity; + + for (let i = 0; i < this.elevatedPositionsAdjustedUnit.length; i += 1) { + if (this.elevatedPositionsAdjustedUnit[i][2] < minElevation) { + minElevation = this.elevatedPositionsAdjustedUnit[i][2]; + } + + if (this.elevatedPositionsAdjustedUnit[i][2] > maxElevation) { + maxElevation = this.elevatedPositionsAdjustedUnit[i][2]; + } + } + + const elevationPadding = (maxElevation - minElevation) * 0.1; + this.chart.data.labels = this.cumulatedDistanceAdjustedUnit; + this.chart.data.datasets[0].data = this.elevatedPositionsAdjustedUnit.map( + (pos) => pos[2] + ); + + if ( + this.chart.options.scales && + this.chart.options.scales.x && + this.chart.options.scales.y + ) { + this.chart.options.scales.x.min = this.cumulatedDistanceAdjustedUnit[0]; + this.chart.options.scales.x.max = + this.cumulatedDistanceAdjustedUnit[ + this.cumulatedDistanceAdjustedUnit.length - 1 + ]; + + + this.chart.options.scales.y.min = minElevation - elevationPadding; + this.chart.options.scales.y.max = maxElevation + elevationPadding; + } + this.chart.update(); + } + +} \ No newline at end of file diff --git a/web/src/lib/vendor/maplibre-elevation-profile/tools.ts b/web/src/lib/vendor/maplibre-elevation-profile/tools.ts new file mode 100644 index 00000000..42eb549f --- /dev/null +++ b/web/src/lib/vendor/maplibre-elevation-profile/tools.ts @@ -0,0 +1,47 @@ +import { haversineDistance } from "$lib/models/gpx/utils"; +import type { + Position +} from "geojson"; + + +export function haversineCumulatedDistanceWgs84(path: Position[]): number[] { + if (path.length < 2) { + return []; + } + + let totalDistance = 0; + const distances: number[] = [0]; + + for (let i = 0; i < path.length - 1; i++) { + const [lon1, lat1] = path[i]; + const [lon2, lat2] = path[i + 1]; + + totalDistance += haversineDistance(lat1, lon1, lat2, lon2); + distances.push(totalDistance); + } + + return distances; // Array of cumulative distances in meters +} + +export function smoothElevations(positions: Position[], windowSize: number): Position[] { + // Ensure windowSize is valid (at least 1) + if (windowSize < 1) throw new Error("Window size must be at least 1."); + + // Create a new array with smoothed elevations + return positions.map((pos, i, arr) => { + const start = Math.max(0, i - Math.floor(windowSize / 2)); // Start index for the window + const end = Math.min(arr.length, i + Math.floor(windowSize / 2) + 1); // End index for the window + const segment = arr.slice(start, end); // Extract the positions in the window + + // Calculate the weighted moving average of elevations + const weights = segment.map((_, idx) => idx + 1); // Increasing weights: 1, 2, 3... + const elevations = segment.map(p => p[2]); // Extract elevations + const weightedSum = elevations.reduce((sum, elevation, idx) => sum + elevation * weights[idx], 0); + const weightTotal = weights.reduce((sum, weight) => sum + weight, 0); + + const smoothedElevation = weightedSum / weightTotal; // Weighted average elevation + + // Return a new Position with the smoothed elevation + return [pos[0], pos[1], smoothedElevation] as Position; + }); +} \ No newline at end of file diff --git a/web/src/routes/+error.svelte b/web/src/routes/+error.svelte index 0e7ad993..793786a0 100644 --- a/web/src/routes/+error.svelte +++ b/web/src/routes/+error.svelte @@ -7,7 +7,7 @@ style="height: calc(100dvh - 120px); font-size: 4rem" > Default error img - import MapWithElevation from "$lib/components/trail/map_with_elevation.svelte"; + import MapWithElevationMaplibre from "$lib/components/trail/map_with_elevation_maplibre.svelte"; import TrailInfoPanel from "$lib/components/trail/trail_info_panel.svelte"; import { trail } from "$lib/stores/trail_store"; import "$lib/vendor/leaflet-elevation/src/index.css"; - import type { Marker } from "leaflet"; import "leaflet.awesome-markers/dist/leaflet.awesome-markers.css"; import "leaflet/dist/leaflet.css"; + import * as M from "maplibre-gl"; import "photoswipe/style.css"; import { _ } from "svelte-i18n"; - let markers: Marker[]; + let markers: M.Marker[]; @@ -18,7 +18,7 @@
- +
diff --git a/web/src/routes/tmp/+page.svelte b/web/src/routes/tmp/+page.svelte new file mode 100644 index 00000000..3507c190 --- /dev/null +++ b/web/src/routes/tmp/+page.svelte @@ -0,0 +1,8 @@ + + + diff --git a/web/src/routes/tmp/+page.ts b/web/src/routes/tmp/+page.ts new file mode 100644 index 00000000..37a18b6e --- /dev/null +++ b/web/src/routes/tmp/+page.ts @@ -0,0 +1,9 @@ +import type { Trail } from "$lib/models/trail"; +import { trails_show } from "$lib/stores/trail_store"; + +export const load = async ({ params, fetch }) => { + const t: Trail = await trails_show("yesm2tqc6jok8jq", true, fetch) + + + return { trail: t } +}; \ No newline at end of file diff --git a/web/src/routes/trail/edit/[id]/+page.svelte b/web/src/routes/trail/edit/[id]/+page.svelte index 1d36d933..6a1589dc 100644 --- a/web/src/routes/trail/edit/[id]/+page.svelte +++ b/web/src/routes/trail/edit/[id]/+page.svelte @@ -296,7 +296,6 @@ elevation_loss: $form.elevation_loss, duration: $form.duration ? $form.duration * 60 : undefined, }); - console.log(selectedFile.type); log.expand.gpx_data = gpxData; const blob = new Blob([gpxData], { type: selectedFile.type }); diff --git a/web/tailwind.config.js b/web/tailwind.config.js index 25d14472..db213aca 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -5,7 +5,9 @@ export default { extend: { colors: { 'background': 'rgba(var(--background))', + 'background-inverse': 'rgba(var(--background-inverse))', 'content': 'rgba(var(--content))', + 'content-inverse': 'rgba(var(--content-inverse))', 'primary': 'rgba(var(--primary))', 'primary-hover': 'rgba(var(--primary-hover))',