feat(hikes): route-builder, overview map + cards, SAC-coloured pipeline
Build pipeline (scripts/build-hikes.ts) parses per-hike GPX, encodes images via sharp, reverse-geocodes the centroid against Swisstopo and emits a typed manifest under src/lib/data/hikes.generated.ts (gitignored). Track JSON + image binaries live outside /static; served in dev by a small hike-images plugin in vite.config.ts, in prod by nginx (private/ images proxied through Node + X-Accel-Redirect for auth-gating). /hikes overview: full-bleed Swisstopo hero map (HikesOverviewMap) sits under the sticky nav, drawing one polyline per route coloured by SAC tier (T1 yellow Wegweiser, T2/T3 white-red-white, T4-T6 white-blue- white). Click navigates, hover thickens + tooltips. Layer toggle, recenter, GPS controls mirror the detail map (minus images toggle). Cards drop the trail SVG, gain a per-route icon + SAC marker pictogram on the cover, altitude range, season label, and "Neu" badge for recently-published hikes. Filter bar + totals strip recompute over the currently-visible set. /hikes/[slug]: hero map with elevation profile, photo strip with map sync, scroll-position pin, GPX download, SAC marker stats + min/max altitude + season. Route-builder (/hikes/route-builder): client-side draft persisted to localStorage, EXIF-driven image placement, snap-to-route via BRouter (OSRM + linear fallback) and Swisstopo profile.json elevation enrichment that handles degenerate same-coord segments via the height endpoint. Filter init switched from a script-time snapshot of data.hikes (which sporadically returned a one-hike subset during dev hydration and locked the page to that single hike) to a post-mount \$effect. Content under src/content/hikes/ intentionally not included (WIP).
This commit is contained in:
+15
@@ -15,6 +15,21 @@ data/usda/
|
|||||||
# Loyalty-card barcodes (regenerated by scripts/generate-loyalty-cards.ts from env)
|
# Loyalty-card barcodes (regenerated by scripts/generate-loyalty-cards.ts from env)
|
||||||
static/shopping/supercard.svg
|
static/shopping/supercard.svg
|
||||||
static/shopping/cumulus.svg
|
static/shopping/cumulus.svg
|
||||||
|
# Hikes build outputs (regenerated by scripts/build-hikes.ts at prebuild)
|
||||||
|
static/hikes/
|
||||||
|
hikes-assets/
|
||||||
|
src/lib/data/hikes.generated.ts
|
||||||
|
# Build-script disk caches (Swisstopo identify, BRouter responses, ...)
|
||||||
|
scripts/.cache/
|
||||||
|
# Loose working-tree scratch files (notes, photos, prototypes) that aren't
|
||||||
|
# part of the committed source.
|
||||||
|
/HIKES_PLAN.md
|
||||||
|
/additional_apologetics.md
|
||||||
|
/header_jellyfin.html
|
||||||
|
/person-hiking.svg
|
||||||
|
/PXL_*.jpg
|
||||||
|
/PXL_*.MP.jpg
|
||||||
|
src-tauri/icons/_safezone_template_*.png
|
||||||
src-tauri/target/
|
src-tauri/target/
|
||||||
src-tauri/*.keystore
|
src-tauri/*.keystore
|
||||||
# Android: ignore build output and caches, track source files
|
# Android: ignore build output and caches, track source files
|
||||||
|
|||||||
@@ -24,10 +24,18 @@ Order = impact. Font items + app.html preload intentionally skipped.
|
|||||||
[x] BF graph (with trend line like weight graph) on /fitness/stats page. Emphasize relative changes, not absolute numbers in design (as we cannot trust those) (e.g., use start day of overview as 0% and then show +/- x % on the graph)
|
[x] BF graph (with trend line like weight graph) on /fitness/stats page. Emphasize relative changes, not absolute numbers in design (as we cannot trust those) (e.g., use start day of overview as 0% and then show +/- x % on the graph)
|
||||||
[x] Workshop better names than "Measure" for the /fitness/measure route. It's about body data points (i.e., non-food related). What's a better, short name than "Measure" to capture the logging of weight, body composition, body part measurements, and period tracking?
|
[x] Workshop better names than "Measure" for the /fitness/measure route. It's about body data points (i.e., non-food related). What's a better, short name than "Measure" to capture the logging of weight, body composition, body part measurements, and period tracking?
|
||||||
[x] on /fitness/stats/histoy/<part> for body measurement graphs, make the range reasonable. e.g., if we have 1 cm change, do not fill the entire y-height with 1 cm. Use reasonable padding for low ranges (i think we do something like htis already on the weight graph?)
|
[x] on /fitness/stats/histoy/<part> for body measurement graphs, make the range reasonable. e.g., if we have 1 cm change, do not fill the entire y-height with 1 cm. Use reasonable padding for low ranges (i think we do something like htis already on the weight graph?)
|
||||||
[ ] on /fitness/check-in, Make the Period ended button a lot more prominent in the period tracker component.
|
[x] on /fitness/check-in, Make the Period ended button a lot more prominent in the period tracker component.
|
||||||
[ ] swap heart emoji on recipe favorites to lucide icon
|
[x] swap heart emoji on recipe favorites to lucide icon
|
||||||
[ ] coop and migros cards on shopping list for scanning
|
[x] coop and migros cards on shopping list for scanning
|
||||||
[ ] login icon from lucide in header
|
[x] login icon from lucide in header
|
||||||
|
[ ] Investigate self-hosting BRouter
|
||||||
|
[ ] Use the same color swisstopo map both for light and dark mode (currentyl only light mode)
|
||||||
|
[ ] pre-compute required map tiles for all tiles on the route (and adjacent enough to be visibile by default on sane screen sizes) and create a fetch instruction for the server. (separate step: create a swiss-topo caching service which smoothly interpolates with non-switzerland service tiles for spots outside of switzerland)
|
||||||
|
[ ] expand compatibility outside of switzerland with non-swiss topo map
|
||||||
|
[ ] align design better with swizterland mobility
|
||||||
|
[ ] allow for difficulty cardio, difficulty technique and T1-T6 labelling
|
||||||
|
[ ] allow for Switzerland Mobility like hike icons (with alpine blue white blue, red white red, and yellow hiking shields as a fallback alternative)
|
||||||
|
[ ] Add smoothing distance for elevation calculations on GPS-tracled workouts (3 meters? more?)
|
||||||
|
|
||||||
## Refactor Recipe Search Component
|
## Refactor Recipe Search Component
|
||||||
|
|
||||||
@@ -39,3 +47,45 @@ Refactor `src/lib/components/Search.svelte` to use the new `SearchInput.svelte`
|
|||||||
Files involved:
|
Files involved:
|
||||||
- `src/lib/components/Search.svelte` - refactor to use SearchInput
|
- `src/lib/components/Search.svelte` - refactor to use SearchInput
|
||||||
- `src/lib/components/SearchInput.svelte` - the reusable input component
|
- `src/lib/components/SearchInput.svelte` - the reusable input component
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
1. $app/stores → $app/state (biggest, most mechanical)
|
||||||
|
Old: import { page } from '$app/stores' + $page.url.pathname
|
||||||
|
New: import { page } from '$app/state' + page.url.pathname (no $, it's a rune now).
|
||||||
|
Runes-based, smaller bundle (no store wrapper), cleaner SSR. Codebase has dozens of $app/stores imports — same kind
|
||||||
|
of codemod-able migration as hrefs. Available since 2.12. $app/stores is deprecated.
|
||||||
|
|
||||||
|
2. Convert legacy stores to .svelte.ts rune state
|
||||||
|
Files like $lib/stores/recipeTranslation.ts, $lib/stores/language.ts use writable(). Modern pattern: .svelte.ts files
|
||||||
|
with $state() + exported getters/setters. Better TS inference, no $ prefix, no auto-subscription gotchas.
|
||||||
|
|
||||||
|
3. Remote functions for new API code ($app/server, since 2.27)
|
||||||
|
Replaces hand-rolled +server.ts + client fetch with type-safe server functions called like normal funcs. Major
|
||||||
|
refactor for existing /api/** (lots of files), so probably only adopt for new endpoints — not worth churning the
|
||||||
|
existing ~80 API routes.
|
||||||
|
|
||||||
|
4. prerender = true audit
|
||||||
|
Static-ish pages (faith catechesis, latin prayers, apologetics arguments) are great candidates. Skip-SSR for static
|
||||||
|
content = faster cold loads + cheaper hosting. Currently nothing's prerendered — quick win where applicable.
|
||||||
|
|
||||||
|
5. @sveltejs/enhanced-img
|
||||||
|
Transparent image optimization (responsive srcset, AVIF/WebP, blur placeholders) at build time. Recipe hero images
|
||||||
|
and saint-day cards would benefit visibly. Drop-in via <enhanced:img src="...">.
|
||||||
|
|
||||||
|
6. {@attach} over use: (Svelte 5 attachments)
|
||||||
|
Newer API for DOM-lifecycle hooks. Supports spread + library composition use: can't. Low urgency; only matters when
|
||||||
|
writing new lifecycle code.
|
||||||
|
|
||||||
|
7. Shallow routing for modals/galleries
|
||||||
|
pushState + <a> flow lets modals participate in history without full navigation. Useful if you ever add a
|
||||||
|
recipe-image lightbox or apologetics-arg overlay. Net-new feature, not a migration.
|
||||||
|
|||||||
+4
-2
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "homepage",
|
"name": "homepage",
|
||||||
"version": "1.70.2",
|
"version": "1.71.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"prebuild": "bash scripts/subset-emoji-font.sh && pnpm exec vite-node scripts/generate-mystery-verses.ts && pnpm exec vite-node scripts/download-models.ts && pnpm exec vite-node scripts/generate-loyalty-cards.ts && pnpm exec vite-node scripts/generate-error-quotes.ts",
|
"prebuild": "bash scripts/subset-emoji-font.sh && pnpm exec vite-node scripts/generate-mystery-verses.ts && pnpm exec vite-node scripts/download-models.ts && pnpm exec vite-node scripts/generate-loyalty-cards.ts && pnpm exec vite-node scripts/generate-error-quotes.ts && pnpm exec vite-node scripts/build-hikes.ts",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"postbuild": "pnpm exec vite-node scripts/build-error-page.ts",
|
"postbuild": "pnpm exec vite-node scripts/build-error-page.ts",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
@@ -40,6 +40,7 @@
|
|||||||
"@vitest/ui": "^4.1.2",
|
"@vitest/ui": "^4.1.2",
|
||||||
"bwip-js": "^4.10.1",
|
"bwip-js": "^4.10.1",
|
||||||
"jsdom": "^27.2.0",
|
"jsdom": "^27.2.0",
|
||||||
|
"mdsvex": "^0.12.7",
|
||||||
"svelte": "^5.55.1",
|
"svelte": "^5.55.1",
|
||||||
"svelte-check": "^4.4.6",
|
"svelte-check": "^4.4.6",
|
||||||
"tslib": "^2.8.1",
|
"tslib": "^2.8.1",
|
||||||
@@ -59,6 +60,7 @@
|
|||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"chartjs-adapter-date-fns": "^3.0.0",
|
"chartjs-adapter-date-fns": "^3.0.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"exifr": "^7.1.3",
|
||||||
"file-type": "^19.0.0",
|
"file-type": "^19.0.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"mongoose": "^9.4.1",
|
"mongoose": "^9.4.1",
|
||||||
|
|||||||
Generated
+86
@@ -38,6 +38,9 @@ importers:
|
|||||||
date-fns:
|
date-fns:
|
||||||
specifier: ^4.1.0
|
specifier: ^4.1.0
|
||||||
version: 4.1.0
|
version: 4.1.0
|
||||||
|
exifr:
|
||||||
|
specifier: ^7.1.3
|
||||||
|
version: 7.1.3
|
||||||
file-type:
|
file-type:
|
||||||
specifier: ^19.0.0
|
specifier: ^19.0.0
|
||||||
version: 19.6.0
|
version: 19.6.0
|
||||||
@@ -99,6 +102,9 @@ importers:
|
|||||||
jsdom:
|
jsdom:
|
||||||
specifier: ^27.2.0
|
specifier: ^27.2.0
|
||||||
version: 27.2.0
|
version: 27.2.0
|
||||||
|
mdsvex:
|
||||||
|
specifier: ^0.12.7
|
||||||
|
version: 0.12.7(svelte@5.55.1)
|
||||||
svelte:
|
svelte:
|
||||||
specifier: ^5.55.1
|
specifier: ^5.55.1
|
||||||
version: 5.55.1
|
version: 5.55.1
|
||||||
@@ -1079,6 +1085,9 @@ packages:
|
|||||||
'@types/leaflet@1.9.21':
|
'@types/leaflet@1.9.21':
|
||||||
resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==}
|
resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==}
|
||||||
|
|
||||||
|
'@types/mdast@4.0.4':
|
||||||
|
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
|
||||||
|
|
||||||
'@types/node-cron@3.0.11':
|
'@types/node-cron@3.0.11':
|
||||||
resolution: {integrity: sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==}
|
resolution: {integrity: sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==}
|
||||||
|
|
||||||
@@ -1091,6 +1100,9 @@ packages:
|
|||||||
'@types/trusted-types@2.0.7':
|
'@types/trusted-types@2.0.7':
|
||||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||||
|
|
||||||
|
'@types/unist@2.0.11':
|
||||||
|
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
|
||||||
|
|
||||||
'@types/webidl-conversions@7.0.0':
|
'@types/webidl-conversions@7.0.0':
|
||||||
resolution: {integrity: sha512-xTE1E+YF4aWPJJeUzaZI5DRntlkY3+BCVJi0axFptnjGmAoWxkyREIh/XMrfxVLejwQxMCfDXdICo0VLxThrog==}
|
resolution: {integrity: sha512-xTE1E+YF4aWPJJeUzaZI5DRntlkY3+BCVJi0axFptnjGmAoWxkyREIh/XMrfxVLejwQxMCfDXdICo0VLxThrog==}
|
||||||
|
|
||||||
@@ -1341,6 +1353,9 @@ packages:
|
|||||||
estree-walker@3.0.3:
|
estree-walker@3.0.3:
|
||||||
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||||
|
|
||||||
|
exifr@7.1.3:
|
||||||
|
resolution: {integrity: sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==}
|
||||||
|
|
||||||
expect-type@1.3.0:
|
expect-type@1.3.0:
|
||||||
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
@@ -1585,6 +1600,11 @@ packages:
|
|||||||
mdn-data@2.12.2:
|
mdn-data@2.12.2:
|
||||||
resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==}
|
resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==}
|
||||||
|
|
||||||
|
mdsvex@0.12.7:
|
||||||
|
resolution: {integrity: sha512-gx4bReLCUvq+MPErHXYeyX+TEq1hsS2KfiZtEOMNTcbibSouFy8AHc5h04KbGCl+g5tLuo4/lbgRVYRnc7bJZw==}
|
||||||
|
peerDependencies:
|
||||||
|
svelte: ^3.56.0 || ^4.0.0 || ^5.0.0-next.120
|
||||||
|
|
||||||
memory-pager@1.5.0:
|
memory-pager@1.5.0:
|
||||||
resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==}
|
resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==}
|
||||||
|
|
||||||
@@ -1738,6 +1758,13 @@ packages:
|
|||||||
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
|
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
|
||||||
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
|
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
|
||||||
|
|
||||||
|
prism-svelte@0.4.7:
|
||||||
|
resolution: {integrity: sha512-yABh19CYbM24V7aS7TuPYRNMqthxwbvx6FF/Rw920YbyBWO3tnyPIqRMgHuSVsLmuHkkBS1Akyof463FVdkeDQ==}
|
||||||
|
|
||||||
|
prismjs@1.30.0:
|
||||||
|
resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
protobufjs@7.5.4:
|
protobufjs@7.5.4:
|
||||||
resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==}
|
resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
@@ -1968,6 +1995,21 @@ packages:
|
|||||||
undici-types@6.21.0:
|
undici-types@6.21.0:
|
||||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||||
|
|
||||||
|
unist-util-is@4.1.0:
|
||||||
|
resolution: {integrity: sha512-ZOQSsnce92GrxSqlnEEseX0gi7GH9zTJZ0p9dtu87WRb/37mMPO2Ilx1s/t9vBHrFhbgweUwb+t7cIn5dxPhZg==}
|
||||||
|
|
||||||
|
unist-util-stringify-position@2.0.3:
|
||||||
|
resolution: {integrity: sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g==}
|
||||||
|
|
||||||
|
unist-util-visit-parents@3.1.1:
|
||||||
|
resolution: {integrity: sha512-1KROIZWo6bcMrZEwiH2UrXDyalAa0uqzWCxCJj6lPOvTve2WkfgCytoDTPaMnodXh1WrXOq0haVYHj99ynJlsg==}
|
||||||
|
|
||||||
|
unist-util-visit@2.0.3:
|
||||||
|
resolution: {integrity: sha512-iJ4/RczbJMkD0712mGktuGpm/U4By4FfDonL7N/9tATGIF4imikjOuagyMY53tnZq3NP6BcmlrHhEKAfGWjh7Q==}
|
||||||
|
|
||||||
|
vfile-message@2.0.4:
|
||||||
|
resolution: {integrity: sha512-DjssxRGkMvifUOJre00juHoP9DPWuzjxKuMDrhNbk2TdaYYBNMStsNhEOt3idrtI12VQYM/1+iM0KOzXi4pxwQ==}
|
||||||
|
|
||||||
vite-node@6.0.0:
|
vite-node@6.0.0:
|
||||||
resolution: {integrity: sha512-oj4PVrT+pDh6GYf5wfUXkcZyekYS8kKPfLPXVl8qe324Ec6l4K2DUKNadRbZ3LQl0qGcDz+PyOo7ZAh00Y+JjQ==}
|
resolution: {integrity: sha512-oj4PVrT+pDh6GYf5wfUXkcZyekYS8kKPfLPXVl8qe324Ec6l4K2DUKNadRbZ3LQl0qGcDz+PyOo7ZAh00Y+JjQ==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -2847,6 +2889,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/geojson': 7946.0.16
|
'@types/geojson': 7946.0.16
|
||||||
|
|
||||||
|
'@types/mdast@4.0.4':
|
||||||
|
dependencies:
|
||||||
|
'@types/unist': 2.0.11
|
||||||
|
|
||||||
'@types/node-cron@3.0.11': {}
|
'@types/node-cron@3.0.11': {}
|
||||||
|
|
||||||
'@types/node@22.18.0':
|
'@types/node@22.18.0':
|
||||||
@@ -2857,6 +2903,8 @@ snapshots:
|
|||||||
|
|
||||||
'@types/trusted-types@2.0.7': {}
|
'@types/trusted-types@2.0.7': {}
|
||||||
|
|
||||||
|
'@types/unist@2.0.11': {}
|
||||||
|
|
||||||
'@types/webidl-conversions@7.0.0': {}
|
'@types/webidl-conversions@7.0.0': {}
|
||||||
|
|
||||||
'@types/whatwg-url@13.0.0':
|
'@types/whatwg-url@13.0.0':
|
||||||
@@ -3096,6 +3144,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.8
|
'@types/estree': 1.0.8
|
||||||
|
|
||||||
|
exifr@7.1.3: {}
|
||||||
|
|
||||||
expect-type@1.3.0: {}
|
expect-type@1.3.0: {}
|
||||||
|
|
||||||
fdir@6.5.0(picomatch@4.0.3):
|
fdir@6.5.0(picomatch@4.0.3):
|
||||||
@@ -3321,6 +3371,16 @@ snapshots:
|
|||||||
|
|
||||||
mdn-data@2.12.2: {}
|
mdn-data@2.12.2: {}
|
||||||
|
|
||||||
|
mdsvex@0.12.7(svelte@5.55.1):
|
||||||
|
dependencies:
|
||||||
|
'@types/mdast': 4.0.4
|
||||||
|
'@types/unist': 2.0.11
|
||||||
|
prism-svelte: 0.4.7
|
||||||
|
prismjs: 1.30.0
|
||||||
|
svelte: 5.55.1
|
||||||
|
unist-util-visit: 2.0.3
|
||||||
|
vfile-message: 2.0.4
|
||||||
|
|
||||||
memory-pager@1.5.0: {}
|
memory-pager@1.5.0: {}
|
||||||
|
|
||||||
min-indent@1.0.1: {}
|
min-indent@1.0.1: {}
|
||||||
@@ -3442,6 +3502,10 @@ snapshots:
|
|||||||
ansi-styles: 5.2.0
|
ansi-styles: 5.2.0
|
||||||
react-is: 17.0.2
|
react-is: 17.0.2
|
||||||
|
|
||||||
|
prism-svelte@0.4.7: {}
|
||||||
|
|
||||||
|
prismjs@1.30.0: {}
|
||||||
|
|
||||||
protobufjs@7.5.4:
|
protobufjs@7.5.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@protobufjs/aspromise': 1.1.2
|
'@protobufjs/aspromise': 1.1.2
|
||||||
@@ -3752,6 +3816,28 @@ snapshots:
|
|||||||
|
|
||||||
undici-types@6.21.0: {}
|
undici-types@6.21.0: {}
|
||||||
|
|
||||||
|
unist-util-is@4.1.0: {}
|
||||||
|
|
||||||
|
unist-util-stringify-position@2.0.3:
|
||||||
|
dependencies:
|
||||||
|
'@types/unist': 2.0.11
|
||||||
|
|
||||||
|
unist-util-visit-parents@3.1.1:
|
||||||
|
dependencies:
|
||||||
|
'@types/unist': 2.0.11
|
||||||
|
unist-util-is: 4.1.0
|
||||||
|
|
||||||
|
unist-util-visit@2.0.3:
|
||||||
|
dependencies:
|
||||||
|
'@types/unist': 2.0.11
|
||||||
|
unist-util-is: 4.1.0
|
||||||
|
unist-util-visit-parents: 3.1.1
|
||||||
|
|
||||||
|
vfile-message@2.0.4:
|
||||||
|
dependencies:
|
||||||
|
'@types/unist': 2.0.11
|
||||||
|
unist-util-stringify-position: 2.0.3
|
||||||
|
|
||||||
vite-node@6.0.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0):
|
vite-node@6.0.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
cac: 7.0.0
|
cac: 7.0.0
|
||||||
|
|||||||
@@ -0,0 +1,846 @@
|
|||||||
|
/**
|
||||||
|
* Build script for the /hikes route.
|
||||||
|
*
|
||||||
|
* For each directory under `src/content/hikes/<slug>/`:
|
||||||
|
* 1. Parse `index.svx` frontmatter (lightweight in-house parser, schema is small).
|
||||||
|
* 2. Parse `track.gpx` and derive distance / elevation gain / loss / bbox /
|
||||||
|
* centroid / duration / preview polyline.
|
||||||
|
* 3. Reverse-geocode the centroid via Swisstopo (cached on disk).
|
||||||
|
* 4. Process every image in `images/` with sharp into AVIF + WebP at 3 widths
|
||||||
|
* and emit srcset strings. Only encode images whose hash is referenced
|
||||||
|
* and collect them as `imagePoints` for on-map markers.
|
||||||
|
* 5. Write `static/hikes/<slug>/track.<hash>.json` (compact tuple format).
|
||||||
|
* Emits `src/lib/data/hikes.generated.ts` containing the typed manifest used
|
||||||
|
* by the `/hikes` overview page.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import os from 'node:os';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import {
|
||||||
|
parseGpx,
|
||||||
|
parseGpxImageRefs,
|
||||||
|
trackDistance,
|
||||||
|
type GpxImageRef,
|
||||||
|
type GpxPoint
|
||||||
|
} from '../src/lib/server/gpx.js';
|
||||||
|
import { simplifyTrack } from '../src/lib/server/simplifyTrack.js';
|
||||||
|
import type {
|
||||||
|
Difficulty,
|
||||||
|
HikeManifestEntry,
|
||||||
|
ImagePoint,
|
||||||
|
ImageVariant
|
||||||
|
} from '../src/types/hikes.js';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const ROOT = path.resolve(process.cwd());
|
||||||
|
const CONTENT_DIR = path.join(ROOT, 'src', 'content', 'hikes');
|
||||||
|
// Track JSON stays under /static — it's public preview data and SvelteKit
|
||||||
|
// serves it directly with the rest of the site. URL: /hikes/<slug>/track.*.json
|
||||||
|
const STATIC_DIR = path.join(ROOT, 'static', 'hikes');
|
||||||
|
// Image binaries live outside /static so they aren't bundled into the Node
|
||||||
|
// build or served by SvelteKit. The deploy step rsyncs this tree to
|
||||||
|
// /var/www/static/hikes/ on the server, where nginx serves public images
|
||||||
|
// directly and gates `/private/` images through Node + X-Accel-Redirect.
|
||||||
|
const HIKES_ASSETS_DIR = path.join(ROOT, 'hikes-assets');
|
||||||
|
const CACHE_DIR = path.join(ROOT, 'scripts', '.cache');
|
||||||
|
const GEOCODE_CACHE_FILE = path.join(CACHE_DIR, 'hikes-geocode.json');
|
||||||
|
const MANIFEST_OUT = path.join(ROOT, 'src', 'lib', 'data', 'hikes.generated.ts');
|
||||||
|
|
||||||
|
const ELEV_SMOOTH_WINDOW = 5; // moving-average window for altitude denoising
|
||||||
|
const ELEV_MIN_STEP_M = 3; // discard altitude deltas below this
|
||||||
|
const PREVIEW_POLYLINE_MAX_POINTS = 30;
|
||||||
|
const IMAGE_WIDTHS = [480, 960, 1600] as const;
|
||||||
|
const IMAGE_THUMBNAIL_WIDTH = 240; // popup thumbnail for map markers
|
||||||
|
const MANIFEST_WARN_BYTES = 200_000;
|
||||||
|
|
||||||
|
const VALID_DIFFICULTIES: readonly Difficulty[] = ['T1', 'T2', 'T3', 'T4', 'T5', 'T6'];
|
||||||
|
|
||||||
|
// Sharp pipelines are CPU-heavy but release the JS thread while libvips runs,
|
||||||
|
// so a small concurrency pool gives a near-linear speed-up. Cap at 4 to avoid
|
||||||
|
// thrashing on smaller boxes (a single AVIF encode can saturate one core).
|
||||||
|
const IMAGE_CONCURRENCY = Math.max(2, Math.min(os.cpus().length, 4));
|
||||||
|
|
||||||
|
async function pathExists(p: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await fs.access(p);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runWithConcurrency<T, R>(
|
||||||
|
items: readonly T[],
|
||||||
|
limit: number,
|
||||||
|
worker: (item: T, index: number) => Promise<R>
|
||||||
|
): Promise<R[]> {
|
||||||
|
const results = new Array<R>(items.length);
|
||||||
|
let next = 0;
|
||||||
|
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
||||||
|
while (true) {
|
||||||
|
const i = next++;
|
||||||
|
if (i >= items.length) return;
|
||||||
|
results[i] = await worker(items[i], i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(runners);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tiny frontmatter parser (no deps).
|
||||||
|
// Supports: strings, numbers, booleans, ISO dates (kept as string),
|
||||||
|
// and bracketed arrays of strings: `[a, b, "c d"]`.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type Frontmatter = Record<string, string | number | boolean | string[]>;
|
||||||
|
|
||||||
|
function parseFrontmatter(source: string): { data: Frontmatter; body: string } {
|
||||||
|
const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
||||||
|
if (!match) return { data: {}, body: source };
|
||||||
|
|
||||||
|
const data: Frontmatter = {};
|
||||||
|
for (const rawLine of match[1].split(/\r?\n/)) {
|
||||||
|
const line = rawLine.replace(/\s+#.*$/, '').trim();
|
||||||
|
if (!line || line.startsWith('#')) continue;
|
||||||
|
const sep = line.indexOf(':');
|
||||||
|
if (sep < 0) continue;
|
||||||
|
const key = line.slice(0, sep).trim();
|
||||||
|
const raw = line.slice(sep + 1).trim();
|
||||||
|
data[key] = parseScalar(raw);
|
||||||
|
}
|
||||||
|
return { data, body: match[2] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseScalar(raw: string): string | number | boolean | string[] {
|
||||||
|
if (raw === '') return '';
|
||||||
|
if (raw === 'true') return true;
|
||||||
|
if (raw === 'false') return false;
|
||||||
|
if (raw.startsWith('[') && raw.endsWith(']')) {
|
||||||
|
return raw
|
||||||
|
.slice(1, -1)
|
||||||
|
.split(',')
|
||||||
|
.map(s => stripQuotes(s.trim()))
|
||||||
|
.filter(s => s.length > 0);
|
||||||
|
}
|
||||||
|
if (/^-?\d+(\.\d+)?$/.test(raw)) return parseFloat(raw);
|
||||||
|
return stripQuotes(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripQuotes(s: string): string {
|
||||||
|
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
||||||
|
return s.slice(1, -1);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a `seasons` frontmatter value into `{ seasonStart, seasonEnd }`.
|
||||||
|
* Accepts:
|
||||||
|
* - a numeric range string `"4-9"` (April through September)
|
||||||
|
* - a 3-letter / full English month range `"apr-sep"` or `"april-september"`
|
||||||
|
* - an array of two numbers `[4, 9]`
|
||||||
|
* Returns `{ seasonStart: null, seasonEnd: null }` when absent or malformed. */
|
||||||
|
function parseSeasonRange(raw: unknown): { seasonStart: number | null; seasonEnd: number | null } {
|
||||||
|
const empty = { seasonStart: null, seasonEnd: null };
|
||||||
|
if (raw == null || raw === '') return empty;
|
||||||
|
|
||||||
|
const MONTHS: Record<string, number> = {
|
||||||
|
jan: 1, january: 1, feb: 2, february: 2, mar: 3, march: 3,
|
||||||
|
apr: 4, april: 4, may: 5, jun: 6, june: 6, jul: 7, july: 7,
|
||||||
|
aug: 8, august: 8, sep: 9, september: 9, sept: 9, oct: 10, october: 10,
|
||||||
|
nov: 11, november: 11, dec: 12, december: 12
|
||||||
|
};
|
||||||
|
const toMonth = (v: string | number): number | null => {
|
||||||
|
if (typeof v === 'number') return v >= 1 && v <= 12 ? v : null;
|
||||||
|
const s = String(v).trim().toLowerCase();
|
||||||
|
if (/^\d+$/.test(s)) {
|
||||||
|
const n = parseInt(s, 10);
|
||||||
|
return n >= 1 && n <= 12 ? n : null;
|
||||||
|
}
|
||||||
|
return MONTHS[s] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let parts: Array<string | number> | null = null;
|
||||||
|
if (Array.isArray(raw) && raw.length === 2) {
|
||||||
|
parts = raw as Array<string | number>;
|
||||||
|
} else if (typeof raw === 'string' && raw.includes('-')) {
|
||||||
|
parts = raw.split('-').map((s) => s.trim());
|
||||||
|
}
|
||||||
|
if (!parts) return empty;
|
||||||
|
|
||||||
|
const a = toMonth(parts[0]);
|
||||||
|
const b = toMonth(parts[1]);
|
||||||
|
if (a == null || b == null) return empty;
|
||||||
|
return { seasonStart: a, seasonEnd: b };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Elevation helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Returns `null` for indices where no defined altitude exists in the ±half
|
||||||
|
// window. The previous behaviour (defaulting to 0) silently turned missing
|
||||||
|
// `<ele>` tags into huge synthetic gain spikes against the next real altitude.
|
||||||
|
function smoothAltitudes(track: GpxPoint[]): (number | null)[] {
|
||||||
|
const n = track.length;
|
||||||
|
const out = new Array<number | null>(n);
|
||||||
|
const half = Math.floor(ELEV_SMOOTH_WINDOW / 2);
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
let sum = 0;
|
||||||
|
let count = 0;
|
||||||
|
for (let j = Math.max(0, i - half); j <= Math.min(n - 1, i + half); j++) {
|
||||||
|
const a = track[j].altitude;
|
||||||
|
if (typeof a === 'number') {
|
||||||
|
sum += a;
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out[i] = count > 0 ? sum / count : null;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeElevationStats(track: GpxPoint[]): { gain: number; loss: number } {
|
||||||
|
if (track.length < 2) return { gain: 0, loss: 0 };
|
||||||
|
const altitudes = smoothAltitudes(track);
|
||||||
|
let gain = 0;
|
||||||
|
let loss = 0;
|
||||||
|
let prev: number | null = null;
|
||||||
|
for (const a of altitudes) {
|
||||||
|
if (a === null) continue;
|
||||||
|
if (prev === null) {
|
||||||
|
prev = a;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const diff = a - prev;
|
||||||
|
if (diff >= ELEV_MIN_STEP_M) {
|
||||||
|
gain += diff;
|
||||||
|
prev = a;
|
||||||
|
} else if (diff <= -ELEV_MIN_STEP_M) {
|
||||||
|
loss += -diff;
|
||||||
|
prev = a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { gain: Math.round(gain), loss: Math.round(loss) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeElevationRange(track: GpxPoint[]): { min: number | null; max: number | null } {
|
||||||
|
let min = Infinity;
|
||||||
|
let max = -Infinity;
|
||||||
|
for (const p of track) {
|
||||||
|
if (typeof p.altitude !== 'number') continue;
|
||||||
|
if (p.altitude < min) min = p.altitude;
|
||||||
|
if (p.altitude > max) max = p.altitude;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(min) || !Number.isFinite(max)) return { min: null, max: null };
|
||||||
|
return { min: Math.round(min), max: Math.round(max) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeBboxAndCentroid(track: GpxPoint[]): {
|
||||||
|
bbox: [number, number, number, number];
|
||||||
|
centroid: [number, number];
|
||||||
|
} {
|
||||||
|
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity;
|
||||||
|
let sumLat = 0, sumLng = 0;
|
||||||
|
for (const p of track) {
|
||||||
|
if (p.lat < minLat) minLat = p.lat;
|
||||||
|
if (p.lat > maxLat) maxLat = p.lat;
|
||||||
|
if (p.lng < minLng) minLng = p.lng;
|
||||||
|
if (p.lng > maxLng) maxLng = p.lng;
|
||||||
|
sumLat += p.lat;
|
||||||
|
sumLng += p.lng;
|
||||||
|
}
|
||||||
|
const n = track.length || 1;
|
||||||
|
return {
|
||||||
|
bbox: [minLat, minLng, maxLat, maxLng],
|
||||||
|
centroid: [sumLat / n, sumLng / n]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Swisstopo reverse-geocode with disk cache
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type GeocodeResult = {
|
||||||
|
canton: string | null;
|
||||||
|
municipality: string | null;
|
||||||
|
region: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GeocodeCache = Record<string, GeocodeResult>;
|
||||||
|
|
||||||
|
async function loadGeocodeCache(): Promise<GeocodeCache> {
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(GEOCODE_CACHE_FILE, 'utf-8');
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveGeocodeCache(cache: GeocodeCache): Promise<void> {
|
||||||
|
await fs.mkdir(CACHE_DIR, { recursive: true });
|
||||||
|
await fs.writeFile(GEOCODE_CACHE_FILE, JSON.stringify(cache, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
const SWISSTOPO_UA = 'bocken-homepage build-hikes';
|
||||||
|
|
||||||
|
async function fetchFeatureName(layerBodId: string, featureId: number | string): Promise<string | null> {
|
||||||
|
const url = `https://api3.geo.admin.ch/rest/services/api/MapServer/${layerBodId}/${featureId}/htmlPopup?lang=de`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { headers: { 'User-Agent': SWISSTOPO_UA } });
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const html = await res.text();
|
||||||
|
// htmlPopup label is "Name" for cantons and "Amtlicher Gemeindename" for municipalities.
|
||||||
|
const m =
|
||||||
|
html.match(/<td[^>]*>(?:Amtlicher\s+Gemeindename|Name)<\/td>\s*<td[^>]*>([^<]+)<\/td>/i);
|
||||||
|
return m ? m[1].trim() : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reverseGeocode(
|
||||||
|
lat: number,
|
||||||
|
lng: number,
|
||||||
|
cache: GeocodeCache
|
||||||
|
): Promise<GeocodeResult> {
|
||||||
|
const key = `${lat.toFixed(5)},${lng.toFixed(5)}`;
|
||||||
|
if (cache[key]) return cache[key];
|
||||||
|
|
||||||
|
const layers =
|
||||||
|
'all:ch.swisstopo.swissboundaries3d-kanton-flaeche.fill,' +
|
||||||
|
'ch.swisstopo.swissboundaries3d-gemeinde-flaeche.fill';
|
||||||
|
// Tight 1000x1000 imageDisplay over a 0.0002 deg mapExtent with 1px tolerance
|
||||||
|
// gives ~2 cm of effective tolerance around the centroid — enough to land in
|
||||||
|
// the correct kanton/gemeinde without picking up neighbours.
|
||||||
|
const eps = 0.0001;
|
||||||
|
const url =
|
||||||
|
`https://api3.geo.admin.ch/rest/services/api/MapServer/identify` +
|
||||||
|
`?geometry=${lng},${lat}` +
|
||||||
|
`&geometryType=esriGeometryPoint&geometryFormat=geojson&returnGeometry=false` +
|
||||||
|
`&imageDisplay=1000,1000,96` +
|
||||||
|
`&mapExtent=${lng - eps},${lat - eps},${lng + eps},${lat + eps}` +
|
||||||
|
`&tolerance=1&layers=${layers}&sr=4326`;
|
||||||
|
|
||||||
|
const result: GeocodeResult = { canton: null, municipality: null, region: null };
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { headers: { 'User-Agent': SWISSTOPO_UA } });
|
||||||
|
if (res.ok) {
|
||||||
|
type IdentifyRow = { layerBodId?: string; layerName?: string; featureId?: number | string; id?: number | string };
|
||||||
|
const json = (await res.json()) as { results?: IdentifyRow[] };
|
||||||
|
// Identify returns historical boundary records too, so we only need the
|
||||||
|
// first hit per layer.
|
||||||
|
for (const r of json.results ?? []) {
|
||||||
|
const layerBodId = r.layerBodId;
|
||||||
|
const featureId = r.featureId ?? r.id;
|
||||||
|
if (!layerBodId || featureId === undefined) continue;
|
||||||
|
if (layerBodId.includes('kanton') && result.canton) continue;
|
||||||
|
if (layerBodId.includes('gemeinde') && result.municipality) continue;
|
||||||
|
const name = await fetchFeatureName(layerBodId, featureId);
|
||||||
|
if (!name) continue;
|
||||||
|
if (layerBodId.includes('kanton')) result.canton = name;
|
||||||
|
else if (layerBodId.includes('gemeinde')) result.municipality = name;
|
||||||
|
}
|
||||||
|
result.region = result.municipality ?? result.canton;
|
||||||
|
} else {
|
||||||
|
console.warn(`[build-hikes] Swisstopo identify failed (${res.status}) for ${key}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[build-hikes] Swisstopo identify error for ${key}:`, err);
|
||||||
|
}
|
||||||
|
|
||||||
|
cache[key] = result;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Image processing (sharp -> AVIF + WebP at multiple widths)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function shortHashOfBuffer(buf: Buffer): string {
|
||||||
|
return crypto.createHash('sha256').update(buf).digest('hex').slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processImage(
|
||||||
|
srcPath: string,
|
||||||
|
slug: string,
|
||||||
|
alt: string,
|
||||||
|
gpxImageRefs: Record<string, GpxImageRef>
|
||||||
|
): Promise<
|
||||||
|
| { variant: ImageVariant; thumbnailRelUrl: string; largestRelUrl: string; hash: string; visibility: 'public' | 'private'; cached: boolean; outNames: string[] }
|
||||||
|
| { skipped: true; hash: string }
|
||||||
|
> {
|
||||||
|
const buffer = await fs.readFile(srcPath);
|
||||||
|
const hash = shortHashOfBuffer(buffer);
|
||||||
|
const ref = gpxImageRefs[hash];
|
||||||
|
if (!ref) {
|
||||||
|
// Not referenced by any waypoint in track.gpx — drop it entirely (no
|
||||||
|
// encode, no manifest entry, no static output). Authors who want an
|
||||||
|
// image published must place it on the route via the route-builder
|
||||||
|
// (which writes a `<bocken:image hash>` waypoint into track.gpx).
|
||||||
|
return { skipped: true, hash };
|
||||||
|
}
|
||||||
|
const visibility: 'public' | 'private' = ref.visibility === 'private' ? 'private' : 'public';
|
||||||
|
// Public images go under `images/` (served directly by nginx).
|
||||||
|
// Private images go under `private/` (proxied through Node for auth check;
|
||||||
|
// nginx hands them off via X-Accel-Redirect once the session is valid).
|
||||||
|
const segment = visibility === 'private' ? 'private' : 'images';
|
||||||
|
// Filenames are content-hash only — the source basename (which usually
|
||||||
|
// encodes a date + camera ID) is intentionally dropped so it doesn't leak
|
||||||
|
// into the published URLs.
|
||||||
|
const outDir = path.join(HIKES_ASSETS_DIR, slug, segment);
|
||||||
|
await fs.mkdir(outDir, { recursive: true });
|
||||||
|
|
||||||
|
const meta = await sharp(buffer).metadata();
|
||||||
|
const intrinsicW = meta.width ?? IMAGE_WIDTHS[IMAGE_WIDTHS.length - 1];
|
||||||
|
const intrinsicH = meta.height ?? 0;
|
||||||
|
|
||||||
|
const widths = IMAGE_WIDTHS.filter(w => w <= intrinsicW);
|
||||||
|
if (widths.length === 0) widths.push(intrinsicW);
|
||||||
|
|
||||||
|
type EncodeJob = {
|
||||||
|
w: number;
|
||||||
|
format: 'avif' | 'webp';
|
||||||
|
filePath: string;
|
||||||
|
quality: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const jobs: EncodeJob[] = [];
|
||||||
|
const avifEntries: string[] = [];
|
||||||
|
const webpEntries: string[] = [];
|
||||||
|
let largestWebp = '';
|
||||||
|
|
||||||
|
for (const w of widths) {
|
||||||
|
const avifName = `${hash}.${w}.avif`;
|
||||||
|
const webpName = `${hash}.${w}.webp`;
|
||||||
|
jobs.push({ w, format: 'avif', filePath: path.join(outDir, avifName), quality: 55 });
|
||||||
|
jobs.push({ w, format: 'webp', filePath: path.join(outDir, webpName), quality: 82 });
|
||||||
|
const avifUrl = `/hikes/${slug}/${segment}/${avifName}`;
|
||||||
|
const webpUrl = `/hikes/${slug}/${segment}/${webpName}`;
|
||||||
|
avifEntries.push(`${avifUrl} ${w}w`);
|
||||||
|
webpEntries.push(`${webpUrl} ${w}w`);
|
||||||
|
largestWebp = webpUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const thumbName = `${hash}.${IMAGE_THUMBNAIL_WIDTH}.webp`;
|
||||||
|
const thumbPath = path.join(outDir, thumbName);
|
||||||
|
const thumbUrl = `/hikes/${slug}/${segment}/${thumbName}`;
|
||||||
|
const thumbJob: EncodeJob = {
|
||||||
|
w: IMAGE_THUMBNAIL_WIDTH,
|
||||||
|
format: 'webp',
|
||||||
|
filePath: thumbPath,
|
||||||
|
quality: 78
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter out jobs whose output already exists — the hash is in the filename,
|
||||||
|
// so an existing file is guaranteed to be the same encoded bytes.
|
||||||
|
const allJobs = [...jobs, thumbJob];
|
||||||
|
const presence = await Promise.all(allJobs.map(j => pathExists(j.filePath)));
|
||||||
|
const pending = allJobs.filter((_, i) => !presence[i]);
|
||||||
|
const cached = pending.length === 0;
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
pending.map(async (job) => {
|
||||||
|
const pipeline = sharp(buffer).rotate().resize({ width: job.w, withoutEnlargement: true });
|
||||||
|
if (job.format === 'avif') {
|
||||||
|
await pipeline.avif({ quality: job.quality }).toFile(job.filePath);
|
||||||
|
} else {
|
||||||
|
await pipeline.webp({ quality: job.quality }).toFile(job.filePath);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const largestW = widths[widths.length - 1];
|
||||||
|
const scale = largestW / intrinsicW;
|
||||||
|
const largestH = Math.round((intrinsicH || largestW) * scale);
|
||||||
|
|
||||||
|
// Names of every output file this image owns — used by the per-hike
|
||||||
|
// cleanup pass to drop orphaned encodes from previous builds.
|
||||||
|
const outNames = allJobs.map((j) => path.basename(j.filePath));
|
||||||
|
|
||||||
|
return {
|
||||||
|
variant: {
|
||||||
|
src: largestWebp,
|
||||||
|
srcsetAvif: avifEntries.join(', '),
|
||||||
|
srcsetWebp: webpEntries.join(', '),
|
||||||
|
width: largestW,
|
||||||
|
height: largestH,
|
||||||
|
alt
|
||||||
|
},
|
||||||
|
thumbnailRelUrl: thumbUrl,
|
||||||
|
largestRelUrl: largestWebp,
|
||||||
|
hash,
|
||||||
|
visibility,
|
||||||
|
cached,
|
||||||
|
outNames
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Per-hike icon (icon.svg / icon.png / icon.jpg / icon.jpeg / icon.webp).
|
||||||
|
// SVG passes through verbatim; raster sources are re-encoded to a single
|
||||||
|
// 256-square WebP so /hikes/<slug>/ stays small. Filenames carry the
|
||||||
|
// source content hash so the URL changes when the icon does, side-stepping
|
||||||
|
// CDN cache concerns.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const ICON_SOURCES: ReadonlyArray<{ filename: string; isSvg: boolean }> = [
|
||||||
|
{ filename: 'icon.svg', isSvg: true },
|
||||||
|
{ filename: 'icon.png', isSvg: false },
|
||||||
|
{ filename: 'icon.jpg', isSvg: false },
|
||||||
|
{ filename: 'icon.jpeg', isSvg: false },
|
||||||
|
{ filename: 'icon.webp', isSvg: false }
|
||||||
|
];
|
||||||
|
|
||||||
|
const ICON_RASTER_SIZE = 256;
|
||||||
|
|
||||||
|
async function processIcon(slug: string, hikeDir: string): Promise<{ url: string; outName: string } | undefined> {
|
||||||
|
let srcPath: string | undefined;
|
||||||
|
let isSvg = false;
|
||||||
|
for (const candidate of ICON_SOURCES) {
|
||||||
|
const p = path.join(hikeDir, candidate.filename);
|
||||||
|
if (await pathExists(p)) {
|
||||||
|
srcPath = p;
|
||||||
|
isSvg = candidate.isSvg;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!srcPath) return undefined;
|
||||||
|
|
||||||
|
const buf = await fs.readFile(srcPath);
|
||||||
|
const hash = shortHashOfBuffer(buf);
|
||||||
|
const outExt = isSvg ? 'svg' : 'webp';
|
||||||
|
const outName = `icon.${hash}.${outExt}`;
|
||||||
|
// Icons live under the `images/` namespace (alongside encoded photos) so
|
||||||
|
// they piggy-back on the same dev-server plugin and nginx public-serve
|
||||||
|
// rules. The naming prefix `icon.` keeps them clearly distinct from
|
||||||
|
// hash-named photo outputs.
|
||||||
|
const outDir = path.join(HIKES_ASSETS_DIR, slug, 'images');
|
||||||
|
await fs.mkdir(outDir, { recursive: true });
|
||||||
|
const outPath = path.join(outDir, outName);
|
||||||
|
|
||||||
|
if (!(await pathExists(outPath))) {
|
||||||
|
if (isSvg) {
|
||||||
|
await fs.writeFile(outPath, buf);
|
||||||
|
} else {
|
||||||
|
await sharp(buf)
|
||||||
|
.rotate()
|
||||||
|
.resize({ width: ICON_RASTER_SIZE, height: ICON_RASTER_SIZE, fit: 'inside', withoutEnlargement: true })
|
||||||
|
.webp({ quality: 88 })
|
||||||
|
.toFile(outPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { url: `/hikes/${slug}/images/${outName}`, outName };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Image EXIF -> ImagePoint
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function extractImagePoint(
|
||||||
|
processed: { thumbnailRelUrl: string; largestRelUrl: string; hash: string },
|
||||||
|
alt: string,
|
||||||
|
gpxImageRef: GpxImageRef
|
||||||
|
): ImagePoint {
|
||||||
|
// The GPX `<bocken:image hash>` waypoint is the single source of truth
|
||||||
|
// for an image's position. Authors place images on the route via the
|
||||||
|
// route-builder (or by hand in the GPX); EXIF GPS is no longer trusted
|
||||||
|
// as a fallback because phone GPS noise produced visible spikes and
|
||||||
|
// because users sometimes want to publish an image at a corrected
|
||||||
|
// location.
|
||||||
|
return {
|
||||||
|
src: processed.largestRelUrl,
|
||||||
|
thumbnail: processed.thumbnailRelUrl,
|
||||||
|
lat: gpxImageRef.lat,
|
||||||
|
lng: gpxImageRef.lng,
|
||||||
|
altitude: gpxImageRef.altitude,
|
||||||
|
timestamp: gpxImageRef.timestamp,
|
||||||
|
alt,
|
||||||
|
visibility: gpxImageRef.visibility ?? 'public'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Per-hike build
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function buildHike(slug: string, cache: GeocodeCache): Promise<HikeManifestEntry | null> {
|
||||||
|
const hikeStart = Date.now();
|
||||||
|
const hikeDir = path.join(CONTENT_DIR, slug);
|
||||||
|
const svxPath = path.join(hikeDir, 'index.svx');
|
||||||
|
const gpxPath = path.join(hikeDir, 'track.gpx');
|
||||||
|
const imagesDir = path.join(hikeDir, 'images');
|
||||||
|
|
||||||
|
let svxSource: string;
|
||||||
|
try {
|
||||||
|
svxSource = await fs.readFile(svxPath, 'utf-8');
|
||||||
|
} catch {
|
||||||
|
console.warn(`[build-hikes] Skipping ${slug}: no index.svx`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let gpxSource: string;
|
||||||
|
try {
|
||||||
|
gpxSource = await fs.readFile(gpxPath, 'utf-8');
|
||||||
|
} catch {
|
||||||
|
console.warn(`[build-hikes] Skipping ${slug}: no track.gpx`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: fm } = parseFrontmatter(svxSource);
|
||||||
|
const track = parseGpx(gpxSource);
|
||||||
|
if (track.length === 0) {
|
||||||
|
console.warn(`[build-hikes] Skipping ${slug}: empty GPX`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const gpxImageRefs = parseGpxImageRefs(gpxSource);
|
||||||
|
const gpxImageCount = Object.keys(gpxImageRefs).length;
|
||||||
|
console.log(`[build-hikes:${slug}] parsed GPX (${track.length} track pts, ${gpxImageCount} image refs)`);
|
||||||
|
|
||||||
|
const distanceKm = trackDistance(track);
|
||||||
|
const { gain, loss } = computeElevationStats(track);
|
||||||
|
const { min: elevationMinM, max: elevationMaxM } = computeElevationRange(track);
|
||||||
|
const { bbox, centroid } = computeBboxAndCentroid(track);
|
||||||
|
const previewPolyline = simplifyTrack(track, PREVIEW_POLYLINE_MAX_POINTS) as [number, number][];
|
||||||
|
const dtMs = track[track.length - 1].timestamp - track[0].timestamp;
|
||||||
|
const durationMin = dtMs > 0 ? Math.round(dtMs / 60000) : null;
|
||||||
|
console.log(`[build-hikes:${slug}] metrics: ${distanceKm.toFixed(2)} km · ↑${gain}m / ↓${loss}m · ${elevationMinM ?? '?'}–${elevationMaxM ?? '?'}m · ${durationMin ?? '?'} min`);
|
||||||
|
|
||||||
|
const geoT0 = Date.now();
|
||||||
|
const geo = await reverseGeocode(centroid[0], centroid[1], cache);
|
||||||
|
console.log(`[build-hikes:${slug}] geocode: ${geo.municipality ?? '–'}, ${geo.canton ?? '–'} (${Date.now() - geoT0}ms)`);
|
||||||
|
|
||||||
|
// Process images
|
||||||
|
const imageFiles: string[] = [];
|
||||||
|
try {
|
||||||
|
const entries = await fs.readdir(imagesDir);
|
||||||
|
for (const e of entries.sort()) {
|
||||||
|
if (/\.(jpe?g|png|webp|heic|heif)$/i.test(e)) imageFiles.push(path.join(imagesDir, e));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// no images dir is fine
|
||||||
|
}
|
||||||
|
// Images whose content hash isn't in gpxImageRefs are dropped before
|
||||||
|
// encoding (see processImage). Count for the log line below.
|
||||||
|
if (imageFiles.length > 0) {
|
||||||
|
console.log(
|
||||||
|
`[build-hikes:${slug}] processing ${imageFiles.length} image(s) — ${Object.keys(gpxImageRefs).length} referenced in track.gpx (concurrency=${IMAGE_CONCURRENCY})…`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let cover: ImageVariant | null = null;
|
||||||
|
const imagePoints: ImagePoint[] = [];
|
||||||
|
// Filenames produced by this build, keyed by segment dir (`images` /
|
||||||
|
// `private`). Used to delete leftover encoded files from previous runs
|
||||||
|
// (images that have since been unreferenced or moved between visibilities).
|
||||||
|
const keepFiles: Record<'images' | 'private', Set<string>> = {
|
||||||
|
images: new Set(),
|
||||||
|
private: new Set()
|
||||||
|
};
|
||||||
|
|
||||||
|
type ImageResult = {
|
||||||
|
variant: ImageVariant | null;
|
||||||
|
point: ImagePoint | null;
|
||||||
|
outNames: string[];
|
||||||
|
visibility: 'public' | 'private';
|
||||||
|
};
|
||||||
|
|
||||||
|
const results = await runWithConcurrency<string, ImageResult>(
|
||||||
|
imageFiles,
|
||||||
|
IMAGE_CONCURRENCY,
|
||||||
|
async (imgPath, i) => {
|
||||||
|
const imgT0 = Date.now();
|
||||||
|
// Hero alt only applies to the first image; later ones get a generic
|
||||||
|
// label (image basenames usually encode date/camera info that we don't
|
||||||
|
// want to leak into alt text or hover tooltips).
|
||||||
|
const alt = i === 0 && typeof fm.heroAlt === 'string'
|
||||||
|
? fm.heroAlt
|
||||||
|
: `Bild ${i + 1}`;
|
||||||
|
const processed = await processImage(imgPath, slug, alt, gpxImageRefs);
|
||||||
|
if ('skipped' in processed) {
|
||||||
|
console.log(
|
||||||
|
`[build-hikes:${slug}] [${i + 1}/${imageFiles.length}] ${path.basename(imgPath)} · ${processed.hash} · skipped (not in track.gpx)`
|
||||||
|
);
|
||||||
|
return { variant: null, point: null, outNames: [], visibility: 'public' as const };
|
||||||
|
}
|
||||||
|
const point = extractImagePoint(processed, alt, gpxImageRefs[processed.hash]);
|
||||||
|
const cacheTag = processed.cached ? ' · cached' : '';
|
||||||
|
console.log(
|
||||||
|
`[build-hikes:${slug}] [${i + 1}/${imageFiles.length}] ${path.basename(imgPath)} · ${processed.hash} · ${processed.visibility}${cacheTag} (${Date.now() - imgT0}ms)`
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
variant: processed.variant,
|
||||||
|
point,
|
||||||
|
outNames: processed.outNames,
|
||||||
|
visibility: processed.visibility
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const r of results) {
|
||||||
|
if (r.variant !== null) {
|
||||||
|
// Use the first PUBLIC image as the cover. Private images must not
|
||||||
|
// surface on the listing page (which is prerendered and served to
|
||||||
|
// anonymous viewers).
|
||||||
|
if (cover === null && r.visibility === 'public') cover = r.variant;
|
||||||
|
const segment = r.visibility === 'private' ? 'private' : 'images';
|
||||||
|
for (const name of r.outNames) keepFiles[segment].add(name);
|
||||||
|
}
|
||||||
|
if (r.point) imagePoints.push(r.point);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-route icon — handled here (before cleanup) so its outName joins
|
||||||
|
// `keepFiles.images` and survives the orphan sweep, while previous-build
|
||||||
|
// `icon.<oldhash>.*` files (different hash, not in keepFiles) get removed.
|
||||||
|
const iconResult = await processIcon(slug, hikeDir);
|
||||||
|
if (iconResult) keepFiles.images.add(iconResult.outName);
|
||||||
|
|
||||||
|
// Cleanup pass: drop any encoded files in either segment dir that don't
|
||||||
|
// belong to a current image. Catches both stale hashes (deleted source
|
||||||
|
// images) and visibility flips (a hash that's now public still has its
|
||||||
|
// old `private/` encodes lying around, and vice versa).
|
||||||
|
for (const segment of ['images', 'private'] as const) {
|
||||||
|
const dir = path.join(HIKES_ASSETS_DIR, slug, segment);
|
||||||
|
try {
|
||||||
|
const existing = await fs.readdir(dir);
|
||||||
|
const keep = keepFiles[segment];
|
||||||
|
const orphans = existing.filter((f) => !keep.has(f));
|
||||||
|
if (orphans.length > 0) {
|
||||||
|
await Promise.all(
|
||||||
|
orphans.map((f) => fs.unlink(path.join(dir, f)).catch(() => {}))
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`[build-hikes:${slug}] removed ${orphans.length} orphaned ${segment}/ file(s) from prior builds`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Dir may not exist when a hike has no images of this visibility — nothing to clean.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cover) {
|
||||||
|
// Synthetic 1x1 placeholder so the manifest type stays satisfied even
|
||||||
|
// when a hike directory has no images yet.
|
||||||
|
cover = {
|
||||||
|
src: '',
|
||||||
|
srcsetAvif: '',
|
||||||
|
srcsetWebp: '',
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
alt: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-hike full track JSON in compact tuple format
|
||||||
|
const tuples = track.map(p => [
|
||||||
|
Number(p.lng.toFixed(6)),
|
||||||
|
Number(p.lat.toFixed(6)),
|
||||||
|
typeof p.altitude === 'number' ? Number(p.altitude.toFixed(1)) : null,
|
||||||
|
p.timestamp
|
||||||
|
]);
|
||||||
|
const trackJson = JSON.stringify(tuples);
|
||||||
|
const trackHash = crypto.createHash('sha256').update(trackJson).digest('hex').slice(0, 8);
|
||||||
|
const trackFile = path.join(STATIC_DIR, slug, `track.${trackHash}.json`);
|
||||||
|
await fs.mkdir(path.dirname(trackFile), { recursive: true });
|
||||||
|
await fs.writeFile(trackFile, trackJson);
|
||||||
|
console.log(`[build-hikes:${slug}] wrote track.${trackHash}.json (${trackJson.length} bytes)`);
|
||||||
|
|
||||||
|
const difficulty = (typeof fm.difficulty === 'string' && VALID_DIFFICULTIES.includes(fm.difficulty as Difficulty))
|
||||||
|
? (fm.difficulty as Difficulty)
|
||||||
|
: 'T1';
|
||||||
|
|
||||||
|
const date = typeof fm.date === 'string'
|
||||||
|
? fm.date
|
||||||
|
: (typeof fm.date === 'number' ? new Date(fm.date).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10));
|
||||||
|
|
||||||
|
const tags = Array.isArray(fm.tags) ? fm.tags : [];
|
||||||
|
|
||||||
|
const iconUrl = iconResult?.url;
|
||||||
|
|
||||||
|
const entry: HikeManifestEntry = {
|
||||||
|
slug,
|
||||||
|
title: typeof fm.title === 'string' ? fm.title : slug,
|
||||||
|
date,
|
||||||
|
summary: typeof fm.summary === 'string' ? fm.summary : '',
|
||||||
|
author: typeof fm.author === 'string' ? fm.author : undefined,
|
||||||
|
tags,
|
||||||
|
difficulty,
|
||||||
|
hidden: fm.hidden === true,
|
||||||
|
...parseSeasonRange(fm.seasons),
|
||||||
|
distanceKm: Math.round(distanceKm * 100) / 100,
|
||||||
|
durationMin,
|
||||||
|
elevationGainM: gain,
|
||||||
|
elevationLossM: loss,
|
||||||
|
elevationMaxM,
|
||||||
|
elevationMinM,
|
||||||
|
bbox,
|
||||||
|
centroid,
|
||||||
|
previewPolyline,
|
||||||
|
region: geo.region,
|
||||||
|
canton: geo.canton,
|
||||||
|
municipality: geo.municipality,
|
||||||
|
trackUrl: `/hikes/${slug}/track.${trackHash}.json`,
|
||||||
|
pointCount: track.length,
|
||||||
|
cover,
|
||||||
|
icon: iconUrl,
|
||||||
|
imagePoints
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`[build-hikes:${slug}] done in ${((Date.now() - hikeStart) / 1000).toFixed(1)}s`);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Entry point
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
let slugs: string[] = [];
|
||||||
|
try {
|
||||||
|
const entries = await fs.readdir(CONTENT_DIR, { withFileTypes: true });
|
||||||
|
slugs = entries.filter(e => e.isDirectory()).map(e => e.name).sort();
|
||||||
|
} catch {
|
||||||
|
console.warn(`[build-hikes] No content dir at ${CONTENT_DIR}; emitting empty manifest.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = await loadGeocodeCache();
|
||||||
|
const hikes: HikeManifestEntry[] = [];
|
||||||
|
|
||||||
|
for (const slug of slugs) {
|
||||||
|
console.log(`[build-hikes] Building ${slug}`);
|
||||||
|
const entry = await buildHike(slug, cache);
|
||||||
|
if (entry) hikes.push(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveGeocodeCache(cache);
|
||||||
|
|
||||||
|
hikes.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0));
|
||||||
|
|
||||||
|
await fs.mkdir(path.dirname(MANIFEST_OUT), { recursive: true });
|
||||||
|
const banner =
|
||||||
|
'// AUTO-GENERATED by scripts/build-hikes.ts — do not edit by hand.\n' +
|
||||||
|
"import type { HikeManifestEntry } from '$types/hikes';\n\n";
|
||||||
|
const body = `export const HIKES: HikeManifestEntry[] = ${JSON.stringify(hikes, null, 2)} as const;\n`;
|
||||||
|
const manifestSrc = banner + body;
|
||||||
|
await fs.writeFile(MANIFEST_OUT, manifestSrc);
|
||||||
|
|
||||||
|
const bytes = Buffer.byteLength(manifestSrc, 'utf-8');
|
||||||
|
if (bytes > MANIFEST_WARN_BYTES) {
|
||||||
|
console.warn(`[build-hikes] Manifest ${bytes} bytes exceeds soft cap ${MANIFEST_WARN_BYTES} — consider trimming previewPolyline size.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[build-hikes] Wrote ${hikes.length} hikes to ${MANIFEST_OUT} (${bytes} bytes)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => {
|
||||||
|
console.error('[build-hikes] Fatal:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
+16
-1
@@ -17,6 +17,12 @@ REMOTE_USER_GROUP="${REMOTE_USER_GROUP:-homepage:homepage}"
|
|||||||
SERVICE="${SERVICE:-homepage.service}"
|
SERVICE="${SERVICE:-homepage.service}"
|
||||||
ERROR_PAGES_DIR="${ERROR_PAGES_DIR:-/var/www/errors}"
|
ERROR_PAGES_DIR="${ERROR_PAGES_DIR:-/var/www/errors}"
|
||||||
ERROR_PAGES_OWNER="${ERROR_PAGES_OWNER:-http:http}"
|
ERROR_PAGES_OWNER="${ERROR_PAGES_OWNER:-http:http}"
|
||||||
|
# Hike images live outside the Node app: nginx serves /hikes/<slug>/images/
|
||||||
|
# directly from disk and gates /hikes/<slug>/private/ through Node via
|
||||||
|
# X-Accel-Redirect. The build pipeline writes them to ./hikes-assets/ and we
|
||||||
|
# rsync that tree to the path nginx serves from.
|
||||||
|
HIKES_ASSETS_DIR="${HIKES_ASSETS_DIR:-/var/www/static/hikes}"
|
||||||
|
HIKES_ASSETS_OWNER="${HIKES_ASSETS_OWNER:-http:http}"
|
||||||
|
|
||||||
DRY=""
|
DRY=""
|
||||||
if [[ "${1:-}" == "--dry-run" ]]; then
|
if [[ "${1:-}" == "--dry-run" ]]; then
|
||||||
@@ -74,13 +80,22 @@ ssh "$REMOTE" "mkdir -p $ERROR_PAGES_DIR"
|
|||||||
rsync -az --delete $DRY --info=progress2 \
|
rsync -az --delete $DRY --info=progress2 \
|
||||||
build/client/errors/ "$REMOTE:$ERROR_PAGES_DIR/"
|
build/client/errors/ "$REMOTE:$ERROR_PAGES_DIR/"
|
||||||
|
|
||||||
|
if [[ -d hikes-assets ]]; then
|
||||||
|
echo ":: Syncing hikes-assets/ → $REMOTE:$HIKES_ASSETS_DIR/"
|
||||||
|
ssh "$REMOTE" "mkdir -p $HIKES_ASSETS_DIR"
|
||||||
|
rsync -az --delete $DRY --info=progress2 \
|
||||||
|
hikes-assets/ "$REMOTE:$HIKES_ASSETS_DIR/"
|
||||||
|
else
|
||||||
|
echo ":: No hikes-assets/ dir — skipping nginx-served hike images sync"
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ -n "$DRY" ]]; then
|
if [[ -n "$DRY" ]]; then
|
||||||
echo ":: Dry run complete — no service restart"
|
echo ":: Dry run complete — no service restart"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ":: Fixing ownership on server"
|
echo ":: Fixing ownership on server"
|
||||||
ssh "$REMOTE" "chown -R $REMOTE_USER_GROUP $REMOTE_DIR/dist $REMOTE_DIR/node_modules $REMOTE_DIR/static $REMOTE_DIR/package.json $REMOTE_DIR/pnpm-lock.yaml && chown -R $ERROR_PAGES_OWNER $ERROR_PAGES_DIR"
|
ssh "$REMOTE" "chown -R $REMOTE_USER_GROUP $REMOTE_DIR/dist $REMOTE_DIR/node_modules $REMOTE_DIR/static $REMOTE_DIR/package.json $REMOTE_DIR/pnpm-lock.yaml && chown -R $ERROR_PAGES_OWNER $ERROR_PAGES_DIR && if [[ -d $HIKES_ASSETS_DIR ]]; then chown -R $HIKES_ASSETS_OWNER $HIKES_ASSETS_DIR; fi"
|
||||||
|
|
||||||
echo ":: Restarting $SERVICE"
|
echo ":: Restarting $SERVICE"
|
||||||
ssh "$REMOTE" "systemctl restart $SERVICE"
|
ssh "$REMOTE" "systemctl restart $SERVICE"
|
||||||
|
|||||||
+56
-4
@@ -25,6 +25,29 @@ async function htmlLang({ event, resolve }: Parameters<Handle>[0]) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Apply headers to a response, transparently cloning it if the original
|
||||||
|
* has immutable headers. Auth.js (and certain fetch error/redirect responses)
|
||||||
|
* hand back frozen Headers, and a direct `.set()` on those throws
|
||||||
|
* `TypeError: immutable` — which would mask the underlying error and 500
|
||||||
|
* the request. Cloning preserves the body stream and status. */
|
||||||
|
function applyHeaders(response: Response, entries: Array<[string, string]>): Response {
|
||||||
|
try {
|
||||||
|
for (const [k, v] of entries) response.headers.set(k, v);
|
||||||
|
return response;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof TypeError) {
|
||||||
|
const headers = new Headers(response.headers);
|
||||||
|
for (const [k, v] of entries) headers.set(k, v);
|
||||||
|
return new Response(response.body, {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Routes that must never appear in search-engine indexes. Search-results pages
|
/** Routes that must never appear in search-engine indexes. Search-results pages
|
||||||
* are thin/duplicate content; admin/edit/auth-walled pages have no public value
|
* are thin/duplicate content; admin/edit/auth-walled pages have no public value
|
||||||
* and shouldn't burn crawl budget. Sets X-Robots-Tag rather than per-page meta
|
* and shouldn't burn crawl budget. Sets X-Robots-Tag rather than per-page meta
|
||||||
@@ -46,11 +69,34 @@ const NOINDEX_PATTERNS: RegExp[] = [
|
|||||||
async function noindex({ event, resolve }: Parameters<Handle>[0]) {
|
async function noindex({ event, resolve }: Parameters<Handle>[0]) {
|
||||||
const response = await resolve(event);
|
const response = await resolve(event);
|
||||||
if (NOINDEX_PATTERNS.some((p) => p.test(event.url.pathname))) {
|
if (NOINDEX_PATTERNS.some((p) => p.test(event.url.pathname))) {
|
||||||
response.headers.set('X-Robots-Tag', 'noindex, nofollow');
|
return applyHeaders(response, [['X-Robots-Tag', 'noindex, nofollow']]);
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Baseline security headers, set on every response.
|
||||||
|
*
|
||||||
|
* - X-Frame-Options + CSP frame-ancestors block this site from being
|
||||||
|
* iframed onto attacker pages (clickjacking on /login, /cospend,
|
||||||
|
* /fitness, etc.). Both directives are sent: modern browsers honour
|
||||||
|
* frame-ancestors and ignore the legacy header; older ones (IE11) only
|
||||||
|
* understand X-Frame-Options.
|
||||||
|
* - Strict-Transport-Security tells browsers to refuse plain-HTTP for
|
||||||
|
* bocken.org and any subdomain for one year, preventing protocol
|
||||||
|
* downgrade. Browsers ignore the header on http:// loads, so dev on
|
||||||
|
* localhost is unaffected. `preload` deliberately omitted — the HSTS
|
||||||
|
* preload list is hard to leave; revisit only after a stable production
|
||||||
|
* deployment.
|
||||||
|
*/
|
||||||
|
async function securityHeaders({ event, resolve }: Parameters<Handle>[0]) {
|
||||||
|
const response = await resolve(event);
|
||||||
|
return applyHeaders(response, [
|
||||||
|
['X-Frame-Options', 'DENY'],
|
||||||
|
['Content-Security-Policy', "frame-ancestors 'none'"],
|
||||||
|
['Strict-Transport-Security', 'max-age=31536000; includeSubDomains']
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
async function timing({ event, resolve }: Parameters<Handle>[0]) {
|
async function timing({ event, resolve }: Parameters<Handle>[0]) {
|
||||||
const marks: Record<string, number> = {};
|
const marks: Record<string, number> = {};
|
||||||
event.locals.timing = {
|
event.locals.timing = {
|
||||||
@@ -72,8 +118,7 @@ async function timing({ event, resolve }: Parameters<Handle>[0]) {
|
|||||||
const header = Object.entries(marks)
|
const header = Object.entries(marks)
|
||||||
.map(([k, v]) => `${k};dur=${v.toFixed(1)}`)
|
.map(([k, v]) => `${k};dur=${v.toFixed(1)}`)
|
||||||
.join(', ');
|
.join(', ');
|
||||||
response.headers.set('Server-Timing', header);
|
return applyHeaders(response, [['Server-Timing', header]]);
|
||||||
return response;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const init: ServerInit = async () => {
|
export const init: ServerInit = async () => {
|
||||||
@@ -172,8 +217,14 @@ async function authorization({ event, resolve }: Parameters<Handle>[0]) {
|
|||||||
return resolve(event);
|
return resolve(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Browser/crawler probes for these paths are routine 404s — not bugs.
|
||||||
|
* Skip the noisy console.error so real errors stay visible. */
|
||||||
|
const SILENT_404_PATHS = new Set(['/favicon.ico', '/apple-touch-icon.png', '/apple-touch-icon-precomposed.png']);
|
||||||
|
|
||||||
export const handleError: HandleServerError = async ({ error, event, status, message }) => {
|
export const handleError: HandleServerError = async ({ error, event, status, message }) => {
|
||||||
console.error('Error occurred:', { error, status, message, url: event.url.pathname });
|
if (!(status === 404 && SILENT_404_PATHS.has(event.url.pathname))) {
|
||||||
|
console.error('Error occurred:', { error, status, message, url: event.url.pathname });
|
||||||
|
}
|
||||||
|
|
||||||
const bibleQuote = await getRandomVerse(event.fetch, event.url.pathname);
|
const bibleQuote = await getRandomVerse(event.fetch, event.url.pathname);
|
||||||
const isEnglish = event.url.pathname.startsWith('/faith/') || event.url.pathname.startsWith('/recipes/');
|
const isEnglish = event.url.pathname.startsWith('/faith/') || event.url.pathname.startsWith('/recipes/');
|
||||||
@@ -189,6 +240,7 @@ export const handle: Handle = sequence(
|
|||||||
timing,
|
timing,
|
||||||
htmlLang,
|
htmlLang,
|
||||||
noindex,
|
noindex,
|
||||||
|
securityHeaders,
|
||||||
auth.handle,
|
auth.handle,
|
||||||
authorization
|
authorization
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,656 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||||
|
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||||
|
import Calendar from '@lucide/svelte/icons/calendar';
|
||||||
|
import Clock from '@lucide/svelte/icons/clock';
|
||||||
|
import X from '@lucide/svelte/icons/x';
|
||||||
|
import { m } from '$lib/js/commonI18n';
|
||||||
|
import type { CommonLang } from '$lib/js/commonI18n';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pill-styled date + (optional) time picker. Built to share the look of
|
||||||
|
* `DatePicker.svelte` while operating on numeric `unix-ms` timestamps so it
|
||||||
|
* can be dropped into any `wp.timestamp: number | null` shaped store without
|
||||||
|
* an intermediate string conversion at every callsite.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - `mode='date'` hides the time pill (useful for waypoints that don't
|
||||||
|
* need a time anchor in their GPX export).
|
||||||
|
* - `inheritedValue` lets a caller suggest a tentative default (e.g. the
|
||||||
|
* nearest timestamped sibling's date). It's rendered in italic with a
|
||||||
|
* dashed outline; an "Übernehmen" button commits it to the bound value.
|
||||||
|
* User edits via the calendar, the time input, the day arrows, or any
|
||||||
|
* nudge button also implicitly commit the inherited value.
|
||||||
|
* - `nudgeMinutes` renders a row of ±N minute quick-adjust buttons. Only
|
||||||
|
* shown when a value is set and `mode='datetime'`.
|
||||||
|
* - `required` hides the clear button (e.g. first/last waypoint must keep
|
||||||
|
* a timestamp for the export's interpolation to bind).
|
||||||
|
*/
|
||||||
|
interface Props {
|
||||||
|
value: number | null | undefined;
|
||||||
|
mode?: 'date' | 'datetime';
|
||||||
|
inheritedValue?: number | null;
|
||||||
|
nudgeMinutes?: number[];
|
||||||
|
required?: boolean;
|
||||||
|
lang?: CommonLang;
|
||||||
|
min?: number | null;
|
||||||
|
max?: number | null;
|
||||||
|
/** Optional extra CSS class on the outer wrapper. */
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = $bindable<number | null | undefined>(null),
|
||||||
|
mode = 'datetime',
|
||||||
|
inheritedValue = null,
|
||||||
|
nudgeMinutes = [],
|
||||||
|
required = false,
|
||||||
|
lang = 'de',
|
||||||
|
min = null,
|
||||||
|
max = null,
|
||||||
|
class: extraClass = ''
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const t = $derived(m[lang]);
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
let pickerRef = $state<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const WEEKDAYS_EN = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
|
||||||
|
const WEEKDAYS_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||||||
|
const MONTHS_EN = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
||||||
|
const MONTHS_DE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
|
||||||
|
|
||||||
|
const weekdays = $derived(lang === 'de' ? WEEKDAYS_DE : WEEKDAYS_EN);
|
||||||
|
const months = $derived(lang === 'de' ? MONTHS_DE : MONTHS_EN);
|
||||||
|
|
||||||
|
// When the bound value is null but the caller supplied an inherited default,
|
||||||
|
// the pill displays the inherited timestamp in "tentative" styling. The
|
||||||
|
// `effective` getter is what every formatting/derived computation runs on.
|
||||||
|
const effective = $derived<number | null>((value ?? inheritedValue) ?? null);
|
||||||
|
const inheritedActive = $derived(value == null && inheritedValue != null);
|
||||||
|
|
||||||
|
function pad(n: number): string {
|
||||||
|
return n.toString().padStart(2, '0');
|
||||||
|
}
|
||||||
|
function toDateStr(ts: number | null): string {
|
||||||
|
if (ts == null) return '';
|
||||||
|
const d = new Date(ts);
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||||
|
}
|
||||||
|
function toTimeStr(ts: number | null): string {
|
||||||
|
if (ts == null) return '';
|
||||||
|
const d = new Date(ts);
|
||||||
|
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateStr = $derived(toDateStr(effective));
|
||||||
|
const timeStr = $derived(toTimeStr(effective));
|
||||||
|
const todayStr = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const dateLabel = $derived.by(() => {
|
||||||
|
if (!dateStr) return t.select_date;
|
||||||
|
if (dateStr === todayStr && lang in t) return t.today;
|
||||||
|
const d = new Date(dateStr + 'T12:00:00');
|
||||||
|
return d.toLocaleDateString(lang === 'de' ? 'de-DE' : 'en-US', {
|
||||||
|
weekday: 'short', month: 'short', day: 'numeric'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calendar view month — independent of selected value, updated when `value`
|
||||||
|
// or the dropdown opens to follow the relevant month.
|
||||||
|
let viewYear = $state(new Date().getFullYear());
|
||||||
|
let viewMonth = $state(new Date().getMonth());
|
||||||
|
$effect(() => {
|
||||||
|
const ref = effective ?? Date.now();
|
||||||
|
const d = new Date(ref);
|
||||||
|
viewYear = d.getFullYear();
|
||||||
|
viewMonth = d.getMonth();
|
||||||
|
});
|
||||||
|
|
||||||
|
function isDisabled(ts: number): boolean {
|
||||||
|
if (min != null && ts < min) return true;
|
||||||
|
if (max != null && ts > max) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function commit(ts: number | null) {
|
||||||
|
if (ts != null && isDisabled(ts)) return;
|
||||||
|
value = ts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTimestamp(date: string, time: string): number | null {
|
||||||
|
if (!date) return null;
|
||||||
|
const [y, m, d] = date.split('-').map((n) => parseInt(n, 10));
|
||||||
|
if (!Number.isFinite(y) || !Number.isFinite(m) || !Number.isFinite(d)) return null;
|
||||||
|
let hh = 12, mm = 0;
|
||||||
|
if (time) {
|
||||||
|
const parts = time.split(':').map((n) => parseInt(n, 10));
|
||||||
|
if (Number.isFinite(parts[0])) hh = parts[0];
|
||||||
|
if (Number.isFinite(parts[1])) mm = parts[1];
|
||||||
|
}
|
||||||
|
return new Date(y, m - 1, d, hh, mm, 0, 0).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectDay(date: string) {
|
||||||
|
// When committing, preserve the time-of-day of the current effective
|
||||||
|
// value so picking a new date doesn't reset to noon and discard the
|
||||||
|
// user's already-tuned time.
|
||||||
|
commit(buildTimestamp(date, mode === 'datetime' ? timeStr || '12:00' : '12:00'));
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTimeInput(value: string) {
|
||||||
|
if (!value) return;
|
||||||
|
commit(buildTimestamp(dateStr || toDateStr(Date.now()), value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function navDay(delta: number) {
|
||||||
|
const ref = effective ?? Date.now();
|
||||||
|
const d = new Date(ref);
|
||||||
|
d.setDate(d.getDate() + delta);
|
||||||
|
commit(d.getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
function navMonth(delta: number) {
|
||||||
|
viewMonth += delta;
|
||||||
|
if (viewMonth > 11) { viewMonth = 0; viewYear++; }
|
||||||
|
if (viewMonth < 0) { viewMonth = 11; viewYear--; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function nudge(deltaMin: number) {
|
||||||
|
const base = effective;
|
||||||
|
if (base == null) return;
|
||||||
|
commit(base + deltaMin * 60_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyInherited() {
|
||||||
|
if (inheritedValue == null) return;
|
||||||
|
commit(inheritedValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
value = null;
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function goNow() {
|
||||||
|
commit(Date.now());
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const calendarDays = $derived.by(() => {
|
||||||
|
const first = new Date(viewYear, viewMonth, 1);
|
||||||
|
let startDay = first.getDay() - 1;
|
||||||
|
if (startDay < 0) startDay = 6;
|
||||||
|
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
|
||||||
|
const daysInPrevMonth = new Date(viewYear, viewMonth, 0).getDate();
|
||||||
|
|
||||||
|
type Day = {
|
||||||
|
date: string;
|
||||||
|
day: number;
|
||||||
|
currentMonth: boolean;
|
||||||
|
isToday: boolean;
|
||||||
|
isSelected: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
};
|
||||||
|
const days: Day[] = [];
|
||||||
|
|
||||||
|
const pushDay = (y: number, mo: number, d: number, currentMonth: boolean) => {
|
||||||
|
const date = `${y}-${pad(mo + 1)}-${pad(d)}`;
|
||||||
|
const ts = new Date(y, mo, d, 12, 0, 0, 0).getTime();
|
||||||
|
days.push({
|
||||||
|
date, day: d, currentMonth,
|
||||||
|
isToday: date === todayStr,
|
||||||
|
isSelected: date === dateStr && value != null,
|
||||||
|
disabled: isDisabled(ts)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = startDay - 1; i >= 0; i--) {
|
||||||
|
const d = daysInPrevMonth - i;
|
||||||
|
const mo = viewMonth === 0 ? 11 : viewMonth - 1;
|
||||||
|
const y = viewMonth === 0 ? viewYear - 1 : viewYear;
|
||||||
|
pushDay(y, mo, d, false);
|
||||||
|
}
|
||||||
|
for (let d = 1; d <= daysInMonth; d++) {
|
||||||
|
pushDay(viewYear, viewMonth, d, true);
|
||||||
|
}
|
||||||
|
const remaining = 7 - (days.length % 7);
|
||||||
|
if (remaining < 7) {
|
||||||
|
for (let d = 1; d <= remaining; d++) {
|
||||||
|
const mo = viewMonth === 11 ? 0 : viewMonth + 1;
|
||||||
|
const y = viewMonth === 11 ? viewYear + 1 : viewYear;
|
||||||
|
pushDay(y, mo, d, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return days;
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
if (pickerRef && e.target instanceof Node && !pickerRef.contains(e.target)) {
|
||||||
|
open = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$effect(() => {
|
||||||
|
if (open) {
|
||||||
|
document.addEventListener('pointerdown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('pointerdown', handleClickOutside);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const negativeNudges = $derived(
|
||||||
|
[...nudgeMinutes].filter((n) => n < 0).sort((a, b) => a - b)
|
||||||
|
);
|
||||||
|
const positiveNudges = $derived(
|
||||||
|
[...nudgeMinutes].filter((n) => n > 0).sort((a, b) => a - b)
|
||||||
|
);
|
||||||
|
const showNudge = $derived(
|
||||||
|
mode === 'datetime' && effective != null && nudgeMinutes.length > 0
|
||||||
|
);
|
||||||
|
const showClear = $derived(!required && value != null);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="dtp {extraClass}" bind:this={pickerRef}>
|
||||||
|
<div class="dtp-pill" class:inherited={inheritedActive} class:empty={effective == null}>
|
||||||
|
<button type="button" class="dtp-arrow" onclick={() => navDay(-1)} aria-label="-1d">
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
</button>
|
||||||
|
<button type="button" class="dtp-display" onclick={() => (open = !open)}>
|
||||||
|
<Calendar size={14} />
|
||||||
|
<span class="dtp-date-label">{dateLabel}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="dtp-arrow" onclick={() => navDay(1)} aria-label="+1d">
|
||||||
|
<ChevronRight size={16} />
|
||||||
|
</button>
|
||||||
|
{#if mode === 'datetime'}
|
||||||
|
{#if showNudge && negativeNudges.length > 0}
|
||||||
|
<div class="dtp-nudge dtp-nudge-neg" role="group" aria-label={t.select_time}>
|
||||||
|
{#each negativeNudges as delta (delta)}
|
||||||
|
<button type="button" onclick={() => nudge(delta)}>
|
||||||
|
−{Math.abs(delta)}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<label class="dtp-time" title={t.select_time}>
|
||||||
|
<Clock size={13} aria-hidden="true" />
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={timeStr}
|
||||||
|
onchange={(e) => updateTimeInput(e.currentTarget.value)}
|
||||||
|
aria-label={t.select_time}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{#if showNudge && positiveNudges.length > 0}
|
||||||
|
<div class="dtp-nudge dtp-nudge-pos" role="group" aria-label={t.select_time}>
|
||||||
|
{#each positiveNudges as delta (delta)}
|
||||||
|
<button type="button" onclick={() => nudge(delta)}>
|
||||||
|
+{delta}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div class="dtp-dropdown" role="dialog" aria-label={t.select_date}>
|
||||||
|
<div class="dtp-header">
|
||||||
|
<button type="button" class="dtp-nav" onclick={() => navMonth(-1)} aria-label="<<">
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
</button>
|
||||||
|
<span class="dtp-month-label">{months[viewMonth]} {viewYear}</span>
|
||||||
|
<button type="button" class="dtp-nav" onclick={() => navMonth(1)} aria-label=">>">
|
||||||
|
<ChevronRight size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="dtp-weekdays">
|
||||||
|
{#each weekdays as wd (wd)}
|
||||||
|
<span class="dtp-wd">{wd}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="dtp-grid">
|
||||||
|
{#each calendarDays as day (day.date)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="dtp-day"
|
||||||
|
class:other-month={!day.currentMonth}
|
||||||
|
class:today={day.isToday}
|
||||||
|
class:selected={day.isSelected}
|
||||||
|
class:disabled={day.disabled}
|
||||||
|
disabled={day.disabled}
|
||||||
|
onclick={() => selectDay(day.date)}
|
||||||
|
>
|
||||||
|
{day.day}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="dtp-today-btn" onclick={goNow}>
|
||||||
|
{mode === 'datetime' ? t.now : t.today}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if inheritedActive}
|
||||||
|
<button type="button" class="dtp-accept" onclick={applyInherited}>
|
||||||
|
{t.apply_inherited}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showClear}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="dtp-clear"
|
||||||
|
onclick={clear}
|
||||||
|
aria-label={t.clear}
|
||||||
|
title={t.clear}
|
||||||
|
>
|
||||||
|
<X size={12} strokeWidth={2.25} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.dtp {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
align-items: center;
|
||||||
|
/* Reserve room above the pill for the absolutely-positioned clear
|
||||||
|
* button so it doesn't visually crash into the row above. */
|
||||||
|
padding-top: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dtp-pill {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dtp-pill.inherited {
|
||||||
|
border-style: dashed;
|
||||||
|
background: color-mix(in oklab, var(--color-bg-tertiary) 70%, transparent);
|
||||||
|
}
|
||||||
|
.dtp-pill.inherited .dtp-date-label,
|
||||||
|
.dtp-pill.inherited .dtp-time input {
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.dtp-pill.empty .dtp-date-label {
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dtp-arrow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.35rem 0.4rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color var(--transition-normal), background var(--transition-normal);
|
||||||
|
}
|
||||||
|
.dtp-arrow:hover {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dtp-display {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-left: 1px solid var(--color-border);
|
||||||
|
border-right: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: color var(--transition-normal), background var(--transition-normal);
|
||||||
|
}
|
||||||
|
.dtp-display:hover {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dtp-time {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0 0.55rem;
|
||||||
|
border-left: 1px solid var(--color-border);
|
||||||
|
border-right: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
.dtp-time input {
|
||||||
|
appearance: none;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
min-width: 4.4em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.dtp-time input::-webkit-calendar-picker-indicator {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nudge clusters live INSIDE the pill, flanking the time input. They
|
||||||
|
* share the pill's chrome (no extra borders, no rounded corners — the pill
|
||||||
|
* itself clips them). */
|
||||||
|
.dtp-nudge {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
.dtp-nudge button {
|
||||||
|
appearance: none;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 2.2rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
transition: color var(--transition-normal), background var(--transition-normal);
|
||||||
|
}
|
||||||
|
.dtp-nudge-neg {
|
||||||
|
border-left: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.dtp-nudge-neg button + button {
|
||||||
|
border-left: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.dtp-nudge-pos button + button {
|
||||||
|
border-left: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.dtp-nudge button:hover {
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dropdown calendar — mirrors DatePicker.svelte */
|
||||||
|
.dtp-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 0.4rem);
|
||||||
|
left: 0;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
padding: 0.6rem;
|
||||||
|
z-index: 200;
|
||||||
|
min-width: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dtp-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
.dtp-month-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
.dtp-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.25rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition-normal), color var(--transition-normal);
|
||||||
|
}
|
||||||
|
.dtp-nav:hover {
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dtp-weekdays {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
.dtp-wd {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
padding: 0.2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dtp-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.dtp-day {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: none;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition-normal), color var(--transition-normal);
|
||||||
|
}
|
||||||
|
.dtp-day:hover {
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
}
|
||||||
|
.dtp-day.other-month {
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
.dtp-day.disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.dtp-day.today {
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: inset 0 0 0 1.5px var(--color-primary);
|
||||||
|
}
|
||||||
|
.dtp-day.selected {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-text-on-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.dtp-day.selected:hover {
|
||||||
|
background: var(--color-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dtp-today-btn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
padding: 0.3rem;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition-normal);
|
||||||
|
}
|
||||||
|
.dtp-today-btn:hover {
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dtp-accept {
|
||||||
|
appearance: none;
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.dtp-accept:hover {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-text-on-primary);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Clear button: top-right corner badge, mirrors close-X affordances on
|
||||||
|
* dismissable chips elsewhere. Sits slightly outside the pill so it
|
||||||
|
* doesn't crowd the date/time controls. */
|
||||||
|
.dtp-clear {
|
||||||
|
position: absolute;
|
||||||
|
top: -4px;
|
||||||
|
right: -4px;
|
||||||
|
appearance: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition:
|
||||||
|
color var(--transition-fast),
|
||||||
|
background var(--transition-fast),
|
||||||
|
border-color var(--transition-fast),
|
||||||
|
transform var(--transition-fast);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.dtp-clear:hover {
|
||||||
|
color: var(--color-text-on-primary);
|
||||||
|
background: var(--red);
|
||||||
|
border-color: var(--red);
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -79,6 +79,7 @@
|
|||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-card);
|
border-radius: var(--radius-card);
|
||||||
|
view-transition-name: workout-focus-card;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Eyebrow row: step counter + bodypart + equipment, controls on the right */
|
/* Eyebrow row: step counter + bodypart + equipment, controls on the right */
|
||||||
@@ -120,7 +121,6 @@
|
|||||||
line-height: 1.15;
|
line-height: 1.15;
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
view-transition-name: workout-focus-name;
|
|
||||||
}
|
}
|
||||||
.focus-details {
|
.focus-details {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -147,7 +147,6 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
view-transition-name: workout-focus-progress;
|
|
||||||
}
|
}
|
||||||
.focus-set-label {
|
.focus-set-label {
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import type { Chart as ChartType } from 'chart.js';
|
||||||
|
import type { HikeTrackPoint } from '$types/hikes';
|
||||||
|
import { hover, setHover, clearHover } from './hoverStore.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
track: HikeTrackPoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { track }: Props = $props();
|
||||||
|
|
||||||
|
let canvas = $state<HTMLCanvasElement | undefined>(undefined);
|
||||||
|
let chart: ChartType | null = null;
|
||||||
|
let ChartCtor: typeof import('chart.js').Chart | null = null;
|
||||||
|
|
||||||
|
// Cumulative distance (km) per track point — used as x axis.
|
||||||
|
const cumKm = $derived.by(() => {
|
||||||
|
const out = new Array<number>(track.length);
|
||||||
|
out[0] = 0;
|
||||||
|
const R = 6371;
|
||||||
|
for (let i = 1; i < track.length; i++) {
|
||||||
|
const a = track[i - 1];
|
||||||
|
const b = track[i];
|
||||||
|
const dLat = ((b[1] - a[1]) * Math.PI) / 180;
|
||||||
|
const dLng = ((b[0] - a[0]) * Math.PI) / 180;
|
||||||
|
const sinLat = Math.sin(dLat / 2);
|
||||||
|
const sinLng = Math.sin(dLng / 2);
|
||||||
|
const h =
|
||||||
|
sinLat * sinLat +
|
||||||
|
Math.cos((a[1] * Math.PI) / 180) * Math.cos((b[1] * Math.PI) / 180) * sinLng * sinLng;
|
||||||
|
out[i] = out[i - 1] + 2 * R * Math.asin(Math.sqrt(h));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
function isDark(): boolean {
|
||||||
|
const t = document.documentElement.getAttribute('data-theme');
|
||||||
|
if (t === 'dark') return true;
|
||||||
|
if (t === 'light') return false;
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPrimaryColor(): string {
|
||||||
|
const v = getComputedStyle(document.documentElement).getPropertyValue('--color-primary').trim();
|
||||||
|
return v || '#5e81ac';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToRgba(hex: string, alpha: number): string {
|
||||||
|
const h = hex.replace('#', '');
|
||||||
|
const full = h.length === 3 ? h.split('').map((c) => c + c).join('') : h;
|
||||||
|
const r = parseInt(full.slice(0, 2), 16);
|
||||||
|
const g = parseInt(full.slice(2, 4), 16);
|
||||||
|
const b = parseInt(full.slice(4, 6), 16);
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createChart() {
|
||||||
|
if (!canvas || !ChartCtor) return;
|
||||||
|
if (chart) chart.destroy();
|
||||||
|
|
||||||
|
const dark = isDark();
|
||||||
|
const textColor = dark ? '#D8DEE9' : '#2E3440';
|
||||||
|
const gridColor = dark ? 'rgba(216,222,233,0.1)' : 'rgba(46,52,64,0.08)';
|
||||||
|
const primary = readPrimaryColor();
|
||||||
|
|
||||||
|
const data = track.map((p, i) => ({ x: cumKm[i], y: typeof p[2] === 'number' ? p[2] : null }));
|
||||||
|
|
||||||
|
// Custom plugin: a dashed vertical guide line at the chart's
|
||||||
|
// active-element x. Works for both pointer-driven hovers and the
|
||||||
|
// externally-triggered setActiveElements path (map, scroll tracker)
|
||||||
|
// because both populate `chart.tooltip._active`.
|
||||||
|
const verticalLine = {
|
||||||
|
id: 'verticalCursor',
|
||||||
|
afterDatasetsDraw(c: ChartType) {
|
||||||
|
const active = c.tooltip?.getActiveElements?.() ?? [];
|
||||||
|
if (active.length === 0) return;
|
||||||
|
const x = (active[0].element as unknown as { x: number }).x;
|
||||||
|
const { top, bottom } = c.chartArea;
|
||||||
|
const ctx = c.ctx;
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, top);
|
||||||
|
ctx.lineTo(x, bottom);
|
||||||
|
ctx.setLineDash([4, 4]);
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeStyle = primary;
|
||||||
|
ctx.globalAlpha = 0.85;
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
chart = new ChartCtor(canvas, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Höhe',
|
||||||
|
data,
|
||||||
|
borderColor: primary,
|
||||||
|
backgroundColor: hexToRgba(primary, 0.18),
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 0,
|
||||||
|
// Active state needs to be visually obvious: a solid
|
||||||
|
// dot at the hovered index, with the dashed line from
|
||||||
|
// the custom plugin above carrying the eye down to the
|
||||||
|
// x-axis.
|
||||||
|
pointHoverRadius: 6,
|
||||||
|
pointHoverBackgroundColor: primary,
|
||||||
|
pointHoverBorderColor: '#fff',
|
||||||
|
pointHoverBorderWidth: 2,
|
||||||
|
tension: 0.2,
|
||||||
|
fill: 'origin',
|
||||||
|
spanGaps: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
plugins: [verticalLine],
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
animation: false,
|
||||||
|
interaction: { mode: 'index', intersect: false },
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
displayColors: false,
|
||||||
|
callbacks: {
|
||||||
|
title: (items) => `${(items[0].parsed.x as number).toFixed(2)} km`,
|
||||||
|
label: (item) => `${Math.round(item.parsed.y as number)} m`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
type: 'linear',
|
||||||
|
// Pin the axis to the actual data range so Chart.js doesn't
|
||||||
|
// round up to the next nice tick — otherwise a 12.3 km hike
|
||||||
|
// ends up with empty space out to 14 km.
|
||||||
|
min: 0,
|
||||||
|
max: cumKm[cumKm.length - 1] ?? 0,
|
||||||
|
bounds: 'data',
|
||||||
|
title: { display: true, text: 'Distanz (km)', color: textColor },
|
||||||
|
ticks: { color: textColor },
|
||||||
|
grid: { color: gridColor }
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
title: { display: true, text: 'Höhe (m)', color: textColor },
|
||||||
|
ticks: { color: textColor },
|
||||||
|
grid: { color: gridColor }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onHover: (_evt, elements) => {
|
||||||
|
if (elements.length === 0) {
|
||||||
|
if (hover.source === 'chart') clearHover();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setHover(elements[0].index, 'chart');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
let disposed = false;
|
||||||
|
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
const onTheme = () => setTimeout(createChart, 100);
|
||||||
|
let obs: MutationObserver | undefined;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const { Chart, registerables } = await import('chart.js');
|
||||||
|
if (disposed) return;
|
||||||
|
Chart.register(...registerables);
|
||||||
|
ChartCtor = Chart;
|
||||||
|
createChart();
|
||||||
|
mq.addEventListener('change', onTheme);
|
||||||
|
obs = new MutationObserver((muts) => {
|
||||||
|
for (const m of muts) if (m.attributeName === 'data-theme') onTheme();
|
||||||
|
});
|
||||||
|
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
mq.removeEventListener('change', onTheme);
|
||||||
|
obs?.disconnect();
|
||||||
|
if (chart) chart.destroy();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// React to external hover (from the map, image markers, or the page-
|
||||||
|
// level scroll tracker) by painting the matching tooltip + cursor at
|
||||||
|
// the right pixel position.
|
||||||
|
//
|
||||||
|
// IMPORTANT: read both `hover.source` and `hover.index` at the very
|
||||||
|
// top of the effect so Svelte registers the subscription on the first
|
||||||
|
// run — even when the Chart.js instance isn't ready yet (the import is
|
||||||
|
// async). If we early-returned on `!chart` first, the hover reads
|
||||||
|
// would never happen and the effect would never re-run for external
|
||||||
|
// updates after the chart finally mounted.
|
||||||
|
$effect(() => {
|
||||||
|
const src = hover.source;
|
||||||
|
const idx = hover.index;
|
||||||
|
if (!chart) return;
|
||||||
|
if (src === 'chart') return;
|
||||||
|
if (idx === null) {
|
||||||
|
chart.setActiveElements([]);
|
||||||
|
chart.tooltip?.setActiveElements([], { x: 0, y: 0 });
|
||||||
|
chart.update('none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (idx < 0 || idx >= track.length) return;
|
||||||
|
const datasetIndex = 0;
|
||||||
|
const meta = chart.getDatasetMeta(datasetIndex);
|
||||||
|
const elem = meta?.data?.[idx] as { x: number; y: number } | undefined;
|
||||||
|
const anchor = elem ? { x: elem.x, y: elem.y } : { x: 0, y: 0 };
|
||||||
|
chart.setActiveElements([{ datasetIndex, index: idx }]);
|
||||||
|
chart.tooltip?.setActiveElements([{ datasetIndex, index: idx }], anchor);
|
||||||
|
chart.update('none');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mouse-leave on the canvas clears the shared hover state so the map marker
|
||||||
|
// disappears too.
|
||||||
|
function onCanvasMouseLeave() {
|
||||||
|
if (hover.source === 'chart') clearHover();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="elevation">
|
||||||
|
<canvas bind:this={canvas} onmouseleave={onCanvasMouseLeave}></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.elevation {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 220px;
|
||||||
|
margin-top: 1rem;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import Route from '@lucide/svelte/icons/route';
|
||||||
|
import Clock from '@lucide/svelte/icons/clock';
|
||||||
|
import TrendingUp from '@lucide/svelte/icons/trending-up';
|
||||||
|
import TrendingDown from '@lucide/svelte/icons/trending-down';
|
||||||
|
import Mountain from '@lucide/svelte/icons/mountain';
|
||||||
|
import CalendarRange from '@lucide/svelte/icons/calendar-range';
|
||||||
|
import type { HikeManifestEntry } from '$types/hikes';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
hike: HikeManifestEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { hike }: Props = $props();
|
||||||
|
|
||||||
|
const durationLabel = $derived(
|
||||||
|
hike.durationMin !== null && hike.durationMin > 0
|
||||||
|
? `${Math.floor(hike.durationMin / 60)}h ${hike.durationMin % 60}m`
|
||||||
|
: '—'
|
||||||
|
);
|
||||||
|
|
||||||
|
// SAC trail-sign colour scheme (matches the detail page):
|
||||||
|
// T1 yellow Wegweiser, T2/T3 white-red-white Bergwanderweg,
|
||||||
|
// T4–T6 white-blue-white Alpinwanderweg.
|
||||||
|
const sacBand = $derived.by<'yellow' | 'red' | 'blue'>(() => {
|
||||||
|
if (hike.difficulty === 'T1') return 'yellow';
|
||||||
|
if (hike.difficulty === 'T2' || hike.difficulty === 'T3') return 'red';
|
||||||
|
return 'blue';
|
||||||
|
});
|
||||||
|
|
||||||
|
const MONTHS_DE_SHORT = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'];
|
||||||
|
const seasonLabel = $derived.by(() => {
|
||||||
|
const a = hike.seasonStart;
|
||||||
|
const b = hike.seasonEnd;
|
||||||
|
if (a == null || b == null) return null;
|
||||||
|
if (a < 1 || a > 12 || b < 1 || b > 12) return null;
|
||||||
|
return `${MONTHS_DE_SHORT[a - 1]}–${MONTHS_DE_SHORT[b - 1]}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// "Neu" badge for hikes published within the last 30 days. Uses the
|
||||||
|
// frontmatter date (`YYYY-MM-DD`) compared against the build clock —
|
||||||
|
// good enough for a prerendered listing page that rebuilds on every
|
||||||
|
// content change.
|
||||||
|
const isRecent = $derived.by(() => {
|
||||||
|
const t = Date.parse(hike.date);
|
||||||
|
if (!Number.isFinite(t)) return false;
|
||||||
|
const days = (Date.now() - t) / 86_400_000;
|
||||||
|
return days >= 0 && days <= 30;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<a class="card" href={resolve('/hikes/[slug]', { slug: hike.slug })} style="view-transition-name: hike-{hike.slug}">
|
||||||
|
<div class="cover">
|
||||||
|
{#if hike.cover.src}
|
||||||
|
<picture>
|
||||||
|
<source type="image/avif" srcset={hike.cover.srcsetAvif} sizes="(max-width: 600px) 100vw, 400px" />
|
||||||
|
<source type="image/webp" srcset={hike.cover.srcsetWebp} sizes="(max-width: 600px) 100vw, 400px" />
|
||||||
|
<img
|
||||||
|
src={hike.cover.src}
|
||||||
|
alt={hike.cover.alt}
|
||||||
|
width={hike.cover.width}
|
||||||
|
height={hike.cover.height}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
</picture>
|
||||||
|
{:else}
|
||||||
|
<div class="cover-placeholder"></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if hike.icon}
|
||||||
|
<span class="icon-pin" aria-hidden="true">
|
||||||
|
<img src={hike.icon} alt="" />
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<span class="sac-pin" aria-label="SAC-Schwierigkeit {hike.difficulty}">
|
||||||
|
<span class="sac-marker sac-marker-{sacBand}">{hike.difficulty}</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{#if isRecent}
|
||||||
|
<span class="recent-badge">Neu</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="body">
|
||||||
|
<header class="head">
|
||||||
|
<h2 class="title">{hike.title}</h2>
|
||||||
|
{#if hike.region}
|
||||||
|
<p class="region">{hike.region}{hike.canton && hike.canton !== hike.region ? `, ${hike.canton}` : ''}</p>
|
||||||
|
{/if}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="metrics">
|
||||||
|
<span title="Distanz"><Route size={14} strokeWidth={1.75} aria-hidden="true" />{hike.distanceKm.toFixed(1)} km</span>
|
||||||
|
<span title="Dauer"><Clock size={14} strokeWidth={1.75} aria-hidden="true" />{durationLabel}</span>
|
||||||
|
<span title="Aufstieg"><TrendingUp size={14} strokeWidth={1.75} aria-hidden="true" />{hike.elevationGainM} m</span>
|
||||||
|
<span title="Abstieg"><TrendingDown size={14} strokeWidth={1.75} aria-hidden="true" />{hike.elevationLossM} m</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if (hike.elevationMinM !== null && hike.elevationMaxM !== null) || seasonLabel}
|
||||||
|
<footer class="foot">
|
||||||
|
{#if hike.elevationMinM !== null && hike.elevationMaxM !== null}
|
||||||
|
<span class="chip" title="Höhenlage">
|
||||||
|
<Mountain size={12} strokeWidth={1.75} aria-hidden="true" />{hike.elevationMinM}–{hike.elevationMaxM} m
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if seasonLabel}
|
||||||
|
<span class="chip" title="Empfohlene Saison">
|
||||||
|
<CalendarRange size={12} strokeWidth={1.75} aria-hidden="true" />{seasonLabel}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</footer>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: scale var(--transition-normal), box-shadow var(--transition-normal);
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
scale: 1.02;
|
||||||
|
box-shadow: var(--shadow-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 16 / 10;
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
picture,
|
||||||
|
.cover-placeholder {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
picture img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Per-route identity icon, top-left of cover. Floats directly on the
|
||||||
|
* image — a soft drop-shadow keeps it legible without a backdrop. */
|
||||||
|
.icon-pin {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.55rem;
|
||||||
|
left: 0.55rem;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
filter: drop-shadow(0 2px 4px rgb(0 0 0 / 0.45));
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-pin img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SAC difficulty badge, top-right of cover. Same approach — sits on
|
||||||
|
* the image with a drop-shadow for separation, no white backdrop. */
|
||||||
|
.sac-pin {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.55rem;
|
||||||
|
right: 0.55rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
filter: drop-shadow(0 2px 4px rgb(0 0 0 / 0.45));
|
||||||
|
}
|
||||||
|
|
||||||
|
.sac-marker {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 22px;
|
||||||
|
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sac-marker-yellow {
|
||||||
|
width: 36px;
|
||||||
|
color: #1a1a1a;
|
||||||
|
background: #f5a623;
|
||||||
|
clip-path: polygon(0 0, 75% 0, 100% 50%, 75% 100%, 0 100%);
|
||||||
|
/* Text in the rectangular left portion (arrow tip is right 25%). */
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding-left: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sac-marker-red,
|
||||||
|
.sac-marker-blue {
|
||||||
|
width: 44px;
|
||||||
|
color: #fff;
|
||||||
|
text-shadow: 0 1px 1px rgb(0 0 0 / 0.45);
|
||||||
|
border-radius: 2px;
|
||||||
|
box-shadow: 0 0 0 1px rgb(0 0 0 / 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sac-marker-red {
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
#fff 0 25%,
|
||||||
|
#dc1d2a 25% 75%,
|
||||||
|
#fff 75% 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sac-marker-blue {
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
#fff 0 25%,
|
||||||
|
#2965c8 25% 75%,
|
||||||
|
#fff 75% 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bottom-left freshness marker — only when the hike was published in
|
||||||
|
* the last 30 days. Small, brand-coloured, all-caps. */
|
||||||
|
.recent-badge {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0.55rem;
|
||||||
|
left: 0.55rem;
|
||||||
|
padding: 0.18rem 0.6rem;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-text-on-primary);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
box-shadow: 0 2px 6px rgb(0 0 0 / 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.9rem 1rem 1rem;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.region {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem 0.85rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics :global(svg) {
|
||||||
|
color: var(--color-primary);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.foot {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip :global(svg) {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { getHikeContext } from './hikeContext.svelte';
|
||||||
|
import { focused } from './focusedImageStore.svelte';
|
||||||
|
import { addScrollAnchor } from './scrollAnchors';
|
||||||
|
import Lock from '@lucide/svelte/icons/lock';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Position in the hike's full chronological image list (0-indexed,
|
||||||
|
* stable across viewers because it refers to the unfiltered list). */
|
||||||
|
idx: number;
|
||||||
|
/** Optional caption shown under the image — narrative blurb, not a
|
||||||
|
* machine-derived label. Elapsed time is shown automatically. */
|
||||||
|
caption?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { idx, caption }: Props = $props();
|
||||||
|
const ctx = getHikeContext();
|
||||||
|
|
||||||
|
const ip = $derived(ctx().images[idx]);
|
||||||
|
const visible = $derived(ip ? ctx().visibleImages.includes(ip) : false);
|
||||||
|
const visibleIdx = $derived(visible ? ctx().visibleImages.indexOf(ip) : -1);
|
||||||
|
const isActive = $derived(visibleIdx >= 0 && focused.index === visibleIdx);
|
||||||
|
|
||||||
|
// Find the track point closest in time to this image. Used by the
|
||||||
|
// page-level scroll listener to interpolate a "current trail position"
|
||||||
|
// between adjacent images as the reader scrolls past them.
|
||||||
|
const trackIdx = $derived.by(() => {
|
||||||
|
const t = ip?.timestamp;
|
||||||
|
const track = ctx().track;
|
||||||
|
if (typeof t !== 'number' || !track) return -1;
|
||||||
|
let bestIdx = 0;
|
||||||
|
let bestDelta = Infinity;
|
||||||
|
for (let i = 0; i < track.length; i++) {
|
||||||
|
const tt = track[i][3];
|
||||||
|
if (typeof tt !== 'number') continue;
|
||||||
|
const d = Math.abs(tt - t);
|
||||||
|
if (d < bestDelta) {
|
||||||
|
bestDelta = d;
|
||||||
|
bestIdx = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestDelta === Infinity ? -1 : bestIdx;
|
||||||
|
});
|
||||||
|
|
||||||
|
let figure: HTMLElement | undefined = $state();
|
||||||
|
|
||||||
|
// Register this image's DOM element as a scroll anchor. The page reads
|
||||||
|
// these anchors on each scroll frame to compute the active trail
|
||||||
|
// position. Desktop-only — there's no sticky map to drive on mobile.
|
||||||
|
$effect(() => {
|
||||||
|
if (!figure) return;
|
||||||
|
if (visibleIdx < 0 || trackIdx < 0) return;
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
if (!window.matchMedia('(min-width: 1024px)').matches) return;
|
||||||
|
return addScrollAnchor({ element: figure, trackIdx, visibleIdx });
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if ip && visible}
|
||||||
|
<figure class="hike-image" class:active={isActive} bind:this={figure}>
|
||||||
|
<img
|
||||||
|
src={ip.src}
|
||||||
|
alt={ip.alt}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
{#if ip.visibility === 'private'}
|
||||||
|
<span class="private" title="Privates Bild — nur für eingeloggte Benutzer sichtbar">
|
||||||
|
<Lock size={11} strokeWidth={2.25} aria-hidden="true" />
|
||||||
|
privat
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if caption}
|
||||||
|
<figcaption>{caption}</figcaption>
|
||||||
|
{/if}
|
||||||
|
</figure>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.hike-image {
|
||||||
|
position: relative;
|
||||||
|
margin: 2rem 0;
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
overflow: hidden;
|
||||||
|
background: #14181f;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
transition: box-shadow 280ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hike-image.active {
|
||||||
|
box-shadow:
|
||||||
|
0 18px 32px -8px color-mix(in oklab, var(--color-primary) 45%, transparent),
|
||||||
|
0 6px 14px -6px rgb(0 0 0 / 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hike-image img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
object-fit: cover;
|
||||||
|
background: #14181f;
|
||||||
|
}
|
||||||
|
|
||||||
|
figcaption {
|
||||||
|
padding: 0.6rem 0.85rem 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
background: var(--color-surface);
|
||||||
|
font-style: italic;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.private {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.6rem;
|
||||||
|
left: 0.6rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 0.18rem 0.5rem;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: rgb(0 0 0 / 0.55);
|
||||||
|
color: #fff;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
-webkit-backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.hike-image {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,810 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Attachment } from 'svelte/attachments';
|
||||||
|
import type { HikeTrackPoint, ImagePoint } from '$types/hikes';
|
||||||
|
import { hover, setHover, clearHover } from './hoverStore.svelte';
|
||||||
|
import { focused, setFocused, clearFocused } from './focusedImageStore.svelte';
|
||||||
|
import Map from '@lucide/svelte/icons/map';
|
||||||
|
import Satellite from '@lucide/svelte/icons/satellite';
|
||||||
|
import Landmark from '@lucide/svelte/icons/landmark';
|
||||||
|
import Layers from '@lucide/svelte/icons/layers';
|
||||||
|
import Camera from '@lucide/svelte/icons/camera';
|
||||||
|
import CameraOff from '@lucide/svelte/icons/camera-off';
|
||||||
|
import Locate from '@lucide/svelte/icons/locate';
|
||||||
|
import LocateOff from '@lucide/svelte/icons/locate-off';
|
||||||
|
import Maximize2 from '@lucide/svelte/icons/maximize-2';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
track: HikeTrackPoint[];
|
||||||
|
imagePoints?: ImagePoint[];
|
||||||
|
/** When false, private images are hidden — anonymous viewers only see
|
||||||
|
* public ones. Logged-in users get the full set. */
|
||||||
|
showPrivate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { track, imagePoints = [], showPrivate = false }: Props = $props();
|
||||||
|
|
||||||
|
// User-location toggle moved inside the map UI. localStorage-persisted so
|
||||||
|
// returning visitors get the same state. Permission errors surface as a
|
||||||
|
// small inline message just under the controls.
|
||||||
|
const GPS_STORAGE_KEY = 'hikes:gpsEnabled';
|
||||||
|
let enableUserLocation = $state(false);
|
||||||
|
let locationError = $state<string | null>(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
if (window.localStorage.getItem(GPS_STORAGE_KEY) === '1') enableUserLocation = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
window.localStorage.setItem(GPS_STORAGE_KEY, enableUserLocation ? '1' : '0');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close the layer menu when clicking anywhere outside of it. The opening
|
||||||
|
// click on the button calls stopPropagation, so this handler never sees
|
||||||
|
// the click that flipped layerMenuOpen to true.
|
||||||
|
$effect(() => {
|
||||||
|
if (!layerMenuOpen) return;
|
||||||
|
function onAway(e: MouseEvent) {
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
if (target && !target.closest('.layer-menu')) {
|
||||||
|
layerMenuOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('click', onAway);
|
||||||
|
return () => window.removeEventListener('click', onAway);
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleLocation() {
|
||||||
|
if (enableUserLocation) {
|
||||||
|
enableUserLocation = false;
|
||||||
|
locationError = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const hasTauri = '__TAURI_INTERNALS__' in window;
|
||||||
|
const hasWebGeo = 'geolocation' in navigator;
|
||||||
|
if (!hasTauri && !hasWebGeo) {
|
||||||
|
locationError = 'Geolocation steht in diesem Browser nicht zur Verfügung.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
locationError = null;
|
||||||
|
enableUserLocation = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SWISSTOPO_FARBE = 'https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.pixelkarte-farbe/default/current/3857/{z}/{x}/{y}.jpeg';
|
||||||
|
const SWISSTOPO_IMAGE = 'https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.swissimage/default/current/3857/{z}/{x}/{y}.jpeg';
|
||||||
|
const SWISSTOPO_DUFOUR = 'https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.hiks-dufour/default/current/3857/{z}/{x}/{y}.png';
|
||||||
|
const SWISSTOPO_ATTRIBUTION = '© <a href="https://www.swisstopo.admin.ch/" target="_blank" rel="noopener">swisstopo</a>';
|
||||||
|
|
||||||
|
type BaseLayer = 'schematic' | 'aerial' | 'dufour';
|
||||||
|
const LAYER_DEFS: Record<BaseLayer, { label: string; icon: typeof Map; maxZoom: number }> = {
|
||||||
|
schematic: { label: 'Karte', icon: Map, maxZoom: 19 },
|
||||||
|
aerial: { label: 'Luftbild', icon: Satellite, maxZoom: 19 },
|
||||||
|
// Dufour Map (1845–1864): swisstopo's historical layer, only goes up
|
||||||
|
// to roughly z16. We cap the map's maxZoom when this layer is active.
|
||||||
|
dufour: { label: 'Dufour (1864)', icon: Landmark, maxZoom: 16 }
|
||||||
|
};
|
||||||
|
|
||||||
|
let showPhotos = $state(true);
|
||||||
|
let baseLayer = $state<BaseLayer>('schematic');
|
||||||
|
let layerMenuOpen = $state(false);
|
||||||
|
// Re-fit-bounds callback — populated once the map and polyline are
|
||||||
|
// alive inside the Leaflet attachment. Null until then so the button
|
||||||
|
// can be hidden / disabled.
|
||||||
|
let recenterMap = $state<(() => void) | null>(null);
|
||||||
|
|
||||||
|
// Cleanup hover/focus state when the component unmounts.
|
||||||
|
$effect(() => {
|
||||||
|
return () => {
|
||||||
|
clearHover();
|
||||||
|
clearFocused();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// The strip and the map share `focused` via the focusedImageStore. The map
|
||||||
|
// owns the visible filtered list internally; the strip works against the
|
||||||
|
// same filtered list, so the index is consistent between them.
|
||||||
|
|
||||||
|
const mapAttachment: Attachment<HTMLElement> = (node) => {
|
||||||
|
let cleanup: (() => void) | undefined;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const L = await import('leaflet');
|
||||||
|
if (cancelled || !node.isConnected) return;
|
||||||
|
|
||||||
|
const latLngs: [number, number][] = track.map((p) => [p[1], p[0]]);
|
||||||
|
|
||||||
|
const map = L.map(node, {
|
||||||
|
attributionControl: true,
|
||||||
|
zoomControl: true,
|
||||||
|
preferCanvas: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const tileLayers: Record<BaseLayer, ReturnType<typeof L.tileLayer>> = {
|
||||||
|
schematic: L.tileLayer(SWISSTOPO_FARBE, {
|
||||||
|
maxZoom: LAYER_DEFS.schematic.maxZoom,
|
||||||
|
minZoom: 7,
|
||||||
|
attribution: SWISSTOPO_ATTRIBUTION,
|
||||||
|
updateWhenZooming: false
|
||||||
|
}),
|
||||||
|
aerial: L.tileLayer(SWISSTOPO_IMAGE, {
|
||||||
|
maxZoom: LAYER_DEFS.aerial.maxZoom,
|
||||||
|
minZoom: 7,
|
||||||
|
attribution: SWISSTOPO_ATTRIBUTION,
|
||||||
|
updateWhenZooming: false
|
||||||
|
}),
|
||||||
|
dufour: L.tileLayer(SWISSTOPO_DUFOUR, {
|
||||||
|
maxZoom: LAYER_DEFS.dufour.maxZoom,
|
||||||
|
minZoom: 7,
|
||||||
|
attribution: SWISSTOPO_ATTRIBUTION,
|
||||||
|
updateWhenZooming: false
|
||||||
|
})
|
||||||
|
};
|
||||||
|
tileLayers.schematic.addTo(map);
|
||||||
|
let currentBase: BaseLayer = 'schematic';
|
||||||
|
|
||||||
|
// Canvas-rendered polylines can't resolve CSS custom properties, so read
|
||||||
|
// the trail color from the document at mount time. Nord red contrasts
|
||||||
|
// strongly against both the schematic map and the aerial imagery.
|
||||||
|
const trailColor =
|
||||||
|
getComputedStyle(document.documentElement).getPropertyValue('--red').trim() ||
|
||||||
|
'#bf616a';
|
||||||
|
|
||||||
|
const polyline = L.polyline(latLngs, {
|
||||||
|
color: trailColor,
|
||||||
|
weight: 4,
|
||||||
|
opacity: 0.95
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
L.circleMarker(latLngs[0], {
|
||||||
|
radius: 6,
|
||||||
|
fillColor: '#a3be8c',
|
||||||
|
fillOpacity: 1,
|
||||||
|
color: '#fff',
|
||||||
|
weight: 2
|
||||||
|
}).addTo(map);
|
||||||
|
L.circleMarker(latLngs[latLngs.length - 1], {
|
||||||
|
radius: 6,
|
||||||
|
fillColor: '#bf616a',
|
||||||
|
fillOpacity: 1,
|
||||||
|
color: '#fff',
|
||||||
|
weight: 2
|
||||||
|
}).addTo(map);
|
||||||
|
const initialBounds = polyline.getBounds();
|
||||||
|
map.fitBounds(initialBounds, { padding: [24, 24] });
|
||||||
|
|
||||||
|
// Expose a re-focus callback that re-fits the polyline bounds —
|
||||||
|
// the same view the user started with after dragging or zooming
|
||||||
|
// somewhere else. Smooth flyToBounds rather than instant fit so
|
||||||
|
// the transition reads as a deliberate gesture.
|
||||||
|
recenterMap = () => {
|
||||||
|
map.flyToBounds(initialBounds, {
|
||||||
|
padding: [24, 24],
|
||||||
|
duration: 0.6,
|
||||||
|
easeLinearity: 0.25
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hovered-vertex marker (driven by the shared hover store). Rendered
|
||||||
|
// as a lucide MapPin in a divIcon so its tip aligns with the actual
|
||||||
|
// track point — circle markers were ambiguous about which lat/lng
|
||||||
|
// they were claiming.
|
||||||
|
const hoverIcon = L.divIcon({
|
||||||
|
className: 'hike-hover-pin',
|
||||||
|
html:
|
||||||
|
'<svg viewBox="0 0 24 24" aria-hidden="true">' +
|
||||||
|
'<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0" fill="currentColor" stroke="rgba(0,0,0,0.35)" stroke-width="1.25" stroke-linejoin="round"/>' +
|
||||||
|
'<circle cx="12" cy="10" r="3" fill="#fff"/>' +
|
||||||
|
'</svg>',
|
||||||
|
iconSize: [28, 34],
|
||||||
|
iconAnchor: [14, 32]
|
||||||
|
});
|
||||||
|
const hoverMarker = L.marker(latLngs[0], {
|
||||||
|
icon: hoverIcon,
|
||||||
|
interactive: false,
|
||||||
|
keyboard: false,
|
||||||
|
zIndexOffset: 1000
|
||||||
|
});
|
||||||
|
|
||||||
|
// Image markers — compact camera badges. The full image lives in the
|
||||||
|
// HikePhotoStage below the strip, so the map keeps the trail visible.
|
||||||
|
// Hovering or clicking a marker just writes to the focus store; the
|
||||||
|
// stage and strip react.
|
||||||
|
let photoLayer = L.layerGroup().addTo(map);
|
||||||
|
// Parallel arrays: the visible subset of imagePoints (post visibility
|
||||||
|
// filter) and the Leaflet markers we built for them. Their indices
|
||||||
|
// match what the photo strip is using, so a strip-side `focused.index`
|
||||||
|
// maps directly into `visibleMarkers`.
|
||||||
|
let visiblePoints: ImagePoint[] = [];
|
||||||
|
let visibleMarkers: ReturnType<typeof L.marker>[] = [];
|
||||||
|
|
||||||
|
// Lucide Camera path, inlined so the divIcon stays self-contained.
|
||||||
|
const cameraSvg =
|
||||||
|
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
||||||
|
'<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/>' +
|
||||||
|
'<circle cx="12" cy="13" r="3"/>' +
|
||||||
|
'</svg>';
|
||||||
|
|
||||||
|
function renderPhotos() {
|
||||||
|
photoLayer.clearLayers();
|
||||||
|
visiblePoints = [];
|
||||||
|
visibleMarkers = [];
|
||||||
|
if (!showPhotos) return;
|
||||||
|
for (const ip of imagePoints) {
|
||||||
|
if (ip.visibility === 'private' && !showPrivate) continue;
|
||||||
|
const visibleIdx = visiblePoints.length;
|
||||||
|
visiblePoints.push(ip);
|
||||||
|
const altSafe = ip.alt.replace(/"/g, '"');
|
||||||
|
const isPrivate = ip.visibility === 'private';
|
||||||
|
const icon = L.divIcon({
|
||||||
|
className: `hike-photo-marker${isPrivate ? ' is-private' : ''}`,
|
||||||
|
html: `<span class="badge" title="${altSafe}">${cameraSvg}</span>`,
|
||||||
|
iconSize: [28, 28],
|
||||||
|
iconAnchor: [14, 14]
|
||||||
|
});
|
||||||
|
const marker = L.marker([ip.lat, ip.lng], { icon });
|
||||||
|
marker.on('mouseover', () => {
|
||||||
|
// Hover preview → stage. Distinct source so the strip
|
||||||
|
// skips scroll (jerky across dense clusters) and the
|
||||||
|
// map skips its own flyTo + focus ring.
|
||||||
|
setFocused(visibleIdx, 'map-hover');
|
||||||
|
// Also drive the chart cursor via the nearest track sample.
|
||||||
|
if (typeof ip.timestamp !== 'number') return;
|
||||||
|
let bestIdx = 0;
|
||||||
|
let bestDelta = Infinity;
|
||||||
|
for (let i = 0; i < track.length; i++) {
|
||||||
|
const t = track[i][3];
|
||||||
|
if (typeof t !== 'number') continue;
|
||||||
|
const d = Math.abs(t - ip.timestamp);
|
||||||
|
if (d < bestDelta) {
|
||||||
|
bestDelta = d;
|
||||||
|
bestIdx = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setHover(bestIdx, 'image');
|
||||||
|
});
|
||||||
|
// Clear chart hover but keep `focused` sticky so the stage
|
||||||
|
// keeps showing the last hovered image.
|
||||||
|
marker.on('mouseout', () => clearHover());
|
||||||
|
// Click (touch fallback): same semantics as a strip click —
|
||||||
|
// scroll the strip card into view and centre the map.
|
||||||
|
marker.on('click', () => setFocused(visibleIdx, 'map'));
|
||||||
|
marker.addTo(photoLayer);
|
||||||
|
visibleMarkers.push(marker);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renderPhotos();
|
||||||
|
|
||||||
|
// Focus ring: a non-vector divIcon (so we can CSS-animate it under
|
||||||
|
// Leaflet's canvas renderer). Created lazily on first focus.
|
||||||
|
const focusIcon = L.divIcon({
|
||||||
|
className: 'hike-photo-focus-ring',
|
||||||
|
html: '<div class="ring"></div><div class="ring delay"></div>',
|
||||||
|
iconSize: [80, 80],
|
||||||
|
iconAnchor: [40, 40]
|
||||||
|
});
|
||||||
|
let focusMarker: ReturnType<typeof L.marker> | null = null;
|
||||||
|
|
||||||
|
// React to the shared hover store: drive the polyline cursor marker.
|
||||||
|
// When the hover comes from the chart and the point is outside the
|
||||||
|
// currently-visible map area, pan the map so the pin stays in view —
|
||||||
|
// using `pad(-0.12)` shrinks the trigger bounds so we pan a touch
|
||||||
|
// before the pin actually hits the edge.
|
||||||
|
const stopHoverEffect = $effect.root(() => {
|
||||||
|
$effect(() => {
|
||||||
|
if (hover.source === 'map') return;
|
||||||
|
if (hover.index === null || hover.index < 0 || hover.index >= latLngs.length) {
|
||||||
|
hoverMarker.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ll = latLngs[hover.index];
|
||||||
|
hoverMarker.setLatLng(ll);
|
||||||
|
hoverMarker.addTo(map);
|
||||||
|
if (hover.source === 'chart' || hover.source === 'scroll') {
|
||||||
|
const inner = map.getBounds().pad(-0.12);
|
||||||
|
if (!inner.contains(ll)) {
|
||||||
|
map.panTo(ll, { animate: true, duration: 0.35, easeLinearity: 0.3 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// React to the shared focus store: when the strip selects a photo we
|
||||||
|
// fly the map there and drop a pulsing focus ring on top of the marker.
|
||||||
|
// Map-side writes (hover or click) are ignored — the user is already
|
||||||
|
// looking at that marker, no need to pan or ring it.
|
||||||
|
const stopFocusEffect = $effect.root(() => {
|
||||||
|
$effect(() => {
|
||||||
|
const idx = focused.index;
|
||||||
|
if (focused.source === 'map' || focused.source === 'map-hover') return;
|
||||||
|
if (idx === null || idx < 0 || idx >= visiblePoints.length) {
|
||||||
|
if (focusMarker) {
|
||||||
|
focusMarker.remove();
|
||||||
|
focusMarker = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ip = visiblePoints[idx];
|
||||||
|
// For inline-scroll focus changes we don't want to fly the map
|
||||||
|
// on every image boundary — the continuous scroll-pin already
|
||||||
|
// shows the reader where they are. Only fly if the focused
|
||||||
|
// marker is currently off the visible viewport. Other sources
|
||||||
|
// (strip click, chevron, keyboard) keep the full flyTo so the
|
||||||
|
// gesture feels deliberate.
|
||||||
|
const target: [number, number] = [ip.lat, ip.lng];
|
||||||
|
const shouldFly =
|
||||||
|
focused.source !== 'inline' || !map.getBounds().pad(-0.05).contains(target);
|
||||||
|
if (shouldFly) {
|
||||||
|
map.flyTo(target, Math.max(map.getZoom(), 15), {
|
||||||
|
duration: 0.7,
|
||||||
|
easeLinearity: 0.25
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!focusMarker) {
|
||||||
|
focusMarker = L.marker([ip.lat, ip.lng], {
|
||||||
|
icon: focusIcon,
|
||||||
|
interactive: false,
|
||||||
|
keyboard: false,
|
||||||
|
zIndexOffset: -100
|
||||||
|
}).addTo(map);
|
||||||
|
} else {
|
||||||
|
focusMarker.setLatLng([ip.lat, ip.lng]);
|
||||||
|
if (!map.hasLayer(focusMarker)) focusMarker.addTo(map);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Polyline hover → write to store.
|
||||||
|
polyline.on('mousemove', (e: { latlng: { lat: number; lng: number } }) => {
|
||||||
|
let bestIdx = 0;
|
||||||
|
let bestSq = Infinity;
|
||||||
|
const { lat, lng } = e.latlng;
|
||||||
|
for (let i = 0; i < latLngs.length; i++) {
|
||||||
|
const dLat = latLngs[i][0] - lat;
|
||||||
|
const dLng = latLngs[i][1] - lng;
|
||||||
|
const sq = dLat * dLat + dLng * dLng;
|
||||||
|
if (sq < bestSq) {
|
||||||
|
bestSq = sq;
|
||||||
|
bestIdx = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setHover(bestIdx, 'map');
|
||||||
|
});
|
||||||
|
polyline.on('mouseout', () => clearHover());
|
||||||
|
|
||||||
|
// User location (opt-in).
|
||||||
|
let userMarker: ReturnType<typeof L.circleMarker> | null = null;
|
||||||
|
let userAccuracyCircle: ReturnType<typeof L.circle> | null = null;
|
||||||
|
let userCleanup: (() => void) | undefined;
|
||||||
|
|
||||||
|
async function attachUserLocation() {
|
||||||
|
if (!enableUserLocation) return;
|
||||||
|
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
|
||||||
|
const handlePos = (lat: number, lng: number, accuracy: number) => {
|
||||||
|
if (!userMarker) {
|
||||||
|
userMarker = L.circleMarker([lat, lng], {
|
||||||
|
radius: 7,
|
||||||
|
fillColor: '#5e81ac',
|
||||||
|
fillOpacity: 1,
|
||||||
|
color: '#fff',
|
||||||
|
weight: 2
|
||||||
|
}).addTo(map);
|
||||||
|
userAccuracyCircle = L.circle([lat, lng], {
|
||||||
|
radius: accuracy,
|
||||||
|
color: '#5e81ac',
|
||||||
|
fillColor: '#5e81ac',
|
||||||
|
fillOpacity: 0.1,
|
||||||
|
weight: 1
|
||||||
|
}).addTo(map);
|
||||||
|
} else {
|
||||||
|
userMarker.setLatLng([lat, lng]);
|
||||||
|
userAccuracyCircle?.setLatLng([lat, lng]);
|
||||||
|
userAccuracyCircle?.setRadius(accuracy);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (isTauri) {
|
||||||
|
try {
|
||||||
|
const geo = await import('@tauri-apps/plugin-geolocation');
|
||||||
|
const watchId = await geo.watchPosition(
|
||||||
|
{ enableHighAccuracy: true, maximumAge: 5000, timeout: 10_000 },
|
||||||
|
(pos) => {
|
||||||
|
if (pos?.coords) handlePos(pos.coords.latitude, pos.coords.longitude, pos.coords.accuracy ?? 30);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
userCleanup = () => geo.clearWatch(watchId).catch(() => {});
|
||||||
|
} catch {
|
||||||
|
/* Tauri plugin unavailable — fall through to web API */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!userCleanup && 'geolocation' in navigator) {
|
||||||
|
const id = navigator.geolocation.watchPosition(
|
||||||
|
(pos) => handlePos(pos.coords.latitude, pos.coords.longitude, pos.coords.accuracy),
|
||||||
|
() => {},
|
||||||
|
{ enableHighAccuracy: true, maximumAge: 5000, timeout: 10_000 }
|
||||||
|
);
|
||||||
|
userCleanup = () => navigator.geolocation.clearWatch(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
attachUserLocation();
|
||||||
|
|
||||||
|
// React to user-toggle of photo markers, base-layer choice, and the
|
||||||
|
// enableUserLocation prop.
|
||||||
|
const stopReactRoot = $effect.root(() => {
|
||||||
|
$effect(() => {
|
||||||
|
renderPhotos();
|
||||||
|
});
|
||||||
|
$effect(() => {
|
||||||
|
if (baseLayer === currentBase) return;
|
||||||
|
tileLayers[currentBase].remove();
|
||||||
|
tileLayers[baseLayer].addTo(map);
|
||||||
|
// Each historical layer caps out at a lower zoom — clamp the
|
||||||
|
// map so we don't end up on a blank tile, and force the
|
||||||
|
// current zoom back down if it's beyond the new ceiling.
|
||||||
|
const newMax = LAYER_DEFS[baseLayer].maxZoom;
|
||||||
|
map.setMaxZoom(newMax);
|
||||||
|
if (map.getZoom() > newMax) map.setZoom(newMax);
|
||||||
|
currentBase = baseLayer;
|
||||||
|
});
|
||||||
|
$effect(() => {
|
||||||
|
if (!enableUserLocation && userCleanup) {
|
||||||
|
userCleanup();
|
||||||
|
userCleanup = undefined;
|
||||||
|
if (userMarker) userMarker.remove();
|
||||||
|
if (userAccuracyCircle) userAccuracyCircle.remove();
|
||||||
|
userMarker = null;
|
||||||
|
userAccuracyCircle = null;
|
||||||
|
} else if (enableUserLocation && !userCleanup) {
|
||||||
|
attachUserLocation();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
cleanup = () => {
|
||||||
|
userCleanup?.();
|
||||||
|
stopHoverEffect();
|
||||||
|
stopFocusEffect();
|
||||||
|
stopReactRoot();
|
||||||
|
if (focusMarker) focusMarker.remove();
|
||||||
|
recenterMap = null;
|
||||||
|
map.remove();
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
cleanup?.();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="map-wrap">
|
||||||
|
<div class="map" {@attach mapAttachment}></div>
|
||||||
|
|
||||||
|
<div class="map-controls">
|
||||||
|
<div class="layer-menu" class:open={layerMenuOpen}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="round-btn"
|
||||||
|
aria-label="Kartenebene wählen"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={layerMenuOpen}
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
layerMenuOpen = !layerMenuOpen;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Layers size={20} strokeWidth={2} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
{#if layerMenuOpen}
|
||||||
|
<div class="layer-popover" role="menu">
|
||||||
|
{#each Object.entries(LAYER_DEFS) as [key, def] (key)}
|
||||||
|
{@const Icon = def.icon}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitemradio"
|
||||||
|
aria-checked={baseLayer === key}
|
||||||
|
class:active={baseLayer === key}
|
||||||
|
onclick={() => {
|
||||||
|
baseLayer = key as BaseLayer;
|
||||||
|
layerMenuOpen = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size={14} strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
{def.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if recenterMap}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="round-btn"
|
||||||
|
aria-label="Auf die Tour zurückzentrieren"
|
||||||
|
title="Karte auf die gesamte Tour zurückzentrieren"
|
||||||
|
onclick={() => recenterMap?.()}
|
||||||
|
>
|
||||||
|
<Maximize2 size={18} strokeWidth={2} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if imagePoints.length > 0}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="round-btn"
|
||||||
|
class:active={showPhotos}
|
||||||
|
aria-pressed={showPhotos}
|
||||||
|
aria-label={showPhotos ? 'Fotos auf der Karte ausblenden' : 'Fotos auf der Karte anzeigen'}
|
||||||
|
title={showPhotos ? 'Fotos auf der Karte ausblenden' : 'Fotos auf der Karte anzeigen'}
|
||||||
|
onclick={() => (showPhotos = !showPhotos)}
|
||||||
|
>
|
||||||
|
{#if showPhotos}
|
||||||
|
<Camera size={20} strokeWidth={2} aria-hidden="true" />
|
||||||
|
{:else}
|
||||||
|
<CameraOff size={20} strokeWidth={2} aria-hidden="true" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="round-btn"
|
||||||
|
class:active={enableUserLocation}
|
||||||
|
aria-pressed={enableUserLocation}
|
||||||
|
title={enableUserLocation
|
||||||
|
? 'Eigenen Standort verbergen'
|
||||||
|
: 'Eigenen Standort anzeigen — wird lokal berechnet, nicht an Dritte gesendet'}
|
||||||
|
aria-label={enableUserLocation ? 'Eigenen Standort verbergen' : 'Eigenen Standort anzeigen'}
|
||||||
|
onclick={toggleLocation}
|
||||||
|
>
|
||||||
|
{#if enableUserLocation}
|
||||||
|
<Locate size={20} strokeWidth={2} aria-hidden="true" />
|
||||||
|
{:else}
|
||||||
|
<LocateOff size={20} strokeWidth={2} aria-hidden="true" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if locationError}
|
||||||
|
<p class="gps-error" role="status">{locationError}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.map-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map {
|
||||||
|
width: 100%;
|
||||||
|
height: 520px;
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.map {
|
||||||
|
height: 360px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vertical stack of round controls at the bottom-right of the map.
|
||||||
|
* Layer (top) → Camera → GPS (bottom). All three share `.round-btn`
|
||||||
|
* styling; the layer button also anchors a popover menu to its left. */
|
||||||
|
.map-controls {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
z-index: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.round-btn {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
color var(--transition-fast),
|
||||||
|
background var(--transition-fast),
|
||||||
|
transform var(--transition-fast),
|
||||||
|
box-shadow var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.round-btn:hover {
|
||||||
|
color: var(--color-primary);
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: var(--shadow-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.round-btn.active {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-text-on-primary);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.round-btn.active:hover {
|
||||||
|
color: var(--color-text-on-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-menu {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-popover {
|
||||||
|
position: absolute;
|
||||||
|
right: calc(100% + 0.5rem);
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
padding: 0.3rem;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
min-width: 9.5rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-popover button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 0.45rem 0.7rem;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background var(--transition-fast), color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-popover button :global(svg) {
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-popover button:hover {
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-popover button.active {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-text-on-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-popover button.active :global(svg) {
|
||||||
|
color: var(--color-text-on-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* GPS-permission error toast — sits above the bottom-right stack of
|
||||||
|
* round controls. Up to four 44 px buttons + three 0.5 rem gaps make the
|
||||||
|
* stack ~216 px tall (incl. the 1 rem bottom inset), so anchor the
|
||||||
|
* toast at 14 rem to clear it with breathing room. */
|
||||||
|
.gps-error {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 14rem;
|
||||||
|
right: 1rem;
|
||||||
|
max-width: 18rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--red);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
z-index: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.hike-hover-pin) {
|
||||||
|
background: transparent !important;
|
||||||
|
border: 0 !important;
|
||||||
|
color: var(--color-primary);
|
||||||
|
filter: drop-shadow(0 2px 3px rgb(0 0 0 / 0.25));
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.hike-hover-pin svg) {
|
||||||
|
display: block;
|
||||||
|
width: 28px;
|
||||||
|
height: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.hike-photo-marker) {
|
||||||
|
background: transparent !important;
|
||||||
|
border: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.hike-photo-marker .badge) {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-text-on-primary);
|
||||||
|
border: 2px solid var(--color-surface);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.hike-photo-marker .badge svg) {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.hike-photo-marker:hover .badge) {
|
||||||
|
transform: scale(1.15);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.hike-photo-marker.is-private .badge) {
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
color: var(--color-primary);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus ring placed by the photo strip → map sync. Two concentric pulses
|
||||||
|
* with staggered animation make the ring feel alive without strobing. */
|
||||||
|
:global(.hike-photo-focus-ring) {
|
||||||
|
background: transparent !important;
|
||||||
|
border: 0 !important;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.hike-photo-focus-ring .ring) {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3px solid var(--color-primary);
|
||||||
|
background: color-mix(in oklab, var(--color-primary) 14%, transparent);
|
||||||
|
animation: hike-photo-focus-pulse 1.6s cubic-bezier(0.16, 1, 0.3, 1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.hike-photo-focus-ring .ring.delay) {
|
||||||
|
animation-delay: 0.8s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hike-photo-focus-pulse {
|
||||||
|
0% {
|
||||||
|
transform: scale(0.45);
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
opacity: 0.15;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1.25);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
:global(.hike-photo-focus-ring .ring) {
|
||||||
|
animation: none;
|
||||||
|
transform: scale(0.9);
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children?: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { children }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<article class="hike-prose">
|
||||||
|
{@render children?.()}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.hike-prose {
|
||||||
|
max-width: 70ch;
|
||||||
|
margin-inline: auto;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
line-height: 1.7;
|
||||||
|
font-size: var(--text-base, 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hike-prose :global(h2) {
|
||||||
|
margin-top: 2.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: var(--text-2xl, 1.5rem);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hike-prose :global(h3) {
|
||||||
|
margin-top: 2rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
font-size: var(--text-xl, 1.25rem);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hike-prose :global(p) {
|
||||||
|
margin-block: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hike-prose :global(a) {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 0.15em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hike-prose :global(a:hover) {
|
||||||
|
color: var(--color-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hike-prose :global(blockquote) {
|
||||||
|
margin-block: 1.5rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-inline-start: 3px solid var(--color-primary);
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hike-prose :global(img) {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
margin-block: 1.5rem;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hike-prose :global(figure) {
|
||||||
|
margin-block: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hike-prose :global(figcaption) {
|
||||||
|
text-align: center;
|
||||||
|
font-size: var(--text-sm, 0.875rem);
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hike-prose :global(ul),
|
||||||
|
.hike-prose :global(ol) {
|
||||||
|
padding-inline-start: 1.5rem;
|
||||||
|
margin-block: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hike-prose :global(li) {
|
||||||
|
margin-block: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hike-prose :global(code) {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
padding: 0.1em 0.35em;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hike-prose :global(pre) {
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hike-prose :global(pre code) {
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hike-prose :global(hr) {
|
||||||
|
margin-block: 2rem;
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HikeTrackPoint, ImagePoint } from '$types/hikes';
|
||||||
|
import { focused, setFocused } from './focusedImageStore.svelte';
|
||||||
|
import MapPin from '@lucide/svelte/icons/map-pin';
|
||||||
|
import Lock from '@lucide/svelte/icons/lock';
|
||||||
|
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||||
|
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
images: ImagePoint[];
|
||||||
|
track: HikeTrackPoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { images, track }: Props = $props();
|
||||||
|
|
||||||
|
const startTimestamp = $derived.by(() => {
|
||||||
|
for (const p of track) {
|
||||||
|
if (typeof p[3] === 'number') return p[3];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatElapsed(ms: number): string {
|
||||||
|
if (!Number.isFinite(ms) || ms < 0) return '–';
|
||||||
|
const totalMin = Math.round(ms / 60000);
|
||||||
|
if (totalMin < 60) return `${totalMin} min`;
|
||||||
|
const h = Math.floor(totalMin / 60);
|
||||||
|
const m = totalMin % 60;
|
||||||
|
return m === 0 ? `${h} h` : `${h} h ${m} min`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardEls: Array<HTMLButtonElement | null> = $state([]);
|
||||||
|
let scrollEl = $state<HTMLDivElement | undefined>(undefined);
|
||||||
|
|
||||||
|
// Recenter the active card horizontally inside the strip on focus change.
|
||||||
|
// We scroll only the strip's own X axis — `scrollIntoView` would also
|
||||||
|
// pull the page Y to bring the strip into the viewport, which is not
|
||||||
|
// what we want here. Map-hover writes are skipped because they fire
|
||||||
|
// rapidly across dense clusters and would jerk the strip around; map
|
||||||
|
// clicks and strip/keyboard navigation still recenter.
|
||||||
|
$effect(() => {
|
||||||
|
const idx = focused.index;
|
||||||
|
if (idx === null || idx < 0) return;
|
||||||
|
if (focused.source === 'map-hover') return;
|
||||||
|
const el = cardEls[idx];
|
||||||
|
if (!el || !scrollEl) return;
|
||||||
|
const targetLeft = el.offsetLeft + el.offsetWidth / 2 - scrollEl.clientWidth / 2;
|
||||||
|
scrollEl.scrollTo({ left: targetLeft, behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
|
||||||
|
function onCardClick(idx: number): void {
|
||||||
|
// Toggle off if the user re-clicks the already-active card.
|
||||||
|
if (focused.index === idx) {
|
||||||
|
setFocused(null, 'strip');
|
||||||
|
} else {
|
||||||
|
setFocused(idx, 'strip');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function advance(direction: -1 | 1): void {
|
||||||
|
if (images.length === 0) return;
|
||||||
|
const current = focused.index;
|
||||||
|
let next: number;
|
||||||
|
if (current === null) {
|
||||||
|
next = direction === 1 ? 0 : images.length - 1;
|
||||||
|
} else {
|
||||||
|
next = current + direction;
|
||||||
|
if (next < 0 || next >= images.length) return;
|
||||||
|
}
|
||||||
|
setFocused(next, 'strip');
|
||||||
|
}
|
||||||
|
|
||||||
|
const canPrev = $derived(focused.index !== null && focused.index > 0);
|
||||||
|
const canNext = $derived(
|
||||||
|
focused.index === null ? images.length > 0 : focused.index < images.length - 1
|
||||||
|
);
|
||||||
|
|
||||||
|
function onKey(e: KeyboardEvent): void {
|
||||||
|
if (images.length === 0) return;
|
||||||
|
if (e.key === 'ArrowLeft') {
|
||||||
|
e.preventDefault();
|
||||||
|
advance(-1);
|
||||||
|
} else if (e.key === 'ArrowRight') {
|
||||||
|
e.preventDefault();
|
||||||
|
advance(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if images.length > 0}
|
||||||
|
<section class="strip-section" aria-label="Bilder der Tour">
|
||||||
|
<header class="strip-header">
|
||||||
|
<h2 class="strip-title">Bildstrecke</h2>
|
||||||
|
<span class="strip-hint">
|
||||||
|
<MapPin size={14} strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
Klicken zeigt die Position auf der Karte
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="strip-frame">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="chev chev-left"
|
||||||
|
aria-label="Vorheriges Bild"
|
||||||
|
disabled={!canPrev}
|
||||||
|
onclick={() => advance(-1)}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={20} strokeWidth={2.25} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="strip-scroll"
|
||||||
|
bind:this={scrollEl}
|
||||||
|
onkeydown={onKey}
|
||||||
|
role="listbox"
|
||||||
|
tabindex="0"
|
||||||
|
aria-label="Tourenfotos chronologisch"
|
||||||
|
>
|
||||||
|
{#each images as ip, i (ip.src)}
|
||||||
|
{@const elapsed =
|
||||||
|
ip.timestamp != null && startTimestamp != null
|
||||||
|
? formatElapsed(ip.timestamp - startTimestamp)
|
||||||
|
: null}
|
||||||
|
{@const active = focused.index === i}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="card"
|
||||||
|
class:active
|
||||||
|
class:private={ip.visibility === 'private'}
|
||||||
|
bind:this={cardEls[i]}
|
||||||
|
onclick={() => onCardClick(i)}
|
||||||
|
aria-label={`Foto ${i + 1} von ${images.length}${elapsed ? `, nach ${elapsed}` : ''}`}
|
||||||
|
role="option"
|
||||||
|
aria-selected={active}
|
||||||
|
>
|
||||||
|
<img src={ip.thumbnail} alt={ip.alt} loading="lazy" decoding="async" />
|
||||||
|
<div class="overlay">
|
||||||
|
{#if elapsed}
|
||||||
|
<span class="chip-elapsed">nach {elapsed}</span>
|
||||||
|
{/if}
|
||||||
|
<span class="chip-index">{i + 1}/{images.length}</span>
|
||||||
|
</div>
|
||||||
|
{#if ip.visibility === 'private'}
|
||||||
|
<span class="badge-private" aria-label="Privat">
|
||||||
|
<Lock size={11} strokeWidth={2.25} aria-hidden="true" />
|
||||||
|
privat
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="chev chev-right"
|
||||||
|
aria-label="Nächstes Bild"
|
||||||
|
disabled={!canNext}
|
||||||
|
onclick={() => advance(1)}
|
||||||
|
>
|
||||||
|
<ChevronRight size={20} strokeWidth={2.25} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.strip-section {
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-hint {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-hint :global(svg) {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.strip-hint {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-frame {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-scroll {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: visible;
|
||||||
|
scroll-snap-type: x mandatory;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
/* Vertical padding makes room for the lifted active card's shadow. */
|
||||||
|
padding: 1rem 0.25rem 1.5rem;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--color-border) transparent;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-scroll:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: 4px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
position: relative;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 232px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
scroll-snap-align: center;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
transition:
|
||||||
|
transform 220ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
box-shadow 220ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover,
|
||||||
|
.card:focus-visible {
|
||||||
|
transform: translateY(-2px) scale(1.02);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active card stands out via a much heavier, tinted drop shadow rather
|
||||||
|
* than dimming everything else — keeps every photo legible. */
|
||||||
|
.card.active {
|
||||||
|
transform: translateY(-6px) scale(1.05);
|
||||||
|
box-shadow:
|
||||||
|
0 18px 32px -8px color-mix(in oklab, var(--color-primary) 55%, transparent),
|
||||||
|
0 6px 14px -6px rgb(0 0 0 / 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
object-fit: cover;
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: auto 0 0 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
background: linear-gradient(to top, rgb(0 0 0 / 0.55), transparent);
|
||||||
|
color: #fff;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-elapsed {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: rgb(0 0 0 / 0.45);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
-webkit-backdrop-filter: blur(4px);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-index {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
opacity: 0.85;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-private {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
left: 0.5rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 0.18rem 0.5rem;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: rgb(0 0 0 / 0.55);
|
||||||
|
color: #fff;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
-webkit-backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chev {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 2;
|
||||||
|
transition:
|
||||||
|
transform var(--transition-fast),
|
||||||
|
box-shadow var(--transition-fast),
|
||||||
|
color var(--transition-fast),
|
||||||
|
opacity var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chev-left {
|
||||||
|
left: -8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chev-right {
|
||||||
|
right: -8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chev:hover:not(:disabled) {
|
||||||
|
color: var(--color-primary);
|
||||||
|
transform: translateY(-50%) scale(1.08);
|
||||||
|
box-shadow: var(--shadow-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chev:disabled {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.card {
|
||||||
|
width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chev {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chev-left {
|
||||||
|
left: -4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chev-right {
|
||||||
|
right: -4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.card,
|
||||||
|
.strip-scroll,
|
||||||
|
.chev {
|
||||||
|
transition: none;
|
||||||
|
scroll-behavior: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
|
import type { Difficulty, HikeManifestEntry } from '$types/hikes';
|
||||||
|
|
||||||
|
export type HikesFilter = {
|
||||||
|
maxDistanceKm: number;
|
||||||
|
maxDurationMin: number;
|
||||||
|
maxGainM: number;
|
||||||
|
maxLossM: number;
|
||||||
|
difficulties: SvelteSet<Difficulty>;
|
||||||
|
regions: SvelteSet<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
hikes: HikeManifestEntry[];
|
||||||
|
filter: HikesFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIFFICULTIES: Difficulty[] = ['T1', 'T2', 'T3', 'T4', 'T5', 'T6'];
|
||||||
|
|
||||||
|
const { hikes, filter }: Props = $props();
|
||||||
|
|
||||||
|
const maxDistance = $derived(Math.max(1, ...hikes.map((h) => Math.ceil(h.distanceKm))));
|
||||||
|
const maxDuration = $derived(Math.max(60, ...hikes.map((h) => h.durationMin ?? 0)));
|
||||||
|
const maxGain = $derived(Math.max(100, ...hikes.map((h) => h.elevationGainM)));
|
||||||
|
const maxLoss = $derived(Math.max(100, ...hikes.map((h) => h.elevationLossM)));
|
||||||
|
|
||||||
|
const regions = $derived.by(() => {
|
||||||
|
const seen: Record<string, true> = {};
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const h of hikes) {
|
||||||
|
if (h.region && !seen[h.region]) {
|
||||||
|
seen[h.region] = true;
|
||||||
|
out.push(h.region);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => a.localeCompare(b));
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleDifficulty(d: Difficulty) {
|
||||||
|
if (filter.difficulties.has(d)) filter.difficulties.delete(d);
|
||||||
|
else filter.difficulties.add(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRegion(r: string) {
|
||||||
|
if (filter.regions.has(r)) filter.regions.delete(r);
|
||||||
|
else filter.regions.add(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
filter.maxDistanceKm = maxDistance;
|
||||||
|
filter.maxDurationMin = maxDuration;
|
||||||
|
filter.maxGainM = maxGain;
|
||||||
|
filter.maxLossM = maxLoss;
|
||||||
|
filter.difficulties.clear();
|
||||||
|
filter.regions.clear();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<aside class="filter-bar">
|
||||||
|
<div class="row">
|
||||||
|
<label>
|
||||||
|
<span class="label-row">
|
||||||
|
<span>Distanz</span>
|
||||||
|
<span class="value">≤ {filter.maxDistanceKm} km</span>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="1"
|
||||||
|
max={maxDistance}
|
||||||
|
step="1"
|
||||||
|
bind:value={filter.maxDistanceKm}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span class="label-row">
|
||||||
|
<span>Dauer</span>
|
||||||
|
<span class="value">≤ {Math.floor(filter.maxDurationMin / 60)}h {filter.maxDurationMin % 60}m</span>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max={maxDuration}
|
||||||
|
step="15"
|
||||||
|
bind:value={filter.maxDurationMin}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span class="label-row">
|
||||||
|
<span>Aufstieg</span>
|
||||||
|
<span class="value">≤ {filter.maxGainM} m</span>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max={maxGain}
|
||||||
|
step="50"
|
||||||
|
bind:value={filter.maxGainM}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span class="label-row">
|
||||||
|
<span>Abstieg</span>
|
||||||
|
<span class="value">≤ {filter.maxLossM} m</span>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max={maxLoss}
|
||||||
|
step="50"
|
||||||
|
bind:value={filter.maxLossM}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<fieldset>
|
||||||
|
<legend>Schwierigkeit (SAC)</legend>
|
||||||
|
<div class="pills">
|
||||||
|
{#each DIFFICULTIES as d (d)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pill"
|
||||||
|
class:active={filter.difficulties.has(d)}
|
||||||
|
onclick={() => toggleDifficulty(d)}
|
||||||
|
>{d}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{#if regions.length > 0}
|
||||||
|
<fieldset>
|
||||||
|
<legend>Region</legend>
|
||||||
|
<div class="pills">
|
||||||
|
{#each regions as r (r)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pill"
|
||||||
|
class:active={filter.regions.has(r)}
|
||||||
|
onclick={() => toggleRegion(r)}
|
||||||
|
>{r}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button type="button" class="reset" onclick={resetFilters}>Zurücksetzen</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.filter-bar {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row + .row {
|
||||||
|
margin-top: 1rem;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='range'] {
|
||||||
|
width: 100%;
|
||||||
|
accent-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldset {
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
legend {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pills {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
appearance: none;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.25rem 0.7rem;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: scale var(--transition-fast), background-color var(--transition-fast), color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill:hover {
|
||||||
|
scale: 1.05;
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill.active {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-text-on-primary);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset {
|
||||||
|
align-self: center;
|
||||||
|
justify-self: end;
|
||||||
|
appearance: none;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset:hover {
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,513 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Attachment } from 'svelte/attachments';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import type { HikeManifestEntry, Difficulty } from '$types/hikes';
|
||||||
|
import Map from '@lucide/svelte/icons/map';
|
||||||
|
import Satellite from '@lucide/svelte/icons/satellite';
|
||||||
|
import Landmark from '@lucide/svelte/icons/landmark';
|
||||||
|
import Layers from '@lucide/svelte/icons/layers';
|
||||||
|
import Locate from '@lucide/svelte/icons/locate';
|
||||||
|
import LocateOff from '@lucide/svelte/icons/locate-off';
|
||||||
|
import Maximize2 from '@lucide/svelte/icons/maximize-2';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
hikes: HikeManifestEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { hikes }: Props = $props();
|
||||||
|
|
||||||
|
// Per-tier polyline colour, matching the painted-marker scheme on the
|
||||||
|
// SAC badges. Canvas-rendered polylines can't resolve CSS variables,
|
||||||
|
// so the values are hard-coded — keep in sync with HikeCard.svelte.
|
||||||
|
const SAC_COLOR: Record<Difficulty, string> = {
|
||||||
|
T1: '#f5a623',
|
||||||
|
T2: '#dc1d2a',
|
||||||
|
T3: '#dc1d2a',
|
||||||
|
T4: '#2965c8',
|
||||||
|
T5: '#2965c8',
|
||||||
|
T6: '#2965c8'
|
||||||
|
};
|
||||||
|
|
||||||
|
const SWISSTOPO_FARBE = 'https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.pixelkarte-farbe/default/current/3857/{z}/{x}/{y}.jpeg';
|
||||||
|
const SWISSTOPO_IMAGE = 'https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.swissimage/default/current/3857/{z}/{x}/{y}.jpeg';
|
||||||
|
const SWISSTOPO_DUFOUR = 'https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.hiks-dufour/default/current/3857/{z}/{x}/{y}.png';
|
||||||
|
const SWISSTOPO_ATTRIBUTION =
|
||||||
|
'© <a href="https://www.swisstopo.admin.ch/" target="_blank" rel="noopener">swisstopo</a>';
|
||||||
|
|
||||||
|
type BaseLayer = 'schematic' | 'aerial' | 'dufour';
|
||||||
|
const LAYER_DEFS: Record<BaseLayer, { label: string; icon: typeof Map; maxZoom: number }> = {
|
||||||
|
schematic: { label: 'Karte', icon: Map, maxZoom: 19 },
|
||||||
|
aerial: { label: 'Luftbild', icon: Satellite, maxZoom: 19 },
|
||||||
|
dufour: { label: 'Dufour (1864)', icon: Landmark, maxZoom: 16 }
|
||||||
|
};
|
||||||
|
|
||||||
|
const GPS_STORAGE_KEY = 'hikes:gpsEnabled';
|
||||||
|
|
||||||
|
let baseLayer = $state<BaseLayer>('schematic');
|
||||||
|
let layerMenuOpen = $state(false);
|
||||||
|
let enableUserLocation = $state(false);
|
||||||
|
let locationError = $state<string | null>(null);
|
||||||
|
// Re-fit callback wired up once Leaflet + bounds are alive inside the
|
||||||
|
// attachment. Null hides the button.
|
||||||
|
let recenterMap = $state<(() => void) | null>(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
if (window.localStorage.getItem(GPS_STORAGE_KEY) === '1') enableUserLocation = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
window.localStorage.setItem(GPS_STORAGE_KEY, enableUserLocation ? '1' : '0');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close the layer popover on outside click. The opening click on the
|
||||||
|
// button calls stopPropagation so this never sees the click that opened it.
|
||||||
|
$effect(() => {
|
||||||
|
if (!layerMenuOpen) return;
|
||||||
|
function onAway(e: MouseEvent) {
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
if (target && !target.closest('.layer-menu')) layerMenuOpen = false;
|
||||||
|
}
|
||||||
|
window.addEventListener('click', onAway);
|
||||||
|
return () => window.removeEventListener('click', onAway);
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleLocation() {
|
||||||
|
if (enableUserLocation) {
|
||||||
|
enableUserLocation = false;
|
||||||
|
locationError = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const hasTauri = '__TAURI_INTERNALS__' in window;
|
||||||
|
const hasWebGeo = 'geolocation' in navigator;
|
||||||
|
if (!hasTauri && !hasWebGeo) {
|
||||||
|
locationError = 'Geolocation steht in diesem Browser nicht zur Verfügung.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
locationError = null;
|
||||||
|
enableUserLocation = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapAttachment: Attachment<HTMLElement> = (node) => {
|
||||||
|
let cancelled = false;
|
||||||
|
let cleanup: (() => void) | undefined;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const L = await import('leaflet');
|
||||||
|
if (cancelled || !node.isConnected) return;
|
||||||
|
|
||||||
|
const map = L.map(node, {
|
||||||
|
attributionControl: true,
|
||||||
|
zoomControl: true,
|
||||||
|
preferCanvas: true
|
||||||
|
}).setView([46.8, 8.3], 8);
|
||||||
|
|
||||||
|
const tileLayers: Record<BaseLayer, ReturnType<typeof L.tileLayer>> = {
|
||||||
|
schematic: L.tileLayer(SWISSTOPO_FARBE, {
|
||||||
|
maxZoom: LAYER_DEFS.schematic.maxZoom,
|
||||||
|
minZoom: 7,
|
||||||
|
attribution: SWISSTOPO_ATTRIBUTION,
|
||||||
|
updateWhenZooming: false
|
||||||
|
}),
|
||||||
|
aerial: L.tileLayer(SWISSTOPO_IMAGE, {
|
||||||
|
maxZoom: LAYER_DEFS.aerial.maxZoom,
|
||||||
|
minZoom: 7,
|
||||||
|
attribution: SWISSTOPO_ATTRIBUTION,
|
||||||
|
updateWhenZooming: false
|
||||||
|
}),
|
||||||
|
dufour: L.tileLayer(SWISSTOPO_DUFOUR, {
|
||||||
|
maxZoom: LAYER_DEFS.dufour.maxZoom,
|
||||||
|
minZoom: 7,
|
||||||
|
attribution: SWISSTOPO_ATTRIBUTION,
|
||||||
|
updateWhenZooming: false
|
||||||
|
})
|
||||||
|
};
|
||||||
|
tileLayers.schematic.addTo(map);
|
||||||
|
let currentBase: BaseLayer = 'schematic';
|
||||||
|
|
||||||
|
// One polyline per hike, sourced from the manifest's already-
|
||||||
|
// simplified previewPolyline (≤30 points each).
|
||||||
|
const layer = L.layerGroup().addTo(map);
|
||||||
|
const bounds = L.latLngBounds([]);
|
||||||
|
for (const hike of hikes) {
|
||||||
|
if (!hike.previewPolyline || hike.previewPolyline.length < 2) continue;
|
||||||
|
const latLngs = hike.previewPolyline.map(([lat, lng]) => [lat, lng] as [number, number]);
|
||||||
|
const color = SAC_COLOR[hike.difficulty] ?? '#5e81ac';
|
||||||
|
const poly = L.polyline(latLngs, {
|
||||||
|
color,
|
||||||
|
weight: 4,
|
||||||
|
opacity: 0.9,
|
||||||
|
interactive: true
|
||||||
|
}).addTo(layer);
|
||||||
|
|
||||||
|
poly.bindTooltip(
|
||||||
|
`<strong>${hike.title}</strong><br>` +
|
||||||
|
`${hike.distanceKm.toFixed(1)} km · ↑${hike.elevationGainM} m · SAC ${hike.difficulty}`,
|
||||||
|
{ sticky: true, direction: 'top', opacity: 0.95, className: 'hike-overview-tooltip' }
|
||||||
|
);
|
||||||
|
poly.on('mouseover', () => {
|
||||||
|
poly.setStyle({ weight: 7, opacity: 1 });
|
||||||
|
poly.bringToFront();
|
||||||
|
});
|
||||||
|
poly.on('mouseout', () => {
|
||||||
|
poly.setStyle({ weight: 4, opacity: 0.9 });
|
||||||
|
});
|
||||||
|
poly.on('click', () => {
|
||||||
|
goto(resolve('/hikes/[slug]', { slug: hike.slug }));
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [lat, lng] of latLngs) {
|
||||||
|
bounds.extend([lat, lng]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let initialBounds: ReturnType<typeof L.latLngBounds> | null = null;
|
||||||
|
if (bounds.isValid()) {
|
||||||
|
map.fitBounds(bounds, { padding: [32, 32], maxZoom: 13 });
|
||||||
|
initialBounds = bounds;
|
||||||
|
recenterMap = () => {
|
||||||
|
if (!initialBounds) return;
|
||||||
|
map.flyToBounds(initialBounds, {
|
||||||
|
padding: [32, 32],
|
||||||
|
maxZoom: 13,
|
||||||
|
duration: 0.6,
|
||||||
|
easeLinearity: 0.25
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// User location (opt-in). Same Tauri-first / Web-Geolocation-fallback
|
||||||
|
// pattern as HikeMap so the toggle behaves identically across the app.
|
||||||
|
let userMarker: ReturnType<typeof L.circleMarker> | null = null;
|
||||||
|
let userAccuracyCircle: ReturnType<typeof L.circle> | null = null;
|
||||||
|
let userCleanup: (() => void) | undefined;
|
||||||
|
|
||||||
|
async function attachUserLocation() {
|
||||||
|
if (!enableUserLocation) return;
|
||||||
|
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
|
||||||
|
const handlePos = (lat: number, lng: number, accuracy: number) => {
|
||||||
|
if (!userMarker) {
|
||||||
|
userMarker = L.circleMarker([lat, lng], {
|
||||||
|
radius: 7,
|
||||||
|
fillColor: '#5e81ac',
|
||||||
|
fillOpacity: 1,
|
||||||
|
color: '#fff',
|
||||||
|
weight: 2
|
||||||
|
}).addTo(map);
|
||||||
|
userAccuracyCircle = L.circle([lat, lng], {
|
||||||
|
radius: accuracy,
|
||||||
|
color: '#5e81ac',
|
||||||
|
fillColor: '#5e81ac',
|
||||||
|
fillOpacity: 0.1,
|
||||||
|
weight: 1
|
||||||
|
}).addTo(map);
|
||||||
|
} else {
|
||||||
|
userMarker.setLatLng([lat, lng]);
|
||||||
|
userAccuracyCircle?.setLatLng([lat, lng]);
|
||||||
|
userAccuracyCircle?.setRadius(accuracy);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (isTauri) {
|
||||||
|
try {
|
||||||
|
const geo = await import('@tauri-apps/plugin-geolocation');
|
||||||
|
const watchId = await geo.watchPosition(
|
||||||
|
{ enableHighAccuracy: true, maximumAge: 5000, timeout: 10_000 },
|
||||||
|
(pos) => {
|
||||||
|
if (pos?.coords)
|
||||||
|
handlePos(pos.coords.latitude, pos.coords.longitude, pos.coords.accuracy ?? 30);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
userCleanup = () => geo.clearWatch(watchId).catch(() => {});
|
||||||
|
} catch {
|
||||||
|
/* Tauri plugin unavailable — fall through to web API */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!userCleanup && 'geolocation' in navigator) {
|
||||||
|
const id = navigator.geolocation.watchPosition(
|
||||||
|
(pos) => handlePos(pos.coords.latitude, pos.coords.longitude, pos.coords.accuracy),
|
||||||
|
() => {},
|
||||||
|
{ enableHighAccuracy: true, maximumAge: 5000, timeout: 10_000 }
|
||||||
|
);
|
||||||
|
userCleanup = () => navigator.geolocation.clearWatch(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
attachUserLocation();
|
||||||
|
|
||||||
|
// React to control toggles outside the attachment.
|
||||||
|
const stopReactRoot = $effect.root(() => {
|
||||||
|
$effect(() => {
|
||||||
|
if (baseLayer === currentBase) return;
|
||||||
|
tileLayers[currentBase].remove();
|
||||||
|
tileLayers[baseLayer].addTo(map);
|
||||||
|
const newMax = LAYER_DEFS[baseLayer].maxZoom;
|
||||||
|
map.setMaxZoom(newMax);
|
||||||
|
if (map.getZoom() > newMax) map.setZoom(newMax);
|
||||||
|
currentBase = baseLayer;
|
||||||
|
});
|
||||||
|
$effect(() => {
|
||||||
|
if (!enableUserLocation && userCleanup) {
|
||||||
|
userCleanup();
|
||||||
|
userCleanup = undefined;
|
||||||
|
if (userMarker) userMarker.remove();
|
||||||
|
if (userAccuracyCircle) userAccuracyCircle.remove();
|
||||||
|
userMarker = null;
|
||||||
|
userAccuracyCircle = null;
|
||||||
|
} else if (enableUserLocation && !userCleanup) {
|
||||||
|
attachUserLocation();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
cleanup = () => {
|
||||||
|
userCleanup?.();
|
||||||
|
stopReactRoot();
|
||||||
|
recenterMap = null;
|
||||||
|
map.remove();
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
cleanup?.();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
|
<link rel="preconnect" href="https://wmts.geo.admin.ch" crossorigin="anonymous" />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="map-wrap">
|
||||||
|
<div class="overview-map" {@attach mapAttachment} aria-label="Übersichtskarte aller Wanderungen"></div>
|
||||||
|
|
||||||
|
<div class="map-controls">
|
||||||
|
<div class="layer-menu" class:open={layerMenuOpen}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="round-btn"
|
||||||
|
aria-label="Kartenebene wählen"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={layerMenuOpen}
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
layerMenuOpen = !layerMenuOpen;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Layers size={20} strokeWidth={2} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
{#if layerMenuOpen}
|
||||||
|
<div class="layer-popover" role="menu">
|
||||||
|
{#each Object.entries(LAYER_DEFS) as [key, def] (key)}
|
||||||
|
{@const Icon = def.icon}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitemradio"
|
||||||
|
aria-checked={baseLayer === key}
|
||||||
|
class:active={baseLayer === key}
|
||||||
|
onclick={() => {
|
||||||
|
baseLayer = key as BaseLayer;
|
||||||
|
layerMenuOpen = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size={14} strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
{def.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if recenterMap}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="round-btn"
|
||||||
|
aria-label="Auf alle Touren zurückzentrieren"
|
||||||
|
title="Karte auf alle Touren zurückzentrieren"
|
||||||
|
onclick={() => recenterMap?.()}
|
||||||
|
>
|
||||||
|
<Maximize2 size={18} strokeWidth={2} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="round-btn"
|
||||||
|
class:active={enableUserLocation}
|
||||||
|
aria-pressed={enableUserLocation}
|
||||||
|
title={enableUserLocation
|
||||||
|
? 'Eigenen Standort verbergen'
|
||||||
|
: 'Eigenen Standort anzeigen — wird lokal berechnet, nicht an Dritte gesendet'}
|
||||||
|
aria-label={enableUserLocation ? 'Eigenen Standort verbergen' : 'Eigenen Standort anzeigen'}
|
||||||
|
onclick={toggleLocation}
|
||||||
|
>
|
||||||
|
{#if enableUserLocation}
|
||||||
|
<Locate size={20} strokeWidth={2} aria-hidden="true" />
|
||||||
|
{:else}
|
||||||
|
<LocateOff size={20} strokeWidth={2} aria-hidden="true" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if locationError}
|
||||||
|
<p class="gps-error" role="status">{locationError}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.map-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-map {
|
||||||
|
width: 100%;
|
||||||
|
height: clamp(320px, 50vh, 520px);
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tooltip lives at body level, so it has to be global. */
|
||||||
|
:global(.hike-overview-tooltip) {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.hike-overview-tooltip strong) {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.1rem;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.leaflet-interactive) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bottom-right stack of round controls. Mirrors HikeMap.svelte exactly so
|
||||||
|
* users get the same controls and visual language as the detail page. */
|
||||||
|
.map-controls {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
z-index: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.round-btn {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
color var(--transition-fast),
|
||||||
|
background var(--transition-fast),
|
||||||
|
transform var(--transition-fast),
|
||||||
|
box-shadow var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.round-btn:hover {
|
||||||
|
color: var(--color-primary);
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: var(--shadow-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.round-btn.active {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-text-on-primary);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.round-btn.active:hover {
|
||||||
|
color: var(--color-text-on-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-menu {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-popover {
|
||||||
|
position: absolute;
|
||||||
|
right: calc(100% + 0.5rem);
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
padding: 0.3rem;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
min-width: 9.5rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-popover button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 0.45rem 0.7rem;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background var(--transition-fast), color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-popover button :global(svg) {
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-popover button:hover {
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-popover button.active {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-text-on-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-popover button.active :global(svg) {
|
||||||
|
color: var(--color-text-on-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* GPS-permission error toast. Three 44 px buttons + two 0.5 rem gaps =
|
||||||
|
* ~148 px stack plus 1 rem inset; anchor the toast above that. */
|
||||||
|
.gps-error {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 11rem;
|
||||||
|
right: 1rem;
|
||||||
|
max-width: 18rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--red);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
z-index: 500;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Toggle from '$lib/components/Toggle.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { enabled = $bindable(false) }: Props = $props();
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'hikes:gpsEnabled';
|
||||||
|
|
||||||
|
let permissionError = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Initialise from localStorage on mount (browser only).
|
||||||
|
$effect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const saved = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (saved === '1') enabled = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, enabled ? '1' : '0');
|
||||||
|
});
|
||||||
|
|
||||||
|
function onChange() {
|
||||||
|
if (!enabled) {
|
||||||
|
permissionError = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Light pre-flight: confirm the API exists. The actual permission grant
|
||||||
|
// happens lazily inside HikeMap so users see the marker appear immediately
|
||||||
|
// once they accept.
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const hasTauri = '__TAURI_INTERNALS__' in window;
|
||||||
|
const hasWebGeo = 'geolocation' in navigator;
|
||||||
|
if (!hasTauri && !hasWebGeo) {
|
||||||
|
enabled = false;
|
||||||
|
permissionError = 'Geolocation steht in diesem Browser nicht zur Verfügung.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="user-loc">
|
||||||
|
<Toggle bind:checked={enabled} label="Eigenen Standort auf der Karte anzeigen" onchange={onChange} />
|
||||||
|
<p class="hint">
|
||||||
|
Dein Standort wird auf deinem Gerät berechnet und nicht an Dritte gesendet.
|
||||||
|
</p>
|
||||||
|
{#if permissionError}
|
||||||
|
<p class="err">{permissionError}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.user-loc {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin: 0.4rem 0 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.err {
|
||||||
|
margin: 0.4rem 0 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Shared focus state for a hike detail page's photo strip + map.
|
||||||
|
*
|
||||||
|
* Writing to `focused.index` from the strip (source='strip') makes the map fly
|
||||||
|
* to that photo and pulse a focus ring; writing from the map (source='map')
|
||||||
|
* makes the strip scroll the matching card into view and highlight it. Each
|
||||||
|
* side ignores its own writes via the `source` field so the two never feed
|
||||||
|
* back into each other.
|
||||||
|
*
|
||||||
|
* Indexes are positions in the visibility-filtered `ImagePoint[]` that both
|
||||||
|
* components share — the page filters once and hands the same array down.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sources of focus-store writes:
|
||||||
|
* - `'strip'`: the user clicked a thumbnail or used a chevron / arrow key.
|
||||||
|
* Full sync: map flies to the marker, strip centres the card.
|
||||||
|
* - `'map'`: the user clicked a map marker. Strip scrolls + highlights,
|
||||||
|
* but the map doesn't fly to itself.
|
||||||
|
* - `'map-hover'`: the user is hovering a map marker. Strip skips scroll
|
||||||
|
* (would jerk across dense clusters), and the map skips its own flyTo +
|
||||||
|
* focus ring (the user is already looking at it).
|
||||||
|
* - `'inline'`: an inline `<HikeImage>` scrolled into the viewport's middle
|
||||||
|
* band. Full sync: map flies to the marker, strip centres the card. This
|
||||||
|
* is the desktop scrollytelling driver.
|
||||||
|
*/
|
||||||
|
export type FocusSource = 'map' | 'map-hover' | 'strip' | 'inline' | null;
|
||||||
|
|
||||||
|
export const focused = $state<{ index: number | null; source: FocusSource }>({
|
||||||
|
index: null,
|
||||||
|
source: null
|
||||||
|
});
|
||||||
|
|
||||||
|
export function setFocused(index: number | null, source: FocusSource): void {
|
||||||
|
focused.index = index;
|
||||||
|
focused.source = source;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearFocused(): void {
|
||||||
|
focused.index = null;
|
||||||
|
focused.source = null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Provides the hike detail page's ImagePoints arrays to descendants —
|
||||||
|
* specifically, to inline `<HikeImage>` components used inside `.svx`
|
||||||
|
* content. The page sets the context; HikeImage reads it.
|
||||||
|
*
|
||||||
|
* Two arrays are exposed because they serve different needs:
|
||||||
|
*
|
||||||
|
* - `images` is the full chronological list (including private images).
|
||||||
|
* `<HikeImage idx={N} />` indexes into this list, so the author's
|
||||||
|
* indices stay stable regardless of the viewer's login state.
|
||||||
|
*
|
||||||
|
* - `visibleImages` is the same list with private entries filtered out
|
||||||
|
* for the current viewer. The strip, map, and stage all operate against
|
||||||
|
* it, and the focus store's `index` field is a position in this array.
|
||||||
|
* `HikeImage` translates its own idx → position-in-visibleImages so the
|
||||||
|
* focus sync works.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getContext, setContext } from 'svelte';
|
||||||
|
import type { HikeTrackPoint, ImagePoint } from '$types/hikes';
|
||||||
|
|
||||||
|
const KEY = Symbol('hike-context');
|
||||||
|
|
||||||
|
interface HikeContext {
|
||||||
|
readonly images: ImagePoint[];
|
||||||
|
readonly visibleImages: ImagePoint[];
|
||||||
|
/** GPX track points — null until the JSON fetch resolves. Used by
|
||||||
|
* inline `<HikeImage>` to compute the nearest-track-index for the
|
||||||
|
* scroll-progress pin on the map. */
|
||||||
|
readonly track: HikeTrackPoint[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setHikeContext(ctx: () => HikeContext): void {
|
||||||
|
setContext(KEY, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHikeContext(): () => HikeContext {
|
||||||
|
const ctx = getContext<() => HikeContext>(KEY);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('HikeImage used outside a hike detail page (no context found).');
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Shared cursor state for a hike detail page.
|
||||||
|
*
|
||||||
|
* The map and the elevation chart each push into `hover.index` when the
|
||||||
|
* pointer moves over them; both observe the rune via `$effect` to draw the
|
||||||
|
* corresponding marker on their own side. A single shared rune avoids the
|
||||||
|
* map↔chart hover-loop bookkeeping that prop wiring would require.
|
||||||
|
*
|
||||||
|
* `source` records which side wrote the last update so the receiver can skip
|
||||||
|
* redrawing on its own write and prevent feedback loops.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type HoverSource = 'map' | 'chart' | 'image' | 'scroll' | null;
|
||||||
|
|
||||||
|
export const hover = $state<{ index: number | null; source: HoverSource }>({
|
||||||
|
index: null,
|
||||||
|
source: null
|
||||||
|
});
|
||||||
|
|
||||||
|
export function setHover(index: number | null, source: HoverSource): void {
|
||||||
|
hover.index = index;
|
||||||
|
hover.source = source;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearHover(): void {
|
||||||
|
hover.index = null;
|
||||||
|
hover.source = null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Attachment } from 'svelte/attachments';
|
||||||
|
import {
|
||||||
|
builder,
|
||||||
|
nextWaypointId,
|
||||||
|
scheduleSave
|
||||||
|
} from './builderStore.svelte';
|
||||||
|
// Single-point Swisstopo elevation lookups are intentionally NOT used —
|
||||||
|
// they returned 0 against WGS-84 inputs in practice, and image waypoints
|
||||||
|
// don't need per-point altitudes anyway. Waypoint altitudes flow from
|
||||||
|
// the routed-segment elevations that snap-to-route populates on the
|
||||||
|
// route polyline; `assembleTrackPoints` falls back to those when the
|
||||||
|
// waypoint itself has no `altitude`.
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** When set, the next map click writes the clicked lat/lng into the
|
||||||
|
* matching unplaced waypoint (instead of creating a new one). */
|
||||||
|
pendingPlacementId?: string | null;
|
||||||
|
onPlacementComplete?: () => void;
|
||||||
|
onPlacementCancel?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pendingPlacementId = null, onPlacementComplete, onPlacementCancel }: Props = $props();
|
||||||
|
|
||||||
|
const pendingWaypoint = $derived(
|
||||||
|
pendingPlacementId ? builder.waypoints.find((w) => w.id === pendingPlacementId) ?? null : null
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
const SWISSTOPO_FARBE =
|
||||||
|
'https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.pixelkarte-farbe/default/current/3857/{z}/{x}/{y}.jpeg';
|
||||||
|
const SWISSTOPO_ATTRIBUTION =
|
||||||
|
'© <a href="https://www.swisstopo.admin.ch/" target="_blank" rel="noopener">swisstopo</a>';
|
||||||
|
// Default view: Switzerland-wide.
|
||||||
|
const DEFAULT_CENTER: [number, number] = [46.8, 8.3];
|
||||||
|
const DEFAULT_ZOOM = 8;
|
||||||
|
|
||||||
|
const editAttachment: Attachment<HTMLElement> = (node) => {
|
||||||
|
let cancelled = false;
|
||||||
|
let cleanup: (() => void) | undefined;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const L = await import('leaflet');
|
||||||
|
if (cancelled || !node.isConnected) return;
|
||||||
|
|
||||||
|
const map = L.map(node, {
|
||||||
|
attributionControl: true,
|
||||||
|
zoomControl: true,
|
||||||
|
preferCanvas: false
|
||||||
|
}).setView(DEFAULT_CENTER, DEFAULT_ZOOM);
|
||||||
|
|
||||||
|
L.tileLayer(SWISSTOPO_FARBE, {
|
||||||
|
maxZoom: 19,
|
||||||
|
minZoom: 7,
|
||||||
|
attribution: SWISSTOPO_ATTRIBUTION,
|
||||||
|
updateWhenZooming: false
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
const markerLayer = L.layerGroup().addTo(map);
|
||||||
|
const lineLayer = L.layerGroup().addTo(map);
|
||||||
|
|
||||||
|
function makeNumberedIcon(num: number, thumbnail?: string) {
|
||||||
|
if (thumbnail) {
|
||||||
|
return L.divIcon({
|
||||||
|
className: 'rb-waypoint with-thumb',
|
||||||
|
html: `<span class="thumb"><img src="${thumbnail}" alt="" /></span><span class="num">${num}</span>`,
|
||||||
|
iconSize: [56, 56],
|
||||||
|
iconAnchor: [28, 28]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return L.divIcon({
|
||||||
|
className: 'rb-waypoint',
|
||||||
|
html: `<span class="num solo">${num}</span>`,
|
||||||
|
iconSize: [28, 28],
|
||||||
|
iconAnchor: [14, 28]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertWaypointAfterFullIdx(fullAfterIdx: number, lat: number, lng: number) {
|
||||||
|
const id = nextWaypointId();
|
||||||
|
const fixedLat = Number(lat.toFixed(6));
|
||||||
|
const fixedLng = Number(lng.toFixed(6));
|
||||||
|
builder.waypoints.splice(fullAfterIdx + 1, 0, {
|
||||||
|
id,
|
||||||
|
lat: fixedLat,
|
||||||
|
lng: fixedLng,
|
||||||
|
timestamp: null
|
||||||
|
});
|
||||||
|
scheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachSegmentClick(
|
||||||
|
poly: ReturnType<typeof L.polyline>,
|
||||||
|
fullAfterIdx: number
|
||||||
|
) {
|
||||||
|
poly.on('click', (ev: { latlng: { lat: number; lng: number }; originalEvent?: MouseEvent } & object) => {
|
||||||
|
L.DomEvent.stopPropagation(ev as Parameters<typeof L.DomEvent.stopPropagation>[0]);
|
||||||
|
insertWaypointAfterFullIdx(fullAfterIdx, ev.latlng.lat, ev.latlng.lng);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
markerLayer.clearLayers();
|
||||||
|
lineLayer.clearLayers();
|
||||||
|
|
||||||
|
// Markers per waypoint. Skip unplaced ones — they don't have a
|
||||||
|
// usable lat/lng and live only in the waypoint table.
|
||||||
|
const placedIndices: number[] = [];
|
||||||
|
builder.waypoints.forEach((w, idx) => {
|
||||||
|
if (w.unplaced) return;
|
||||||
|
placedIndices.push(idx);
|
||||||
|
});
|
||||||
|
placedIndices.forEach((idx, displayPos) => {
|
||||||
|
const w = builder.waypoints[idx];
|
||||||
|
const marker = L.marker([w.lat, w.lng], {
|
||||||
|
icon: makeNumberedIcon(displayPos + 1, w.thumbnail),
|
||||||
|
draggable: true
|
||||||
|
}).addTo(markerLayer);
|
||||||
|
marker.on('dragend', () => {
|
||||||
|
const p = marker.getLatLng();
|
||||||
|
const wp = builder.waypoints[idx];
|
||||||
|
wp.lat = Number(p.lat.toFixed(6));
|
||||||
|
wp.lng = Number(p.lng.toFixed(6));
|
||||||
|
wp.altitude = undefined;
|
||||||
|
scheduleSave();
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
marker.on('contextmenu', () => {
|
||||||
|
builder.waypoints.splice(idx, 1);
|
||||||
|
scheduleSave();
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lines: per-pair so each can carry a segIdx for inline insertion.
|
||||||
|
// Snapped + linear segments share the same visual styling — there's
|
||||||
|
// no need to call out the difference, the user picked the mode.
|
||||||
|
const primary =
|
||||||
|
getComputedStyle(document.documentElement).getPropertyValue('--color-primary').trim() ||
|
||||||
|
'#5e81ac';
|
||||||
|
if (builder.routedSegments.length > 0) {
|
||||||
|
builder.routedSegments.forEach((seg, segIdx) => {
|
||||||
|
const latLngs = seg.map((p) => [p[1], p[0]] as [number, number]);
|
||||||
|
const poly = L.polyline(latLngs, {
|
||||||
|
color: primary,
|
||||||
|
weight: 4,
|
||||||
|
opacity: 0.9
|
||||||
|
}).addTo(lineLayer);
|
||||||
|
// Routed segments index aligns with placed-only pairs; map back
|
||||||
|
// to the full waypoint-array index so inline insertion still
|
||||||
|
// places the new waypoint correctly relative to unplaced ones.
|
||||||
|
const fullAfterIdx = placedIndices[segIdx];
|
||||||
|
attachSegmentClick(poly, fullAfterIdx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// React to store changes.
|
||||||
|
const stopRoot = $effect.root(() => {
|
||||||
|
$effect(() => {
|
||||||
|
// Touch each reactive field so we re-render on any mutation.
|
||||||
|
builder.waypoints.length;
|
||||||
|
for (const w of builder.waypoints) {
|
||||||
|
w.lat; w.lng; w.thumbnail;
|
||||||
|
}
|
||||||
|
builder.routedSegments.length;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click on blank map. In normal mode, append a new waypoint at the end.
|
||||||
|
// When a placement is pending (the user clicked "Auf Karte platzieren"
|
||||||
|
// on an unplaced image in the waypoint table), instead drop the
|
||||||
|
// clicked lat/lng into that existing waypoint — preserving its
|
||||||
|
// chronological position in the table.
|
||||||
|
map.on('click', async (e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => {
|
||||||
|
if (e.originalEvent.shiftKey) return;
|
||||||
|
const lat = Number(e.latlng.lat.toFixed(6));
|
||||||
|
const lng = Number(e.latlng.lng.toFixed(6));
|
||||||
|
|
||||||
|
if (pendingPlacementId) {
|
||||||
|
const wp = builder.waypoints.find((w) => w.id === pendingPlacementId);
|
||||||
|
if (!wp) return;
|
||||||
|
wp.lat = lat;
|
||||||
|
wp.lng = lng;
|
||||||
|
wp.unplaced = false;
|
||||||
|
scheduleSave();
|
||||||
|
onPlacementComplete?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = nextWaypointId();
|
||||||
|
builder.waypoints.push({ id, lat, lng, timestamp: null });
|
||||||
|
scheduleSave();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-fit once when waypoints first exist.
|
||||||
|
if (builder.waypoints.length >= 2) {
|
||||||
|
const bounds = L.latLngBounds(builder.waypoints.map((w) => [w.lat, w.lng]));
|
||||||
|
map.fitBounds(bounds, { padding: [40, 40] });
|
||||||
|
} else if (builder.waypoints.length === 1) {
|
||||||
|
const w = builder.waypoints[0];
|
||||||
|
map.setView([w.lat, w.lng], 13);
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup = () => {
|
||||||
|
stopRoot();
|
||||||
|
map.remove();
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
cleanup?.();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="edit-map-wrap" class:placement-mode={!!pendingWaypoint}>
|
||||||
|
<div class="edit-map" {@attach editAttachment}></div>
|
||||||
|
{#if pendingWaypoint}
|
||||||
|
<div class="placement-banner" role="status">
|
||||||
|
<span>Klicke auf die Karte, um <strong>das Bild</strong> zu platzieren.</span>
|
||||||
|
<button type="button" onclick={() => onPlacementCancel?.()}>Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.edit-map-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-map {
|
||||||
|
width: 100%;
|
||||||
|
height: 600px;
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.edit-map {
|
||||||
|
height: 480px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-map-wrap.placement-mode :global(.leaflet-container) {
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placement-banner {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.75rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 0.9rem;
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
z-index: 500;
|
||||||
|
max-width: calc(100% - 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.placement-banner button {
|
||||||
|
appearance: none;
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DON'T override `position` here — Leaflet sets `.leaflet-marker-icon` to
|
||||||
|
* `position: absolute` for placement, and the inner `.num` badge relies on
|
||||||
|
* that same ancestor as its abs-positioning context. Reassigning to
|
||||||
|
* `position: relative` causes markers to fall into normal flow and stack
|
||||||
|
* vertically instead of sitting at their lat/lng. */
|
||||||
|
:global(.rb-waypoint) {
|
||||||
|
background: transparent !important;
|
||||||
|
border: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.rb-waypoint .num) {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 0.4em;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-text-on-primary);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
border: 2px solid var(--color-surface);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.rb-waypoint .num.solo) {
|
||||||
|
min-width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.rb-waypoint.with-thumb .num) {
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
right: -6px;
|
||||||
|
min-width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.rb-waypoint .thumb) {
|
||||||
|
display: block;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 2px solid var(--color-surface);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.rb-waypoint .thumb img) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
builder,
|
||||||
|
insertWaypointChronologically,
|
||||||
|
nextWaypointId,
|
||||||
|
scheduleSave,
|
||||||
|
type Waypoint
|
||||||
|
} from './builderStore.svelte';
|
||||||
|
import { generateImageHashClient } from '$lib/imageHashClient';
|
||||||
|
import { readThumbnail } from './imageThumbnail';
|
||||||
|
import { setFullImage } from './fullImageCache.svelte';
|
||||||
|
|
||||||
|
type Status = 'pending' | 'placed' | 'unplaced' | 'error';
|
||||||
|
|
||||||
|
type Entry = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: Status;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let entries = $state<Entry[]>([]);
|
||||||
|
let isDragging = $state(false);
|
||||||
|
|
||||||
|
type Prepared =
|
||||||
|
| { ok: true; wp: Waypoint; hasGps: boolean; id: string; file: File }
|
||||||
|
| { ok: false };
|
||||||
|
|
||||||
|
async function handleFiles(files: File[]) {
|
||||||
|
const exifr = (await import('exifr')).default;
|
||||||
|
|
||||||
|
// Prep every file in parallel (EXIF + hash + thumbnail). The result
|
||||||
|
// is staged in `prepared` rather than pushed into `builder.waypoints`
|
||||||
|
// one at a time — that way the snap-to-route effect (which fires on
|
||||||
|
// every waypoint insertion) sees a single synchronous batch insertion
|
||||||
|
// at the end instead of N consecutive ones. The Brouter / Swisstopo
|
||||||
|
// routing API only gets hit once per bulk upload.
|
||||||
|
const prepared = await Promise.all(
|
||||||
|
files.map(async (file): Promise<Prepared> => {
|
||||||
|
const id = nextWaypointId();
|
||||||
|
const entryIdx = entries.length;
|
||||||
|
entries.push({ id, name: file.name, status: 'pending' });
|
||||||
|
try {
|
||||||
|
const exif = await exifr
|
||||||
|
.parse(file, { gps: true, exif: true })
|
||||||
|
.catch(() => null);
|
||||||
|
let thumbnail: string | undefined;
|
||||||
|
try {
|
||||||
|
thumbnail = await readThumbnail(file);
|
||||||
|
} catch { /* preview is optional */ }
|
||||||
|
const imageHash = await generateImageHashClient(file);
|
||||||
|
const timestamp =
|
||||||
|
exif?.DateTimeOriginal instanceof Date ? exif.DateTimeOriginal.getTime() : null;
|
||||||
|
|
||||||
|
const hasGps =
|
||||||
|
exif &&
|
||||||
|
typeof exif.latitude === 'number' &&
|
||||||
|
typeof exif.longitude === 'number';
|
||||||
|
|
||||||
|
// Note: we deliberately ignore `exif.GPSAltitude` even when
|
||||||
|
// present. Phone GPS altitude has metre-scale noise; we backfill
|
||||||
|
// the terrain-model altitude from Swisstopo after insertion.
|
||||||
|
const wp: Waypoint = hasGps
|
||||||
|
? {
|
||||||
|
id,
|
||||||
|
lat: exif.latitude,
|
||||||
|
lng: exif.longitude,
|
||||||
|
timestamp,
|
||||||
|
thumbnail,
|
||||||
|
imageHash,
|
||||||
|
imageVisibility: 'public'
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
id,
|
||||||
|
lat: 0,
|
||||||
|
lng: 0,
|
||||||
|
timestamp,
|
||||||
|
thumbnail,
|
||||||
|
imageHash,
|
||||||
|
imageVisibility: 'public',
|
||||||
|
unplaced: true
|
||||||
|
};
|
||||||
|
|
||||||
|
entries[entryIdx].status = hasGps ? 'placed' : 'unplaced';
|
||||||
|
return { ok: true, wp, hasGps, id, file };
|
||||||
|
} catch (err) {
|
||||||
|
entries[entryIdx].status = 'error';
|
||||||
|
entries[entryIdx].message = (err as Error).message;
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// One synchronous batch of waypoint insertions → one snap-to-route
|
||||||
|
// debounce cycle for the whole upload. No per-image altitude fetch:
|
||||||
|
// image waypoints inherit the elevation of the routed segment they
|
||||||
|
// sit on once the route is snapped — Swisstopo's profile.json (used
|
||||||
|
// by snap-to-route enrichment) is the only reliable elevation
|
||||||
|
// source against WGS-84 inputs, and its single-point variant kept
|
||||||
|
// returning 0 even with workaround attempts.
|
||||||
|
for (const p of prepared) {
|
||||||
|
if (!p.ok) continue;
|
||||||
|
insertWaypointChronologically(p.wp);
|
||||||
|
// Cache the original file so the waypoint table can show a
|
||||||
|
// full-resolution preview this session. Persistence to
|
||||||
|
// localStorage keeps only the small thumbnail.
|
||||||
|
setFullImage(p.id, p.file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
isDragging = false;
|
||||||
|
const files = [...(e.dataTransfer?.files ?? [])].filter((f) => f.type.startsWith('image/'));
|
||||||
|
if (files.length > 0) handleFiles(files);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileInput(e: Event) {
|
||||||
|
const input = e.currentTarget as HTMLInputElement;
|
||||||
|
const files = [...(input.files ?? [])];
|
||||||
|
if (files.length > 0) handleFiles(files);
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismiss(entryId: string) {
|
||||||
|
const idx = entries.findIndex((e) => e.id === entryId);
|
||||||
|
if (idx >= 0) entries.splice(idx, 1);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section
|
||||||
|
class="dropzone"
|
||||||
|
class:active={isDragging}
|
||||||
|
aria-label="Bild-Drop"
|
||||||
|
ondragenter={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
isDragging = true;
|
||||||
|
}}
|
||||||
|
ondragover={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
}}
|
||||||
|
ondragleave={() => {
|
||||||
|
isDragging = false;
|
||||||
|
}}
|
||||||
|
ondrop={onDrop}
|
||||||
|
>
|
||||||
|
<header>
|
||||||
|
<h2>Bilder</h2>
|
||||||
|
<p class="hint">
|
||||||
|
Bilder mit GPS-EXIF werden chronologisch platziert. Bilder ohne GPS
|
||||||
|
erscheinen in der Wegpunkt-Liste und können dort auf der Karte platziert
|
||||||
|
werden. Die Bilder verlassen dein Gerät nicht.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<label class="file-input">
|
||||||
|
<input type="file" accept="image/*" multiple onchange={onFileInput} />
|
||||||
|
<span>Bilder auswählen oder hierher ziehen</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{#if entries.length > 0}
|
||||||
|
<ul class="list">
|
||||||
|
{#each entries as e (e.id)}
|
||||||
|
<li class="entry status-{e.status}">
|
||||||
|
<span class="dot"></span>
|
||||||
|
<span class="name">{e.name}</span>
|
||||||
|
<span class="msg">
|
||||||
|
{#if e.status === 'pending'}wird gelesen…
|
||||||
|
{:else if e.status === 'placed'}✓ chronologisch platziert
|
||||||
|
{:else if e.status === 'unplaced'}⚠ Position fehlt — in Liste platzieren
|
||||||
|
{:else if e.status === 'error'}Fehler: {e.message ?? 'unbekannt'}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<button type="button" class="dismiss" aria-label="Schließen" onclick={() => dismiss(e.id)}>×</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.dropzone {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 2px dashed var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
transition: border-color var(--transition-fast), background-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropzone.active {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin: 0.25rem 0 0.75rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.65rem 1rem;
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input:hover {
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0.75rem 0 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto auto;
|
||||||
|
gap: 0.6rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 0.55rem;
|
||||||
|
height: 0.55rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-text-tertiary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-placed .dot { background: var(--green); }
|
||||||
|
.status-unplaced .dot { background: var(--orange); }
|
||||||
|
.status-error .dot { background: var(--red); }
|
||||||
|
.status-pending .dot {
|
||||||
|
background: var(--color-primary);
|
||||||
|
animation: pulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 0.4; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg {
|
||||||
|
text-align: right;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-error .msg { color: var(--red); }
|
||||||
|
.status-unplaced .msg { color: var(--orange); }
|
||||||
|
.status-placed .msg { color: var(--green); }
|
||||||
|
|
||||||
|
.dismiss {
|
||||||
|
appearance: none;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0 0.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dismiss:hover {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,633 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { flip } from 'svelte/animate';
|
||||||
|
import { builder, scheduleSave } from './builderStore.svelte';
|
||||||
|
import { generateImageHashClient } from '$lib/imageHashClient';
|
||||||
|
import { readThumbnail } from './imageThumbnail';
|
||||||
|
import { dropFullImage, getFullImageUrl, setFullImage } from './fullImageCache.svelte';
|
||||||
|
import DateTimePicker from '$lib/components/DateTimePicker.svelte';
|
||||||
|
import ImagePlus from '@lucide/svelte/icons/image-plus';
|
||||||
|
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
|
||||||
|
|
||||||
|
const NUDGE_MINUTES = [-10, -5, 5, 10];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** The id of the waypoint currently in "place me on the map" mode.
|
||||||
|
* Used to highlight the active row. */
|
||||||
|
pendingPlacementId?: string | null;
|
||||||
|
onRequestPlacement?: (waypointId: string) => void;
|
||||||
|
onCancelPlacement?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pendingPlacementId = null, onRequestPlacement, onCancelPlacement }: Props = $props();
|
||||||
|
|
||||||
|
// Index of the first / last *placed* waypoint — those are the ones that
|
||||||
|
// need a timestamp for the GPX export's interpolation.
|
||||||
|
const firstPlacedIdx = $derived(
|
||||||
|
builder.waypoints.findIndex((w) => !w.unplaced)
|
||||||
|
);
|
||||||
|
const lastPlacedIdx = $derived.by(() => {
|
||||||
|
for (let i = builder.waypoints.length - 1; i >= 0; i--) {
|
||||||
|
if (!builder.waypoints[i].unplaced) return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Find the nearest waypoint *by index* that already carries a timestamp.
|
||||||
|
* Used as the `inheritedValue` for click waypoints — searching by sequence
|
||||||
|
* position (rather than geography) mirrors how authors typically insert
|
||||||
|
* waypoints (between existing ones, in trail order). */
|
||||||
|
function nearestTimestamp(idx: number): number | undefined {
|
||||||
|
const wps = builder.waypoints;
|
||||||
|
for (let dist = 1; dist < wps.length; dist++) {
|
||||||
|
const a = wps[idx - dist];
|
||||||
|
if (a && typeof a.timestamp === 'number') return a.timestamp;
|
||||||
|
const b = wps[idx + dist];
|
||||||
|
if (b && typeof b.timestamp === 'number') return b.timestamp;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DateTimePicker mutates `builder.waypoints[i].timestamp` through $bindable
|
||||||
|
// — there's no per-change callback, so persist via reactivity instead.
|
||||||
|
// Reads every timestamp inside a tracked $effect; any write triggers the
|
||||||
|
// re-run and a debounced localStorage save.
|
||||||
|
$effect(() => {
|
||||||
|
for (const wp of builder.waypoints) {
|
||||||
|
void wp.timestamp;
|
||||||
|
}
|
||||||
|
scheduleSave();
|
||||||
|
});
|
||||||
|
|
||||||
|
function move(idx: number, delta: number) {
|
||||||
|
const next = idx + delta;
|
||||||
|
if (next < 0 || next >= builder.waypoints.length) return;
|
||||||
|
const [w] = builder.waypoints.splice(idx, 1);
|
||||||
|
builder.waypoints.splice(next, 0, w);
|
||||||
|
scheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(idx: number) {
|
||||||
|
const wp = builder.waypoints[idx];
|
||||||
|
dropFullImage(wp.id);
|
||||||
|
builder.waypoints.splice(idx, 1);
|
||||||
|
scheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLat(idx: number, raw: string) {
|
||||||
|
const n = parseFloat(raw);
|
||||||
|
if (!isNaN(n)) {
|
||||||
|
builder.waypoints[idx].lat = n;
|
||||||
|
scheduleSave();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLng(idx: number, raw: string) {
|
||||||
|
const n = parseFloat(raw);
|
||||||
|
if (!isNaN(n)) {
|
||||||
|
builder.waypoints[idx].lng = n;
|
||||||
|
scheduleSave();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setVisibility(idx: number, value: 'public' | 'private') {
|
||||||
|
builder.waypoints[idx].imageVisibility = value;
|
||||||
|
scheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
let attachBusy = $state<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
async function attachImage(idx: number, fileList: FileList | null) {
|
||||||
|
const file = fileList?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const wp = builder.waypoints[idx];
|
||||||
|
attachBusy[wp.id] = true;
|
||||||
|
try {
|
||||||
|
const exifr = (await import('exifr')).default;
|
||||||
|
const exif = await exifr.parse(file, { gps: true, exif: true }).catch(() => null);
|
||||||
|
const hash = await generateImageHashClient(file);
|
||||||
|
let thumbnail: string | undefined;
|
||||||
|
try {
|
||||||
|
thumbnail = await readThumbnail(file);
|
||||||
|
} catch { /* thumbnail is optional */ }
|
||||||
|
wp.imageHash = hash;
|
||||||
|
wp.thumbnail = thumbnail;
|
||||||
|
wp.imageVisibility = 'public';
|
||||||
|
setFullImage(wp.id, file);
|
||||||
|
if (wp.timestamp == null && exif?.DateTimeOriginal instanceof Date) {
|
||||||
|
wp.timestamp = exif.DateTimeOriginal.getTime();
|
||||||
|
}
|
||||||
|
// EXIF GPSAltitude is intentionally ignored — terrain-model altitude
|
||||||
|
// from Swisstopo (already set when this waypoint was placed on the
|
||||||
|
// map) is more accurate and avoids spikes in the elevation profile.
|
||||||
|
scheduleSave();
|
||||||
|
} finally {
|
||||||
|
attachBusy[wp.id] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="wp-table">
|
||||||
|
<header>
|
||||||
|
<h2>Wegpunkte ({builder.waypoints.length})</h2>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if builder.waypoints.length === 0}
|
||||||
|
<p class="empty">Klicke auf die Karte oder lade Bilder, um Wegpunkte zu setzen.</p>
|
||||||
|
{:else}
|
||||||
|
<p class="legend">* Erster und letzter platzierter Wegpunkt brauchen einen Zeitstempel.</p>
|
||||||
|
<ol>
|
||||||
|
{#each builder.waypoints as wp, idx (wp.id)}
|
||||||
|
<li
|
||||||
|
class="wp"
|
||||||
|
class:unplaced={wp.unplaced}
|
||||||
|
class:active={wp.id === pendingPlacementId}
|
||||||
|
animate:flip={{ duration: 220 }}
|
||||||
|
>
|
||||||
|
{#if wp.thumbnail || getFullImageUrl(wp.id)}
|
||||||
|
<div class="hero">
|
||||||
|
<img
|
||||||
|
src={getFullImageUrl(wp.id) ?? wp.thumbnail}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
{#if wp.unplaced}
|
||||||
|
<span class="hero-badge">📍 noch nicht platziert</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="row title-row">
|
||||||
|
<span class="idx" class:unplaced-idx={wp.unplaced}>
|
||||||
|
{wp.unplaced ? '?' : idx + 1}
|
||||||
|
</span>
|
||||||
|
<span class="title">
|
||||||
|
{#if wp.unplaced}
|
||||||
|
Bild ohne Position
|
||||||
|
{:else if wp.imageHash}
|
||||||
|
Bild {idx + 1}
|
||||||
|
{:else}
|
||||||
|
Wegpunkt {idx + 1}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<div class="row-actions">
|
||||||
|
<button type="button" onclick={() => move(idx, -1)} disabled={idx === 0} aria-label="Nach oben">↑</button>
|
||||||
|
<button type="button" onclick={() => move(idx, 1)} disabled={idx === builder.waypoints.length - 1} aria-label="Nach unten">↓</button>
|
||||||
|
<button type="button" class="del" onclick={() => remove(idx)} aria-label="Entfernen">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if wp.unplaced}
|
||||||
|
<div class="row placement-row">
|
||||||
|
{#if wp.id === pendingPlacementId}
|
||||||
|
<span class="placing">Klicke auf die Karte…</span>
|
||||||
|
<button type="button" class="ghost" onclick={() => onCancelPlacement?.()}>Abbrechen</button>
|
||||||
|
{:else}
|
||||||
|
<button type="button" class="primary" onclick={() => onRequestPlacement?.(wp.id)}>
|
||||||
|
📍 Auf Karte platzieren
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="row coords-row">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.000001"
|
||||||
|
value={wp.lat}
|
||||||
|
onchange={(e) => updateLat(idx, e.currentTarget.value)}
|
||||||
|
aria-label="Breitengrad"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.000001"
|
||||||
|
value={wp.lng}
|
||||||
|
onchange={(e) => updateLng(idx, e.currentTarget.value)}
|
||||||
|
aria-label="Längengrad"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !wp.unplaced}
|
||||||
|
{@const requiresTime = idx === firstPlacedIdx || idx === lastPlacedIdx}
|
||||||
|
{@const isImage = !!wp.imageHash}
|
||||||
|
{@const hasTimestamp = wp.timestamp != null}
|
||||||
|
{@const inheritedTs = !hasTimestamp ? nearestTimestamp(idx) ?? null : null}
|
||||||
|
{@const showTime = isImage || requiresTime || hasTimestamp}
|
||||||
|
<div class="row sub time-row">
|
||||||
|
<span class="time-cap">
|
||||||
|
{showTime ? 'Zeit' : 'Datum'}{requiresTime ? ' *' : ''}
|
||||||
|
</span>
|
||||||
|
<DateTimePicker
|
||||||
|
bind:value={builder.waypoints[idx].timestamp}
|
||||||
|
mode={showTime ? 'datetime' : 'date'}
|
||||||
|
inheritedValue={inheritedTs}
|
||||||
|
nudgeMinutes={showTime ? NUDGE_MINUTES : []}
|
||||||
|
required={requiresTime}
|
||||||
|
lang="de"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if wp.imageHash}
|
||||||
|
<div class="row image-visibility">
|
||||||
|
<span class="vis-label">Sichtbarkeit</span>
|
||||||
|
<div class="segment" role="radiogroup" aria-label="Sichtbarkeit">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class:active={wp.imageVisibility !== 'private'}
|
||||||
|
aria-pressed={wp.imageVisibility !== 'private'}
|
||||||
|
onclick={() => setVisibility(idx, 'public')}
|
||||||
|
>🌐 Öffentlich</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class:active={wp.imageVisibility === 'private'}
|
||||||
|
aria-pressed={wp.imageVisibility === 'private'}
|
||||||
|
onclick={() => setVisibility(idx, 'private')}
|
||||||
|
>🔒 Privat</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if !wp.unplaced}
|
||||||
|
<label class="image-attach" class:busy={attachBusy[wp.id]}>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
disabled={attachBusy[wp.id]}
|
||||||
|
onchange={(e) => attachImage(idx, e.currentTarget.files)}
|
||||||
|
/>
|
||||||
|
<span class="attach-cta">
|
||||||
|
{#if attachBusy[wp.id]}
|
||||||
|
<LoaderCircle size={15} strokeWidth={2} class="spin" />
|
||||||
|
<span>Bild wird gelesen…</span>
|
||||||
|
{:else}
|
||||||
|
<span class="attach-icon">
|
||||||
|
<ImagePlus size={15} strokeWidth={1.75} />
|
||||||
|
</span>
|
||||||
|
<span>Bild anhängen</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.wp-table {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 1rem;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
max-height: 600px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h2 {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
margin: 0 0 0.6rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
ol {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wp {
|
||||||
|
padding: 0;
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wp.unplaced {
|
||||||
|
border-color: var(--orange);
|
||||||
|
background: color-mix(in oklab, var(--orange) 6%, var(--color-bg-secondary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.wp.active {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in oklab, var(--color-primary) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
background: var(--color-bg-elevated);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.4rem;
|
||||||
|
left: 0.4rem;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
background: var(--orange);
|
||||||
|
color: white;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row.title-row {
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idx {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 0.4em;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-text-on-primary);
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idx.unplaced-idx {
|
||||||
|
background: var(--orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
.coords-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coords-row input {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placement-row {
|
||||||
|
gap: 0.6rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placement-row .placing {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row.sub {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-row {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
row-gap: 0.4rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-cap {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='number'] {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.25rem 0.4rem;
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-actions button {
|
||||||
|
appearance: none;
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font: inherit;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-actions button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-actions button.del {
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.placement-row .primary,
|
||||||
|
.placement-row .ghost {
|
||||||
|
appearance: none;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.3rem 0.8rem;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placement-row .primary {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-text-on-primary);
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placement-row .primary:hover {
|
||||||
|
background: var(--color-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.placement-row .ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-visibility {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vis-label {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment {
|
||||||
|
display: inline-flex;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment button {
|
||||||
|
appearance: none;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment button + button {
|
||||||
|
border-left: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.segment button.active {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-text-on-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-attach {
|
||||||
|
display: flex;
|
||||||
|
padding: 0.5rem 0.6rem 0.65rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-attach.busy {
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-attach input[type='file'] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-attach .attach-cta {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.45rem 0.95rem;
|
||||||
|
background:
|
||||||
|
linear-gradient(
|
||||||
|
135deg,
|
||||||
|
color-mix(in oklab, var(--color-primary) 8%, transparent),
|
||||||
|
transparent 70%
|
||||||
|
),
|
||||||
|
var(--color-bg-tertiary);
|
||||||
|
border: 1px dashed color-mix(in oklab, var(--color-primary) 32%, var(--color-border));
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
transition:
|
||||||
|
background var(--transition-fast),
|
||||||
|
border-color var(--transition-fast),
|
||||||
|
color var(--transition-fast),
|
||||||
|
box-shadow var(--transition-fast),
|
||||||
|
transform var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attach-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.55rem;
|
||||||
|
height: 1.55rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: color-mix(in oklab, var(--color-primary) 14%, var(--color-bg-elevated));
|
||||||
|
color: var(--color-primary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition:
|
||||||
|
background var(--transition-fast),
|
||||||
|
color var(--transition-fast),
|
||||||
|
transform var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-attach:hover:not(.busy) .attach-cta {
|
||||||
|
background: var(--color-primary);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
border-style: solid;
|
||||||
|
color: var(--color-text-on-primary);
|
||||||
|
box-shadow: 0 0 0 1px var(--color-primary),
|
||||||
|
0 0.5em 1.2em -0.5em color-mix(in oklab, var(--color-primary) 60%, transparent);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-attach:hover:not(.busy) .attach-icon {
|
||||||
|
background: color-mix(in oklab, var(--color-text-on-primary) 18%, transparent);
|
||||||
|
color: var(--color-text-on-primary);
|
||||||
|
transform: rotate(-6deg) scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-attach.busy .attach-cta {
|
||||||
|
opacity: 0.78;
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-attach :global(.spin) {
|
||||||
|
animation: attach-spin 0.85s linear infinite;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes attach-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
/**
|
||||||
|
* State for the route-builder editor.
|
||||||
|
*
|
||||||
|
* The whole store is a single $state object so any field — waypoints,
|
||||||
|
* routed segments, profile — automatically reactivates dependent UI
|
||||||
|
* (table rows, map markers, polyline). Draft state is mirrored to
|
||||||
|
* `localStorage` so accidental tab close doesn't lose work.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
|
export type RoutingProfile = 'hiking-mountain' | 'trekking' | 'road';
|
||||||
|
|
||||||
|
export type ImageVisibility = 'public' | 'private';
|
||||||
|
|
||||||
|
export type Waypoint = {
|
||||||
|
id: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
altitude?: number;
|
||||||
|
timestamp?: number | null;
|
||||||
|
thumbnail?: string; // optional base64 preview for marker badge + table row
|
||||||
|
/** First 8 hex chars of the source image's sha256 content hash. Matches
|
||||||
|
* the same scheme used by the build script's output filenames so the
|
||||||
|
* build can re-attach the image to this user-corrected position. */
|
||||||
|
imageHash?: string;
|
||||||
|
/** Whether the image should be visible to anonymous viewers. Both values
|
||||||
|
* embed the image in the GPX export — private images are simply hidden
|
||||||
|
* from the public map unless the viewer is logged in. Defaults to
|
||||||
|
* `'public'`; only meaningful when `imageHash` is set. */
|
||||||
|
imageVisibility?: ImageVisibility;
|
||||||
|
/** When true, this waypoint represents an image with a known timestamp
|
||||||
|
* but unknown location — the user still needs to drop it on the map.
|
||||||
|
* Lat/lng are placeholders (0/0) and the waypoint is hidden from the map
|
||||||
|
* and excluded from GPX export until placed. */
|
||||||
|
unplaced?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BuilderState = {
|
||||||
|
name: string;
|
||||||
|
profile: RoutingProfile;
|
||||||
|
/** When true, newly created segments are snapped to the trail network via
|
||||||
|
* the routing API. When false, new segments use a direct straight line.
|
||||||
|
* Existing (already-snapped) segments are preserved across toggle. */
|
||||||
|
autoSnap: boolean;
|
||||||
|
waypoints: Waypoint[];
|
||||||
|
/** One coordinate run per consecutive-waypoint pair (snapped or linear). */
|
||||||
|
routedSegments: Array<Array<[number, number, number?]>>; // [lng, lat, ele?]
|
||||||
|
/** Parallel record of which waypoint pair each `routedSegments[i]` was
|
||||||
|
* built for — by id AND by coordinate. Both must match for the segment to
|
||||||
|
* be considered still valid, so a drag (same id, new coords) correctly
|
||||||
|
* invalidates the adjacent segments. */
|
||||||
|
segmentSources: Array<SegmentSource>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SegmentSource = {
|
||||||
|
startId: string;
|
||||||
|
endId: string;
|
||||||
|
startLat: number;
|
||||||
|
startLng: number;
|
||||||
|
endLat: number;
|
||||||
|
endLng: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'hikes:route-builder:draft';
|
||||||
|
|
||||||
|
function loadDraft(): BuilderState {
|
||||||
|
if (!browser) return defaultState();
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return defaultState();
|
||||||
|
const parsed = JSON.parse(raw) as BuilderState;
|
||||||
|
if (!parsed || !Array.isArray(parsed.waypoints)) return defaultState();
|
||||||
|
// Migrate older drafts that used `showImageOnMap` (boolean) instead of
|
||||||
|
// the new `imageVisibility` enum: false → private, anything else → public.
|
||||||
|
const waypoints = parsed.waypoints.map((w) => {
|
||||||
|
const legacy = w as Waypoint & { showImageOnMap?: boolean };
|
||||||
|
if (legacy.imageVisibility === undefined && legacy.showImageOnMap === false) {
|
||||||
|
return { ...legacy, imageVisibility: 'private' as const, showImageOnMap: undefined };
|
||||||
|
}
|
||||||
|
return legacy;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
name: parsed.name ?? '',
|
||||||
|
profile: parsed.profile ?? 'hiking-mountain',
|
||||||
|
autoSnap: parsed.autoSnap !== false,
|
||||||
|
waypoints,
|
||||||
|
routedSegments: Array.isArray(parsed.routedSegments) ? parsed.routedSegments : [],
|
||||||
|
segmentSources: Array.isArray(parsed.segmentSources) ? parsed.segmentSources : []
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return defaultState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultState(): BuilderState {
|
||||||
|
return {
|
||||||
|
name: '',
|
||||||
|
profile: 'hiking-mountain',
|
||||||
|
autoSnap: true,
|
||||||
|
waypoints: [],
|
||||||
|
routedSegments: [],
|
||||||
|
segmentSources: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const builder = $state<BuilderState>(loadDraft());
|
||||||
|
|
||||||
|
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
export function scheduleSave(): void {
|
||||||
|
if (!browser) return;
|
||||||
|
if (saveTimer) clearTimeout(saveTimer);
|
||||||
|
saveTimer = setTimeout(() => {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(builder));
|
||||||
|
} catch {
|
||||||
|
/* localStorage may be unavailable in private mode */
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearDraft(): void {
|
||||||
|
builder.name = '';
|
||||||
|
builder.profile = 'hiking-mountain';
|
||||||
|
builder.autoSnap = true;
|
||||||
|
builder.waypoints.splice(0, builder.waypoints.length);
|
||||||
|
builder.routedSegments.splice(0, builder.routedSegments.length);
|
||||||
|
builder.segmentSources.splice(0, builder.segmentSources.length);
|
||||||
|
if (browser) {
|
||||||
|
try {
|
||||||
|
window.localStorage.removeItem(STORAGE_KEY);
|
||||||
|
} catch { /* ignored */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextWaypointId(): string {
|
||||||
|
return Math.random().toString(36).slice(2, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert `wp` into `builder.waypoints` so that timestamped waypoints stay in
|
||||||
|
* chronological order. Waypoints without a timestamp (map-click clicks,
|
||||||
|
* draft scribbles) act as transparent neighbours — they don't affect sorting.
|
||||||
|
* Without a timestamp on the new waypoint, falls back to a plain append.
|
||||||
|
*/
|
||||||
|
export function insertWaypointChronologically(wp: Waypoint): void {
|
||||||
|
if (typeof wp.timestamp !== 'number') {
|
||||||
|
builder.waypoints.push(wp);
|
||||||
|
scheduleSave();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const t = wp.timestamp;
|
||||||
|
let insertIdx = builder.waypoints.length;
|
||||||
|
for (let i = 0; i < builder.waypoints.length; i++) {
|
||||||
|
const other = builder.waypoints[i].timestamp;
|
||||||
|
if (typeof other === 'number' && other > t) {
|
||||||
|
insertIdx = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
builder.waypoints.splice(insertIdx, 0, wp);
|
||||||
|
scheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSource(a: Waypoint, b: Waypoint): SegmentSource {
|
||||||
|
return {
|
||||||
|
startId: a.id,
|
||||||
|
endId: b.id,
|
||||||
|
startLat: a.lat,
|
||||||
|
startLng: a.lng,
|
||||||
|
endLat: b.lat,
|
||||||
|
endLng: b.lng
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourcesMatch(s: SegmentSource, a: Waypoint, b: Waypoint): boolean {
|
||||||
|
return (
|
||||||
|
s.startId === a.id &&
|
||||||
|
s.endId === b.id &&
|
||||||
|
s.startLat === a.lat &&
|
||||||
|
s.startLng === a.lng &&
|
||||||
|
s.endLat === b.lat &&
|
||||||
|
s.endLng === b.lng
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setRoutedSegments(segments: Array<Array<[number, number, number?]>>): void {
|
||||||
|
builder.routedSegments.splice(0, builder.routedSegments.length, ...segments);
|
||||||
|
const sources: SegmentSource[] = [];
|
||||||
|
for (let i = 0; i < builder.waypoints.length - 1 && i < segments.length; i++) {
|
||||||
|
sources.push(makeSource(builder.waypoints[i], builder.waypoints[i + 1]));
|
||||||
|
}
|
||||||
|
builder.segmentSources.splice(0, builder.segmentSources.length, ...sources);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk the current waypoint pairs and rebuild `routedSegments` so it aligns
|
||||||
|
* 1:1 with consecutive waypoint pairs. A segment is preserved verbatim only
|
||||||
|
* when both endpoints match (same id AND same lat/lng) — a waypoint drag
|
||||||
|
* keeps the id but changes coords, which is exactly when the snapped geometry
|
||||||
|
* goes stale. Stale pairs are replaced with a straight two-point linear
|
||||||
|
* placeholder; if autoSnap is on, the page's snapToRoute call will overwrite
|
||||||
|
* them shortly after.
|
||||||
|
*/
|
||||||
|
export function reconcileSegments(): void {
|
||||||
|
const newSegs: Array<Array<[number, number, number?]>> = [];
|
||||||
|
const newSources: SegmentSource[] = [];
|
||||||
|
// Walk only placed waypoints — unplaced ones (image without location) sit
|
||||||
|
// in the table but don't participate in the track until the user drops
|
||||||
|
// them on the map.
|
||||||
|
const placed: Waypoint[] = [];
|
||||||
|
for (const w of builder.waypoints) {
|
||||||
|
if (!w.unplaced) placed.push(w);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < placed.length - 1; i++) {
|
||||||
|
const a = placed[i];
|
||||||
|
const b = placed[i + 1];
|
||||||
|
const oldIdx = builder.segmentSources.findIndex((s) => sourcesMatch(s, a, b));
|
||||||
|
if (oldIdx >= 0 && builder.routedSegments[oldIdx]) {
|
||||||
|
newSegs.push(builder.routedSegments[oldIdx]);
|
||||||
|
newSources.push(builder.segmentSources[oldIdx]);
|
||||||
|
} else {
|
||||||
|
newSegs.push([
|
||||||
|
[a.lng, a.lat, a.altitude],
|
||||||
|
[b.lng, b.lat, b.altitude]
|
||||||
|
]);
|
||||||
|
newSources.push(makeSource(a, b));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
builder.routedSegments.splice(0, builder.routedSegments.length, ...newSegs);
|
||||||
|
builder.segmentSources.splice(0, builder.segmentSources.length, ...newSources);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setElevations(elevations: (number | null)[]): void {
|
||||||
|
// elevations are aligned with the flattened routedSegments points; fold them
|
||||||
|
// back into the per-segment arrays.
|
||||||
|
let idx = 0;
|
||||||
|
for (const seg of builder.routedSegments) {
|
||||||
|
for (let i = 0; i < seg.length; i++) {
|
||||||
|
const e = elevations[idx++];
|
||||||
|
if (typeof e === 'number') {
|
||||||
|
seg[i] = [seg[i][0], seg[i][1], e];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Single-point Swisstopo elevation lookup for the route-builder.
|
||||||
|
* Image GPS altitude (EXIF GPSAltitude) is unreliable and causes spikes in the
|
||||||
|
* elevation profile, so all waypoints — including image-derived ones — should
|
||||||
|
* source their altitude from the terrain model instead.
|
||||||
|
*/
|
||||||
|
export async function fetchElevationAt(lat: number, lng: number): Promise<number | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/hikes/route-builder/elevation?lat=${lat}&lng=${lng}`);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const { elevation } = (await res.json()) as { elevation: number | null };
|
||||||
|
return typeof elevation === 'number' ? elevation : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* In-memory cache of full-resolution image Blob URLs keyed by waypoint id.
|
||||||
|
*
|
||||||
|
* Storing the original File as a base64 data URL would blow past the
|
||||||
|
* localStorage quota almost immediately, so the persisted draft only carries
|
||||||
|
* a 360w WebP thumbnail. The full-resolution preview lives here only for the
|
||||||
|
* lifetime of the page — on reload, callers fall back to the thumbnail.
|
||||||
|
*
|
||||||
|
* Backed by a Svelte 5 `$state` proxy so the table re-renders the moment a
|
||||||
|
* fresh Blob URL is registered (e.g. after image upload or re-attach).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
|
const urls = $state<Record<string, string>>({});
|
||||||
|
|
||||||
|
export function setFullImage(waypointId: string, file: Blob): void {
|
||||||
|
if (!browser) return;
|
||||||
|
const old = urls[waypointId];
|
||||||
|
if (old) URL.revokeObjectURL(old);
|
||||||
|
urls[waypointId] = URL.createObjectURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFullImageUrl(waypointId: string): string | undefined {
|
||||||
|
return urls[waypointId];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dropFullImage(waypointId: string): void {
|
||||||
|
if (!browser) return;
|
||||||
|
const url = urls[waypointId];
|
||||||
|
if (url) URL.revokeObjectURL(url);
|
||||||
|
delete urls[waypointId];
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Render a WebP data-URL preview of an image file using the browser's canvas.
|
||||||
|
* 360px wide — large enough to serve as a full-width preview in the waypoint
|
||||||
|
* table while still being a sane base64 payload (~30 KB) for localStorage.
|
||||||
|
* The marker badge on the map (56×56) downsamples it cleanly.
|
||||||
|
*/
|
||||||
|
const PREVIEW_WIDTH = 360;
|
||||||
|
|
||||||
|
export async function readThumbnail(file: Blob): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
const w = Math.min(PREVIEW_WIDTH, img.width || PREVIEW_WIDTH);
|
||||||
|
const ratio = img.width / img.height || 1;
|
||||||
|
const h = Math.round(w / ratio);
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = w;
|
||||||
|
canvas.height = h;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) {
|
||||||
|
reject(new Error('canvas 2d unavailable'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.drawImage(img, 0, 0, w, h);
|
||||||
|
resolve(canvas.toDataURL('image/webp', 0.7));
|
||||||
|
};
|
||||||
|
img.onerror = () => reject(new Error('Konnte Bild nicht laden'));
|
||||||
|
img.src = reader.result as string;
|
||||||
|
};
|
||||||
|
reader.onerror = () => reject(new Error('Konnte Datei nicht lesen'));
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Module-scoped registry of "scroll anchors" — DOM elements rendered by
|
||||||
|
* inline `<HikeImage>` components whose viewport positions are sampled on
|
||||||
|
* every scroll frame to compute a continuous trail-position indicator.
|
||||||
|
*
|
||||||
|
* Each anchor carries:
|
||||||
|
* - `element` — the DOM node we read `getBoundingClientRect()` from.
|
||||||
|
* - `trackIdx` — the index in the GPX track points array nearest to the
|
||||||
|
* image's timestamp. The "current trail position" is interpolated between
|
||||||
|
* adjacent anchors' `trackIdx` based on scroll progress.
|
||||||
|
* - `visibleIdx` — index in the visibility-filtered ImagePoints. Used to
|
||||||
|
* drive the focused store (strip highlighting) when the nearest-image
|
||||||
|
* changes.
|
||||||
|
*
|
||||||
|
* The registry is a singleton because there's only ever one hike detail
|
||||||
|
* page open at a time, and a Svelte context would otherwise force every
|
||||||
|
* read site (the page's scroll listener) to be inside the component tree.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ScrollAnchor {
|
||||||
|
element: HTMLElement;
|
||||||
|
trackIdx: number;
|
||||||
|
visibleIdx: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const anchors = new Set<ScrollAnchor>();
|
||||||
|
|
||||||
|
export function addScrollAnchor(a: ScrollAnchor): () => void {
|
||||||
|
anchors.add(a);
|
||||||
|
return () => {
|
||||||
|
anchors.delete(a);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listScrollAnchors(): ScrollAnchor[] {
|
||||||
|
return Array.from(anchors);
|
||||||
|
}
|
||||||
+240
@@ -0,0 +1,240 @@
|
|||||||
|
/**
|
||||||
|
* Pure GPX serializer usable from both server scripts and the browser.
|
||||||
|
* Kept dependency-free so the route-builder can bundle it for client-side
|
||||||
|
* GPX export without dragging in Node-only helpers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface GpxWritePoint {
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
altitude?: number;
|
||||||
|
/** Unix milliseconds. Pass null/undefined to omit `<time>`. */
|
||||||
|
timestamp?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Haversine distance in metres. */
|
||||||
|
function haversineM(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {
|
||||||
|
const R = 6371000;
|
||||||
|
const dLat = ((b.lat - a.lat) * Math.PI) / 180;
|
||||||
|
const dLng = ((b.lng - a.lng) * Math.PI) / 180;
|
||||||
|
const sinLat = Math.sin(dLat / 2);
|
||||||
|
const sinLng = Math.sin(dLng / 2);
|
||||||
|
const h =
|
||||||
|
sinLat * sinLat +
|
||||||
|
Math.cos((a.lat * Math.PI) / 180) *
|
||||||
|
Math.cos((b.lat * Math.PI) / 180) *
|
||||||
|
sinLng * sinLng;
|
||||||
|
return 2 * R * Math.asin(Math.sqrt(h));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AssemblyWaypoint = {
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
altitude?: number;
|
||||||
|
timestamp?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AssembleResult =
|
||||||
|
| { ok: true; points: GpxWritePoint[] }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assemble track points from a sequence of waypoints and per-pair routed
|
||||||
|
* segments, interpolating timestamps by cumulative distance between bounding
|
||||||
|
* timestamped anchors.
|
||||||
|
*
|
||||||
|
* Rules:
|
||||||
|
* - First and last waypoint MUST carry a timestamp (validated here).
|
||||||
|
* - Intermediate waypoints MAY carry a timestamp; if present, it's used as an
|
||||||
|
* anchor for the surrounding interpolation segments.
|
||||||
|
* - Track points between timestamped anchors get timestamps proportional to
|
||||||
|
* their cumulative-distance fraction within the anchor-to-anchor span.
|
||||||
|
*
|
||||||
|
* `routedSegments[i]` is the polyline from `waypoints[i]` to `waypoints[i+1]`
|
||||||
|
* as an array of `[lng, lat, ele?]` tuples. When empty / falsy, a straight
|
||||||
|
* great-circle segment is implied (two endpoints only).
|
||||||
|
*/
|
||||||
|
export function assembleTrackPoints(opts: {
|
||||||
|
waypoints: AssemblyWaypoint[];
|
||||||
|
routedSegments?: Array<Array<[number, number, number?]>>;
|
||||||
|
}): AssembleResult {
|
||||||
|
const wps = opts.waypoints;
|
||||||
|
if (wps.length < 2) return { ok: false, error: 'Mindestens zwei Wegpunkte nötig.' };
|
||||||
|
if (typeof wps[0].timestamp !== 'number') {
|
||||||
|
return { ok: false, error: 'Erster Wegpunkt benötigt einen Zeitstempel.' };
|
||||||
|
}
|
||||||
|
if (typeof wps[wps.length - 1].timestamp !== 'number') {
|
||||||
|
return { ok: false, error: 'Letzter Wegpunkt benötigt einen Zeitstempel.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Build a flat list of points (lat/lng/altitude only for now) and remember
|
||||||
|
// which indices correspond to a *waypoint anchor* (vs interpolated routing
|
||||||
|
// vertex). Waypoint altitudes win when explicitly set, but image waypoints
|
||||||
|
// typically have no `altitude` of their own — in that case we inherit the
|
||||||
|
// routed segment's elevation at the matching endpoint, so the GPX track
|
||||||
|
// still has continuous altitudes across waypoint anchors.
|
||||||
|
type FlatPoint = { lat: number; lng: number; altitude?: number; wpIndex: number | null };
|
||||||
|
const flat: FlatPoint[] = [];
|
||||||
|
for (let segIdx = 0; segIdx < wps.length - 1; segIdx++) {
|
||||||
|
const a = wps[segIdx];
|
||||||
|
const b = wps[segIdx + 1];
|
||||||
|
const routed = opts.routedSegments?.[segIdx];
|
||||||
|
const startRoutedEle =
|
||||||
|
routed && routed.length > 0 && typeof routed[0][2] === 'number' ? routed[0][2] : undefined;
|
||||||
|
const endRoutedEle =
|
||||||
|
routed && routed.length > 0 && typeof routed[routed.length - 1][2] === 'number'
|
||||||
|
? routed[routed.length - 1][2]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Start of segment — explicit waypoint altitude wins, otherwise fall
|
||||||
|
// back to the routed segment's first-vertex elevation.
|
||||||
|
if (flat.length === 0) {
|
||||||
|
flat.push({
|
||||||
|
lat: a.lat,
|
||||||
|
lng: a.lng,
|
||||||
|
altitude: typeof a.altitude === 'number' ? a.altitude : startRoutedEle,
|
||||||
|
wpIndex: segIdx
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (routed && routed.length > 0) {
|
||||||
|
// Skip the first vertex (== waypoint a) and last (== waypoint b); add
|
||||||
|
// only the interior routing vertices, then explicitly add waypoint b.
|
||||||
|
for (let i = 1; i < routed.length - 1; i++) {
|
||||||
|
const [lng, lat, ele] = routed[i];
|
||||||
|
flat.push({ lat, lng, altitude: typeof ele === 'number' ? ele : undefined, wpIndex: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flat.push({
|
||||||
|
lat: b.lat,
|
||||||
|
lng: b.lng,
|
||||||
|
altitude: typeof b.altitude === 'number' ? b.altitude : endRoutedEle,
|
||||||
|
wpIndex: segIdx + 1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Cumulative distance per flat point.
|
||||||
|
const cumDist = new Array<number>(flat.length);
|
||||||
|
cumDist[0] = 0;
|
||||||
|
for (let i = 1; i < flat.length; i++) {
|
||||||
|
cumDist[i] = cumDist[i - 1] + haversineM(flat[i - 1], flat[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Collect anchor indices (flat-array indices of waypoints with a timestamp).
|
||||||
|
const anchors: Array<{ flatIdx: number; t: number }> = [];
|
||||||
|
for (let i = 0; i < flat.length; i++) {
|
||||||
|
const wpIdx = flat[i].wpIndex;
|
||||||
|
if (wpIdx !== null) {
|
||||||
|
const ts = wps[wpIdx].timestamp;
|
||||||
|
if (typeof ts === 'number') anchors.push({ flatIdx: i, t: ts });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (anchors.length < 2) {
|
||||||
|
return { ok: false, error: 'Erster und letzter Wegpunkt benötigen Zeitstempel.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Walk anchor pairs, distribute timestamps by cumulative-distance fraction.
|
||||||
|
const times = new Array<number | null>(flat.length).fill(null);
|
||||||
|
for (let a = 0; a < anchors.length - 1; a++) {
|
||||||
|
const A = anchors[a];
|
||||||
|
const B = anchors[a + 1];
|
||||||
|
const span = cumDist[B.flatIdx] - cumDist[A.flatIdx];
|
||||||
|
const dt = B.t - A.t;
|
||||||
|
for (let i = A.flatIdx; i <= B.flatIdx; i++) {
|
||||||
|
const frac = span > 0 ? (cumDist[i] - cumDist[A.flatIdx]) / span : 0;
|
||||||
|
times[i] = A.t + dt * frac;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const out: GpxWritePoint[] = flat.map((p, i) => ({
|
||||||
|
lat: p.lat,
|
||||||
|
lng: p.lng,
|
||||||
|
altitude: p.altitude,
|
||||||
|
timestamp: times[i]
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { ok: true, points: out };
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeXml(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate a GPX 1.1 track from a list of points. */
|
||||||
|
export function buildGpxFromWaypoints(points: GpxWritePoint[], name: string): string {
|
||||||
|
return buildGpx({ name, trackPoints: points });
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GpxImageWaypoint {
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
altitude?: number;
|
||||||
|
timestamp?: number | null;
|
||||||
|
/** 8-hex-char short content hash that matches `generateImageHashClient`
|
||||||
|
* (browser) and `generateImageHashFromBuffer` (build script). */
|
||||||
|
hash: string;
|
||||||
|
/** `'private'` means anonymous viewers won't see this image on the public
|
||||||
|
* map — logged-in users still will. Omitted == `'public'`. */
|
||||||
|
visibility?: 'public' | 'private';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a GPX 1.1 document with an optional list of image waypoints.
|
||||||
|
*
|
||||||
|
* Image waypoints are emitted as standard `<wpt>` elements (separate from
|
||||||
|
* the track itself), with a custom `<bocken:image hash="…"/>` extension. The
|
||||||
|
* build script reads these back and uses the embedded coordinates instead of
|
||||||
|
* the image's EXIF GPS — letting a contributor correct an image's position
|
||||||
|
* by simply dragging the matching waypoint in the route-builder.
|
||||||
|
*/
|
||||||
|
export function buildGpx(opts: {
|
||||||
|
name: string;
|
||||||
|
trackPoints: GpxWritePoint[];
|
||||||
|
imageWaypoints?: GpxImageWaypoint[];
|
||||||
|
}): string {
|
||||||
|
const trkpts = opts.trackPoints
|
||||||
|
.map((p) => {
|
||||||
|
const ele = typeof p.altitude === 'number' ? ` <ele>${p.altitude.toFixed(1)}</ele>\n` : '';
|
||||||
|
const time = typeof p.timestamp === 'number'
|
||||||
|
? ` <time>${new Date(p.timestamp).toISOString()}</time>\n`
|
||||||
|
: '';
|
||||||
|
return ` <trkpt lat="${p.lat}" lon="${p.lng}">\n${ele}${time} </trkpt>`;
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
const hasImages = (opts.imageWaypoints?.length ?? 0) > 0;
|
||||||
|
const wpts = hasImages
|
||||||
|
? opts.imageWaypoints!
|
||||||
|
.map((w) => {
|
||||||
|
const ele = typeof w.altitude === 'number' ? ` <ele>${w.altitude.toFixed(1)}</ele>\n` : '';
|
||||||
|
const time = typeof w.timestamp === 'number'
|
||||||
|
? ` <time>${new Date(w.timestamp).toISOString()}</time>\n`
|
||||||
|
: '';
|
||||||
|
const vis = w.visibility === 'private' ? ' visibility="private"' : '';
|
||||||
|
return ` <wpt lat="${w.lat}" lon="${w.lng}">\n` +
|
||||||
|
ele + time +
|
||||||
|
` <extensions>\n` +
|
||||||
|
` <bocken:image hash="${escapeXml(w.hash)}"${vis}/>\n` +
|
||||||
|
` </extensions>\n` +
|
||||||
|
` </wpt>`;
|
||||||
|
})
|
||||||
|
.join('\n') + '\n'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const ns = hasImages ? ' xmlns:bocken="https://bocken.org/gpx/v1"' : '';
|
||||||
|
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<gpx version="1.1" creator="Bocken Route Builder" xmlns="http://www.topografix.com/GPX/1/1"${ns}>
|
||||||
|
${wpts} <trk>
|
||||||
|
<name>${escapeXml(opts.name)}</name>
|
||||||
|
<trkseg>
|
||||||
|
${trkpts}
|
||||||
|
</trkseg>
|
||||||
|
</trk>
|
||||||
|
</gpx>
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -35,7 +35,11 @@ export const de = {
|
|||||||
|
|
||||||
// Date picker
|
// Date picker
|
||||||
select_date: 'Datum wählen',
|
select_date: 'Datum wählen',
|
||||||
|
select_time: 'Uhrzeit wählen',
|
||||||
today: 'Heute',
|
today: 'Heute',
|
||||||
|
now: 'Jetzt',
|
||||||
|
apply_inherited: 'Übernehmen',
|
||||||
|
clear: 'Entfernen',
|
||||||
|
|
||||||
// Error view
|
// Error view
|
||||||
error_label: 'Fehler'
|
error_label: 'Fehler'
|
||||||
|
|||||||
@@ -35,7 +35,11 @@ export const en = {
|
|||||||
|
|
||||||
// Date picker
|
// Date picker
|
||||||
select_date: 'Select date',
|
select_date: 'Select date',
|
||||||
|
select_time: 'Select time',
|
||||||
today: 'Today',
|
today: 'Today',
|
||||||
|
now: 'Now',
|
||||||
|
apply_inherited: 'Apply',
|
||||||
|
clear: 'Clear',
|
||||||
|
|
||||||
// Error view
|
// Error view
|
||||||
error_label: 'Error'
|
error_label: 'Error'
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Compute the same 8-hex-char short content hash that `src/utils/imageHash.ts`
|
||||||
|
* produces server-side. The shared format lets the route-builder embed image
|
||||||
|
* refs in GPX files (`<wpt>` extensions) that the build script can match back
|
||||||
|
* to image files in the hike content directory.
|
||||||
|
*
|
||||||
|
* Format: SHA-256 of the full file buffer, first 4 bytes rendered as lowercase
|
||||||
|
* hex → 8 characters.
|
||||||
|
*/
|
||||||
|
export async function generateImageHashClient(file: Blob): Promise<string> {
|
||||||
|
const buf = await file.arrayBuffer();
|
||||||
|
const digest = await crypto.subtle.digest('SHA-256', buf);
|
||||||
|
const view = new Uint8Array(digest, 0, 4);
|
||||||
|
return Array.from(view, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* Shared GPX helpers used by the fitness API and the hikes build pipeline.
|
||||||
|
* Kept dependency-free so the same module is callable from server routes,
|
||||||
|
* vite-node build scripts, and (where useful) the browser.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface GpxPoint {
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
altitude?: number;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Haversine distance in km between two points. */
|
||||||
|
export function haversine(a: GpxPoint, b: GpxPoint): number {
|
||||||
|
const R = 6371;
|
||||||
|
const dLat = ((b.lat - a.lat) * Math.PI) / 180;
|
||||||
|
const dLng = ((b.lng - a.lng) * Math.PI) / 180;
|
||||||
|
const sinLat = Math.sin(dLat / 2);
|
||||||
|
const sinLng = Math.sin(dLng / 2);
|
||||||
|
const h =
|
||||||
|
sinLat * sinLat +
|
||||||
|
Math.cos((a.lat * Math.PI) / 180) *
|
||||||
|
Math.cos((b.lat * Math.PI) / 180) *
|
||||||
|
sinLng * sinLng;
|
||||||
|
return 2 * R * Math.asin(Math.sqrt(h));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sum of consecutive haversine distances in km. */
|
||||||
|
export function trackDistance(track: GpxPoint[]): number {
|
||||||
|
let total = 0;
|
||||||
|
for (let i = 1; i < track.length; i++) {
|
||||||
|
total += haversine(track[i - 1], track[i]);
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a GPX XML string into an array of GpxPoints.
|
||||||
|
* Extracts `<trkpt>`/`<rtept>` with optional `<ele>` and `<time>`.
|
||||||
|
* Falls back to `Date.now()` when no timestamp is present so downstream
|
||||||
|
* consumers always have a numeric `timestamp` field.
|
||||||
|
*/
|
||||||
|
export function parseGpx(xml: string): GpxPoint[] {
|
||||||
|
const points: GpxPoint[] = [];
|
||||||
|
const trkptRegex = /<(?:trkpt|rtept)\s+lat="([^"]+)"\s+lon="([^"]+)"[^>]*>([\s\S]*?)<\/(?:trkpt|rtept)>/gi;
|
||||||
|
let match;
|
||||||
|
while ((match = trkptRegex.exec(xml)) !== null) {
|
||||||
|
const lat = parseFloat(match[1]);
|
||||||
|
const lng = parseFloat(match[2]);
|
||||||
|
const body = match[3];
|
||||||
|
|
||||||
|
let altitude: number | undefined;
|
||||||
|
const eleMatch = body.match(/<ele>([^<]+)<\/ele>/);
|
||||||
|
if (eleMatch) altitude = parseFloat(eleMatch[1]);
|
||||||
|
|
||||||
|
let timestamp = Date.now();
|
||||||
|
const timeMatch = body.match(/<time>([^<]+)<\/time>/);
|
||||||
|
if (timeMatch) timestamp = new Date(timeMatch[1]).getTime();
|
||||||
|
|
||||||
|
if (!isNaN(lat) && !isNaN(lng)) {
|
||||||
|
points.push({ lat, lng, altitude, timestamp });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GpxImageRef {
|
||||||
|
hash: string;
|
||||||
|
name?: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
altitude?: number;
|
||||||
|
timestamp?: number;
|
||||||
|
visibility?: 'public' | 'private';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse standalone `<wpt>` waypoints that carry a `<bocken:image hash="…"/>`
|
||||||
|
* extension. Returned as a hash → ref map so the build script can look up an
|
||||||
|
* image's corrected position by content hash. Waypoints without the image
|
||||||
|
* extension are ignored.
|
||||||
|
*/
|
||||||
|
export function parseGpxImageRefs(xml: string): Record<string, GpxImageRef> {
|
||||||
|
const out: Record<string, GpxImageRef> = {};
|
||||||
|
const wptRegex = /<wpt\s+lat="([^"]+)"\s+lon="([^"]+)"[^>]*>([\s\S]*?)<\/wpt>/gi;
|
||||||
|
let match;
|
||||||
|
while ((match = wptRegex.exec(xml)) !== null) {
|
||||||
|
const lat = parseFloat(match[1]);
|
||||||
|
const lng = parseFloat(match[2]);
|
||||||
|
const body = match[3];
|
||||||
|
// Accept either namespaced (`bocken:image`) or bare (`image`) tags so
|
||||||
|
// the parser tolerates GPX files produced by other tooling that may
|
||||||
|
// drop our custom namespace prefix.
|
||||||
|
const imageMatch = body.match(/<(?:[A-Za-z]+:)?image\s+([^/>]*?)\/?>/i);
|
||||||
|
if (!imageMatch) continue;
|
||||||
|
const attrs = imageMatch[1];
|
||||||
|
const hashAttr = attrs.match(/\bhash="([^"]+)"/i);
|
||||||
|
if (!hashAttr) continue;
|
||||||
|
const hash = hashAttr[1];
|
||||||
|
const visibilityAttr = attrs.match(/\bvisibility="([^"]+)"/i);
|
||||||
|
const visibility: 'public' | 'private' =
|
||||||
|
visibilityAttr && visibilityAttr[1].toLowerCase() === 'private' ? 'private' : 'public';
|
||||||
|
const nameMatch = body.match(/<name>([^<]+)<\/name>/);
|
||||||
|
const eleMatch = body.match(/<ele>([^<]+)<\/ele>/);
|
||||||
|
const timeMatch = body.match(/<time>([^<]+)<\/time>/);
|
||||||
|
if (isNaN(lat) || isNaN(lng)) continue;
|
||||||
|
out[hash] = {
|
||||||
|
hash,
|
||||||
|
name: nameMatch ? nameMatch[1].trim() : undefined,
|
||||||
|
lat,
|
||||||
|
lng,
|
||||||
|
altitude: eleMatch ? parseFloat(eleMatch[1]) : undefined,
|
||||||
|
timestamp: timeMatch ? new Date(timeMatch[1]).getTime() : undefined,
|
||||||
|
visibility
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
/**
|
||||||
|
* Server-side helpers for the /hikes route-builder API.
|
||||||
|
*
|
||||||
|
* Wraps BRouter (primary) and OSRM (fallback) into one `routeWaypoints`
|
||||||
|
* function with a content-hashed disk cache. Falls through to linear
|
||||||
|
* interpolation when both upstreams fail so the editor stays usable
|
||||||
|
* offline.
|
||||||
|
*
|
||||||
|
* Cache layout:
|
||||||
|
* scripts/.cache/brouter/<sha256-of-payload>.json
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
export type RoutingProfile = 'hiking-mountain' | 'trekking' | 'road';
|
||||||
|
|
||||||
|
export type LatLng = { lat: number; lng: number };
|
||||||
|
|
||||||
|
const CACHE_DIR = path.resolve(process.cwd(), 'scripts', '.cache', 'brouter');
|
||||||
|
|
||||||
|
async function readCache(key: string): Promise<unknown | null> {
|
||||||
|
try {
|
||||||
|
const buf = await fs.readFile(path.join(CACHE_DIR, key), 'utf-8');
|
||||||
|
return JSON.parse(buf);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeCache(key: string, value: unknown): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fs.mkdir(CACHE_DIR, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(CACHE_DIR, key), JSON.stringify(value));
|
||||||
|
} catch {
|
||||||
|
/* cache write failure is non-fatal */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashKey(payload: unknown): string {
|
||||||
|
return (
|
||||||
|
crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex').slice(0, 16) +
|
||||||
|
'.json'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const BROUTER_PROFILE: Record<RoutingProfile, string> = {
|
||||||
|
'hiking-mountain': 'hiking-mountain',
|
||||||
|
trekking: 'trekking',
|
||||||
|
road: 'car-fast'
|
||||||
|
};
|
||||||
|
|
||||||
|
const OSRM_PROFILE: Record<RoutingProfile, string> = {
|
||||||
|
'hiking-mountain': 'foot',
|
||||||
|
trekking: 'foot',
|
||||||
|
road: 'driving'
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchJson<T>(url: string, signal: AbortSignal): Promise<T | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
signal,
|
||||||
|
headers: { 'User-Agent': 'bocken-homepage route-builder' }
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return (await res.json()) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type BrouterGeoJson = {
|
||||||
|
type: string;
|
||||||
|
features: Array<{
|
||||||
|
geometry: { coordinates: number[][] };
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OsrmResponse = {
|
||||||
|
code: string;
|
||||||
|
routes?: Array<{
|
||||||
|
geometry: { coordinates: number[][] };
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Pair `(A,B), (B,C), ...` so segments can be cached per-pair. */
|
||||||
|
function pairs(waypoints: LatLng[]): Array<[LatLng, LatLng]> {
|
||||||
|
const out: Array<[LatLng, LatLng]> = [];
|
||||||
|
for (let i = 0; i < waypoints.length - 1; i++) {
|
||||||
|
out.push([waypoints[i], waypoints[i + 1]]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Haversine distance in metres between two LatLng. */
|
||||||
|
function haversineM(a: LatLng, b: LatLng): number {
|
||||||
|
const R = 6_371_000;
|
||||||
|
const dLat = ((b.lat - a.lat) * Math.PI) / 180;
|
||||||
|
const dLng = ((b.lng - a.lng) * Math.PI) / 180;
|
||||||
|
const sinLat = Math.sin(dLat / 2);
|
||||||
|
const sinLng = Math.sin(dLng / 2);
|
||||||
|
const h =
|
||||||
|
sinLat * sinLat +
|
||||||
|
Math.cos((a.lat * Math.PI) / 180) *
|
||||||
|
Math.cos((b.lat * Math.PI) / 180) *
|
||||||
|
sinLng * sinLng;
|
||||||
|
return 2 * R * Math.asin(Math.sqrt(h));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Below this pair distance we don't bother snapping. Two waypoints that close
|
||||||
|
* are nearly always image-EXIF points placed within a few footsteps of each
|
||||||
|
* other; routing them produces noisy "snap to the nearest trail and back"
|
||||||
|
* detours and burns a BRouter API call for nothing. Straight line wins. */
|
||||||
|
const MIN_SNAP_DISTANCE_M = 50;
|
||||||
|
|
||||||
|
async function routeBrouter(
|
||||||
|
a: LatLng,
|
||||||
|
b: LatLng,
|
||||||
|
profile: RoutingProfile,
|
||||||
|
signal: AbortSignal
|
||||||
|
): Promise<Array<[number, number, number?]> | null> {
|
||||||
|
const url =
|
||||||
|
`https://brouter.de/brouter?lonlats=${a.lng},${a.lat}|${b.lng},${b.lat}` +
|
||||||
|
`&profile=${BROUTER_PROFILE[profile]}&alternativeidx=0&format=geojson`;
|
||||||
|
const json = await fetchJson<BrouterGeoJson>(url, signal);
|
||||||
|
if (!json?.features?.[0]?.geometry?.coordinates) return null;
|
||||||
|
const coords = json.features[0].geometry.coordinates;
|
||||||
|
// BRouter geojson is [lng, lat, ele].
|
||||||
|
return coords.map((c) => [c[0], c[1], typeof c[2] === 'number' ? c[2] : undefined] as [number, number, number?]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function routeOsrm(
|
||||||
|
a: LatLng,
|
||||||
|
b: LatLng,
|
||||||
|
profile: RoutingProfile,
|
||||||
|
signal: AbortSignal
|
||||||
|
): Promise<Array<[number, number]> | null> {
|
||||||
|
const url =
|
||||||
|
`https://router.project-osrm.org/route/v1/${OSRM_PROFILE[profile]}/` +
|
||||||
|
`${a.lng},${a.lat};${b.lng},${b.lat}?overview=full&geometries=geojson`;
|
||||||
|
const json = await fetchJson<OsrmResponse>(url, signal);
|
||||||
|
if (json?.code !== 'Ok' || !json.routes?.[0]?.geometry?.coordinates) return null;
|
||||||
|
return json.routes[0].geometry.coordinates as Array<[number, number]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function linearSegment(a: LatLng, b: LatLng, n = 16): Array<[number, number]> {
|
||||||
|
const out: Array<[number, number]> = [];
|
||||||
|
for (let i = 0; i <= n; i++) {
|
||||||
|
const f = i / n;
|
||||||
|
out.push([a.lng + (b.lng - a.lng) * f, a.lat + (b.lat - a.lat) * f]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a routed polyline per waypoint pair.
|
||||||
|
* `forceLinear` skips upstream routing — handy when the editor knows the route
|
||||||
|
* is off-trail.
|
||||||
|
*/
|
||||||
|
export async function routeWaypoints(opts: {
|
||||||
|
waypoints: LatLng[];
|
||||||
|
profile: RoutingProfile;
|
||||||
|
forceLinear?: boolean;
|
||||||
|
}): Promise<{
|
||||||
|
segments: Array<Array<[number, number, number?]>>;
|
||||||
|
source: 'brouter' | 'osrm' | 'linear' | 'mixed' | 'cache';
|
||||||
|
}> {
|
||||||
|
const segments: Array<Array<[number, number, number?]>> = [];
|
||||||
|
const sources = new Set<'brouter' | 'osrm' | 'linear' | 'cache'>();
|
||||||
|
|
||||||
|
const ac = new AbortController();
|
||||||
|
const timeout = setTimeout(() => ac.abort(), 20_000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const [a, b] of pairs(opts.waypoints)) {
|
||||||
|
let seg: Array<[number, number, number?]> | null = null;
|
||||||
|
|
||||||
|
const shortPair = haversineM(a, b) < MIN_SNAP_DISTANCE_M;
|
||||||
|
|
||||||
|
if (!opts.forceLinear && !shortPair) {
|
||||||
|
const key = hashKey({ a, b, p: opts.profile });
|
||||||
|
const cached = (await readCache(key)) as Array<[number, number, number?]> | null;
|
||||||
|
if (cached && Array.isArray(cached)) {
|
||||||
|
seg = cached;
|
||||||
|
sources.add('cache');
|
||||||
|
}
|
||||||
|
if (!seg) {
|
||||||
|
seg = await routeBrouter(a, b, opts.profile, ac.signal);
|
||||||
|
if (seg) sources.add('brouter');
|
||||||
|
}
|
||||||
|
if (!seg) {
|
||||||
|
const osrm = await routeOsrm(a, b, opts.profile, ac.signal);
|
||||||
|
if (osrm) {
|
||||||
|
seg = osrm.map((c) => [c[0], c[1]] as [number, number, number?]);
|
||||||
|
sources.add('osrm');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (seg) {
|
||||||
|
await writeCache(hashKey({ a, b, p: opts.profile }), seg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!seg) {
|
||||||
|
// Short pairs don't need 16-step interpolation — two endpoints suffice.
|
||||||
|
seg = shortPair
|
||||||
|
? [
|
||||||
|
[a.lng, a.lat] as [number, number, number?],
|
||||||
|
[b.lng, b.lat] as [number, number, number?]
|
||||||
|
]
|
||||||
|
: linearSegment(a, b).map((c) => [c[0], c[1]] as [number, number, number?]);
|
||||||
|
sources.add('linear');
|
||||||
|
}
|
||||||
|
segments.push(seg);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = sources.size === 1 ? [...sources][0] : 'mixed';
|
||||||
|
return { segments, source };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Swisstopo elevation enrichment
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const ELEV_CACHE_DIR = path.resolve(process.cwd(), 'scripts', '.cache', 'swisstopo-elevation');
|
||||||
|
|
||||||
|
type SwisstopoProfile = Array<{ alts: { COMB?: number; DTM2?: number; DTM25?: number } }>;
|
||||||
|
|
||||||
|
async function heightAt(lng: number, lat: number): Promise<number | null> {
|
||||||
|
const key = hashKey({ kind: 'height', lng, lat });
|
||||||
|
try {
|
||||||
|
await fs.mkdir(ELEV_CACHE_DIR, { recursive: true });
|
||||||
|
const cached = await fs.readFile(path.join(ELEV_CACHE_DIR, key), 'utf-8').catch(() => null);
|
||||||
|
if (cached) return JSON.parse(cached) as number | null;
|
||||||
|
} catch { /* ignored */ }
|
||||||
|
|
||||||
|
const url =
|
||||||
|
`https://api3.geo.admin.ch/rest/services/height` +
|
||||||
|
`?easting=${lng}&northing=${lat}&sr=4326`;
|
||||||
|
let elev: number | null = null;
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { 'User-Agent': 'bocken-homepage route-builder' }
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = (await res.json()) as { height?: string | number };
|
||||||
|
const raw = data.height;
|
||||||
|
const n = typeof raw === 'number' ? raw : typeof raw === 'string' ? parseFloat(raw) : NaN;
|
||||||
|
elev = Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
} catch { /* keep null */ }
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.writeFile(path.join(ELEV_CACHE_DIR, key), JSON.stringify(elev));
|
||||||
|
} catch { /* ignored */ }
|
||||||
|
return elev;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enrich a coordinate list (`[lng, lat][]`) with elevations from Swisstopo.
|
||||||
|
* Cached on disk by content hash. Returns the elevations aligned 1:1 with
|
||||||
|
* the input.
|
||||||
|
*
|
||||||
|
* Implementation note: profile.json sampled along a LineString breaks when
|
||||||
|
* the input contains consecutive identical coordinates (zero-length sub-
|
||||||
|
* segments) — Swisstopo returns fewer samples than requested, which
|
||||||
|
* silently shifts every elevation that follows. We dedupe consecutive
|
||||||
|
* duplicates before calling profile.json and expand the result back; for
|
||||||
|
* the resulting single-unique-vertex degenerate case we fall through to
|
||||||
|
* the per-point `height` endpoint instead.
|
||||||
|
*/
|
||||||
|
export async function enrichElevations(
|
||||||
|
coordinates: Array<[number, number]>
|
||||||
|
): Promise<(number | null)[]> {
|
||||||
|
if (coordinates.length === 0) return [];
|
||||||
|
|
||||||
|
// Collapse consecutive identical coords into runs. `positions` records
|
||||||
|
// where each unique point sat in the original input so we can fan the
|
||||||
|
// elevation result back out 1:1.
|
||||||
|
type Run = { coord: [number, number]; positions: number[] };
|
||||||
|
const runs: Run[] = [];
|
||||||
|
for (let i = 0; i < coordinates.length; i++) {
|
||||||
|
const c = coordinates[i];
|
||||||
|
const tail = runs[runs.length - 1];
|
||||||
|
if (tail && tail.coord[0] === c[0] && tail.coord[1] === c[1]) {
|
||||||
|
tail.positions.push(i);
|
||||||
|
} else {
|
||||||
|
runs.push({ coord: c, positions: [i] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single unique vertex (degenerate LineString) → `height` endpoint.
|
||||||
|
if (runs.length === 1) {
|
||||||
|
const e = await heightAt(runs[0].coord[0], runs[0].coord[1]);
|
||||||
|
return new Array<number | null>(coordinates.length).fill(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueCoords = runs.map((r) => r.coord);
|
||||||
|
const key = hashKey({ kind: 'elev', c: uniqueCoords });
|
||||||
|
let uniqueElev: (number | null)[] | null = null;
|
||||||
|
try {
|
||||||
|
await fs.mkdir(ELEV_CACHE_DIR, { recursive: true });
|
||||||
|
const cached = await fs.readFile(path.join(ELEV_CACHE_DIR, key), 'utf-8').catch(() => null);
|
||||||
|
if (cached) uniqueElev = JSON.parse(cached) as (number | null)[];
|
||||||
|
} catch { /* ignored */ }
|
||||||
|
|
||||||
|
if (!uniqueElev) {
|
||||||
|
uniqueElev = new Array<number | null>(uniqueCoords.length).fill(null);
|
||||||
|
// Swisstopo caps offsets/payload sizes; chunk if needed.
|
||||||
|
const CHUNK = 200;
|
||||||
|
let cursor = 0;
|
||||||
|
while (cursor < uniqueCoords.length) {
|
||||||
|
const slice = uniqueCoords.slice(cursor, Math.min(uniqueCoords.length, cursor + CHUNK));
|
||||||
|
const slicedGeom = { type: 'LineString', coordinates: slice };
|
||||||
|
const url =
|
||||||
|
`https://api3.geo.admin.ch/rest/services/profile.json` +
|
||||||
|
`?geom=${encodeURIComponent(JSON.stringify(slicedGeom))}` +
|
||||||
|
`&nb_points=${slice.length}&offset=0&sr=4326`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { 'User-Agent': 'bocken-homepage route-builder' }
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const json = (await res.json()) as SwisstopoProfile;
|
||||||
|
for (let i = 0; i < json.length && cursor + i < uniqueElev.length; i++) {
|
||||||
|
const e = json[i].alts?.COMB ?? json[i].alts?.DTM2 ?? json[i].alts?.DTM25 ?? null;
|
||||||
|
uniqueElev[cursor + i] = typeof e === 'number' ? e : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* keep nulls */ }
|
||||||
|
cursor += CHUNK;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.writeFile(path.join(ELEV_CACHE_DIR, key), JSON.stringify(uniqueElev));
|
||||||
|
} catch { /* ignored */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const elevations: (number | null)[] = new Array(coordinates.length).fill(null);
|
||||||
|
for (let r = 0; r < runs.length; r++) {
|
||||||
|
const e = uniqueElev[r];
|
||||||
|
for (const pos of runs[r].positions) {
|
||||||
|
elevations[pos] = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return elevations;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Per-user rate limiter (in-memory token bucket).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const RATE_BUCKETS = new Map<string, { tokens: number; refilledAt: number }>();
|
||||||
|
const RATE_LIMIT = 30; // requests per minute
|
||||||
|
const RATE_WINDOW_MS = 60_000;
|
||||||
|
|
||||||
|
export function rateLimit(key: string): { ok: boolean; retryAfter?: number } {
|
||||||
|
const now = Date.now();
|
||||||
|
const bucket = RATE_BUCKETS.get(key) ?? { tokens: RATE_LIMIT, refilledAt: now };
|
||||||
|
const refillTokens = Math.floor(((now - bucket.refilledAt) / RATE_WINDOW_MS) * RATE_LIMIT);
|
||||||
|
if (refillTokens > 0) {
|
||||||
|
bucket.tokens = Math.min(RATE_LIMIT, bucket.tokens + refillTokens);
|
||||||
|
bucket.refilledAt = now;
|
||||||
|
}
|
||||||
|
if (bucket.tokens <= 0) {
|
||||||
|
RATE_BUCKETS.set(key, bucket);
|
||||||
|
return { ok: false, retryAfter: Math.ceil((RATE_WINDOW_MS - (now - bucket.refilledAt)) / 1000) };
|
||||||
|
}
|
||||||
|
bucket.tokens -= 1;
|
||||||
|
RATE_BUCKETS.set(key, bucket);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
@@ -263,5 +263,10 @@ section h2{
|
|||||||
<h3>{labels.audiobooksPodcasts}</h3>
|
<h3>{labels.audiobooksPodcasts}</h3>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<a href={resolve('/hikes')}>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-10 -290 436 600"><path d="M192-280c31 0 56 25 56 56s-25 56-56 56-56-25-56-56 25-56 56-56zM128-74c0-34 28-62 62-62 20 0 39 8 54 22l48 49c6 6 14 9 22 9h38c6 0 11 2 16 4v-76c0-13 11-24 24-24s24 11 24 24v400c0 13-11 24-24 24s-24-11-24-24V4c-5 2-10 4-16 4h-38c-25 0-49-10-67-28l-7-7V83l34 29c18 15 30 36 33 59l13 89c2 17-10 33-28 36-17 2-33-10-36-27l-12-89c-1-7-5-14-11-19l-72-62c-21-18-33-44-33-72V-74zm-5 203 7 7 45 38c-3 9-8 17-14 24l-72 87c-12 13-32 15-45 4-14-12-16-32-5-45l73-87c2-3 4-7 6-11l5-17zM0-88c0-35 29-64 64-64 18 0 32 14 32 32V8c0 18-14 32-32 32H32C14 40 0 26 0 8v-96z"/></svg>
|
||||||
|
<h3>{isEnglish ? 'Hikes' : 'Wanderungen'}</h3>
|
||||||
|
</a>
|
||||||
|
|
||||||
</LinksGrid>
|
</LinksGrid>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -2,61 +2,12 @@ import { json } from '@sveltejs/kit';
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { dbConnect } from '$utils/db';
|
import { dbConnect } from '$utils/db';
|
||||||
import { WorkoutSession } from '$models/WorkoutSession';
|
import { WorkoutSession } from '$models/WorkoutSession';
|
||||||
import type { IGpsPoint } from '$models/WorkoutSession';
|
|
||||||
import { simplifyTrack } from '$lib/server/simplifyTrack';
|
import { simplifyTrack } from '$lib/server/simplifyTrack';
|
||||||
import { computeSessionKcal } from '$lib/server/computeSessionKcal';
|
import { computeSessionKcal } from '$lib/server/computeSessionKcal';
|
||||||
import { generateGpx, buildGpxFilename } from '$lib/server/gpxExport';
|
import { generateGpx, buildGpxFilename } from '$lib/server/gpxExport';
|
||||||
|
import { parseGpx, trackDistance } from '$lib/server/gpx';
|
||||||
import mongoose from 'mongoose';
|
import mongoose from 'mongoose';
|
||||||
|
|
||||||
/** Haversine distance in km between two points */
|
|
||||||
function haversine(a: IGpsPoint, b: IGpsPoint): number {
|
|
||||||
const R = 6371;
|
|
||||||
const dLat = ((b.lat - a.lat) * Math.PI) / 180;
|
|
||||||
const dLng = ((b.lng - a.lng) * Math.PI) / 180;
|
|
||||||
const sinLat = Math.sin(dLat / 2);
|
|
||||||
const sinLng = Math.sin(dLng / 2);
|
|
||||||
const h =
|
|
||||||
sinLat * sinLat +
|
|
||||||
Math.cos((a.lat * Math.PI) / 180) *
|
|
||||||
Math.cos((b.lat * Math.PI) / 180) *
|
|
||||||
sinLng * sinLng;
|
|
||||||
return 2 * R * Math.asin(Math.sqrt(h));
|
|
||||||
}
|
|
||||||
|
|
||||||
function trackDistance(track: IGpsPoint[]): number {
|
|
||||||
let total = 0;
|
|
||||||
for (let i = 1; i < track.length; i++) {
|
|
||||||
total += haversine(track[i - 1], track[i]);
|
|
||||||
}
|
|
||||||
return total;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Parse a GPX XML string into an array of GpsPoints */
|
|
||||||
function parseGpx(xml: string): IGpsPoint[] {
|
|
||||||
const points: IGpsPoint[] = [];
|
|
||||||
// Match <trkpt> or <rtept> elements
|
|
||||||
const trkptRegex = /<(?:trkpt|rtept)\s+lat="([^"]+)"\s+lon="([^"]+)"[^>]*>([\s\S]*?)<\/(?:trkpt|rtept)>/gi;
|
|
||||||
let match;
|
|
||||||
while ((match = trkptRegex.exec(xml)) !== null) {
|
|
||||||
const lat = parseFloat(match[1]);
|
|
||||||
const lng = parseFloat(match[2]);
|
|
||||||
const body = match[3];
|
|
||||||
|
|
||||||
let altitude: number | undefined;
|
|
||||||
const eleMatch = body.match(/<ele>([^<]+)<\/ele>/);
|
|
||||||
if (eleMatch) altitude = parseFloat(eleMatch[1]);
|
|
||||||
|
|
||||||
let timestamp = Date.now();
|
|
||||||
const timeMatch = body.match(/<time>([^<]+)<\/time>/);
|
|
||||||
if (timeMatch) timestamp = new Date(timeMatch[1]).getTime();
|
|
||||||
|
|
||||||
if (!isNaN(lat) && !isNaN(lng)) {
|
|
||||||
points.push({ lat, lng, altitude, timestamp });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return points;
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /api/fitness/sessions/[id]/gpx?exerciseIdx=N — download GPX export of the track
|
// GET /api/fitness/sessions/[id]/gpx?exerciseIdx=N — download GPX export of the track
|
||||||
export const GET: RequestHandler = async ({ params, url, locals }) => {
|
export const GET: RequestHandler = async ({ params, url, locals }) => {
|
||||||
const session = locals.session ?? await locals.auth();
|
const session = locals.session ?? await locals.auth();
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { rateLimit, enrichElevations } from '$lib/server/hikesRouting';
|
||||||
|
|
||||||
|
const MAX_COORDS = 4000;
|
||||||
|
|
||||||
|
// Single-point lookup: `GET ?lat=…&lng=…` returns `{ elevation: number | null }`.
|
||||||
|
// Used by EditMap when a waypoint is dropped via map click — keeps round-trips
|
||||||
|
// cheap for the common one-at-a-time case.
|
||||||
|
export const GET: RequestHandler = async ({ url, locals, getClientAddress }) => {
|
||||||
|
const session = locals.session ?? (await locals.auth());
|
||||||
|
if (!session?.user) throw error(401, 'Anmeldung erforderlich');
|
||||||
|
|
||||||
|
const rateKey = session.user.nickname ?? session.user.email ?? getClientAddress();
|
||||||
|
const { ok, retryAfter } = rateLimit(`elev:${rateKey}`);
|
||||||
|
if (!ok) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Rate limit exceeded' }), {
|
||||||
|
status: 429,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Retry-After': String(retryAfter ?? 60)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const lat = parseFloat(url.searchParams.get('lat') ?? '');
|
||||||
|
const lng = parseFloat(url.searchParams.get('lng') ?? '');
|
||||||
|
if (!isFinite(lat) || !isFinite(lng)) throw error(400, 'Invalid lat/lng');
|
||||||
|
|
||||||
|
const elevations = await enrichElevations([[lng, lat]]);
|
||||||
|
return json({ elevation: elevations[0] ?? null });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request, locals, getClientAddress }) => {
|
||||||
|
const session = locals.session ?? (await locals.auth());
|
||||||
|
if (!session?.user) throw error(401, 'Anmeldung erforderlich');
|
||||||
|
|
||||||
|
const rateKey = session.user.nickname ?? session.user.email ?? getClientAddress();
|
||||||
|
const { ok, retryAfter } = rateLimit(`elev:${rateKey}`);
|
||||||
|
if (!ok) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Rate limit exceeded' }), {
|
||||||
|
status: 429,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Retry-After': String(retryAfter ?? 60)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: { coordinates?: Array<[number, number]> };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
throw error(400, 'Invalid JSON body');
|
||||||
|
}
|
||||||
|
|
||||||
|
const coords = body.coordinates;
|
||||||
|
if (!Array.isArray(coords) || coords.length === 0) {
|
||||||
|
throw error(400, 'coordinates muss ein nicht-leeres Array sein');
|
||||||
|
}
|
||||||
|
if (coords.length > MAX_COORDS) {
|
||||||
|
throw error(400, `Maximal ${MAX_COORDS} Koordinaten pro Anfrage`);
|
||||||
|
}
|
||||||
|
const cleaned = coords.filter(
|
||||||
|
(c): c is [number, number] =>
|
||||||
|
Array.isArray(c) && typeof c[0] === 'number' && typeof c[1] === 'number'
|
||||||
|
);
|
||||||
|
if (cleaned.length !== coords.length) {
|
||||||
|
throw error(400, 'Ungültige Koordinate(n) im Array');
|
||||||
|
}
|
||||||
|
|
||||||
|
const elevations = await enrichElevations(cleaned);
|
||||||
|
return json({ elevations });
|
||||||
|
};
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import {
|
||||||
|
rateLimit,
|
||||||
|
routeWaypoints,
|
||||||
|
type LatLng,
|
||||||
|
type RoutingProfile
|
||||||
|
} from '$lib/server/hikesRouting';
|
||||||
|
|
||||||
|
const MAX_WAYPOINTS = 200;
|
||||||
|
|
||||||
|
const VALID_PROFILES: RoutingProfile[] = ['hiking-mountain', 'trekking', 'road'];
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request, locals, getClientAddress }) => {
|
||||||
|
const session = locals.session ?? (await locals.auth());
|
||||||
|
if (!session?.user) throw error(401, 'Anmeldung erforderlich');
|
||||||
|
|
||||||
|
const rateKey = session.user.nickname ?? session.user.email ?? getClientAddress();
|
||||||
|
const { ok, retryAfter } = rateLimit(`route:${rateKey}`);
|
||||||
|
if (!ok) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Rate limit exceeded' }), {
|
||||||
|
status: 429,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Retry-After': String(retryAfter ?? 60)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: { waypoints?: LatLng[]; profile?: RoutingProfile; forceLinear?: boolean };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
throw error(400, 'Invalid JSON body');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(body.waypoints) || body.waypoints.length < 2) {
|
||||||
|
throw error(400, 'Mindestens zwei Wegpunkte nötig');
|
||||||
|
}
|
||||||
|
if (body.waypoints.length > MAX_WAYPOINTS) {
|
||||||
|
throw error(400, `Maximal ${MAX_WAYPOINTS} Wegpunkte pro Anfrage`);
|
||||||
|
}
|
||||||
|
const waypoints = body.waypoints.filter(
|
||||||
|
(w): w is LatLng =>
|
||||||
|
typeof w?.lat === 'number' &&
|
||||||
|
typeof w?.lng === 'number' &&
|
||||||
|
isFinite(w.lat) &&
|
||||||
|
isFinite(w.lng)
|
||||||
|
);
|
||||||
|
if (waypoints.length !== body.waypoints.length) {
|
||||||
|
throw error(400, 'Ungültige Koordinaten im Wegpunkt-Array');
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile: RoutingProfile = VALID_PROFILES.includes(body.profile as RoutingProfile)
|
||||||
|
? (body.profile as RoutingProfile)
|
||||||
|
: 'hiking-mountain';
|
||||||
|
|
||||||
|
const { segments, source } = await routeWaypoints({
|
||||||
|
waypoints,
|
||||||
|
profile,
|
||||||
|
forceLinear: body.forceLinear === true
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({ segments, source });
|
||||||
|
};
|
||||||
@@ -2142,50 +2142,33 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
padding: 0.25rem 0 0;
|
padding: 0.25rem 0 0;
|
||||||
view-transition-name: workout-set-table;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* View transition choreography: vertical for the focus card's name +
|
/* Exercise progression: the focus card swaps as a single unit along two
|
||||||
set-counter (always), horizontal for the set table on desktop. */
|
different axes so the old and new never share visual space — old lifts
|
||||||
:global(::view-transition-old(workout-focus-name)),
|
up and out of frame (the sky above is empty), new settles in from the
|
||||||
:global(::view-transition-old(workout-focus-progress)) {
|
right with a small drift bounded in rem to stay within its column.
|
||||||
animation: workout-slide-out-up 220ms cubic-bezier(0.4, 0, 0.2, 1) both;
|
The set table is intentionally unnamed so it re-renders in place — the
|
||||||
}
|
rest timer reflows the layout, and a named slide would otherwise produce
|
||||||
:global(::view-transition-new(workout-focus-name)),
|
a diagonal interpolation between the old and new positions. */
|
||||||
:global(::view-transition-new(workout-focus-progress)) {
|
:global(::view-transition-group(workout-focus-card)) {
|
||||||
animation: workout-slide-in-up 260ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
|
|
||||||
}
|
|
||||||
:global(::view-transition-old(workout-set-table)),
|
|
||||||
:global(::view-transition-new(workout-set-table)) {
|
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
@media (min-width: 900px) {
|
:global(::view-transition-old(workout-focus-card)) {
|
||||||
:global(::view-transition-old(workout-set-table)) {
|
animation: workout-card-out 480ms cubic-bezier(0.4, 0, 1, 1) both;
|
||||||
animation: workout-slide-out-left 240ms cubic-bezier(0.4, 0, 0.2, 1) both;
|
|
||||||
}
|
|
||||||
:global(::view-transition-new(workout-set-table)) {
|
|
||||||
animation: workout-slide-in-right 280ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@keyframes workout-slide-out-up {
|
:global(::view-transition-new(workout-focus-card)) {
|
||||||
to { opacity: 0; transform: translateY(-28%); }
|
animation: workout-card-in 380ms cubic-bezier(0.22, 1, 0.36, 1) 160ms both;
|
||||||
}
|
}
|
||||||
@keyframes workout-slide-in-up {
|
@keyframes workout-card-out {
|
||||||
from { opacity: 0; transform: translateY(28%); }
|
to { opacity: 0; transform: translateY(-110%); }
|
||||||
}
|
}
|
||||||
@keyframes workout-slide-out-left {
|
@keyframes workout-card-in {
|
||||||
to { opacity: 0; transform: translateX(-6%); }
|
from { opacity: 0; transform: translateX(100%); }
|
||||||
}
|
|
||||||
@keyframes workout-slide-in-right {
|
|
||||||
from { opacity: 0; transform: translateX(6%); }
|
|
||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
:global(::view-transition-old(workout-focus-name)),
|
:global(::view-transition-old(workout-focus-card)),
|
||||||
:global(::view-transition-old(workout-focus-progress)),
|
:global(::view-transition-new(workout-focus-card)) {
|
||||||
:global(::view-transition-new(workout-focus-name)),
|
|
||||||
:global(::view-transition-new(workout-focus-progress)),
|
|
||||||
:global(::view-transition-old(workout-set-table)),
|
|
||||||
:global(::view-transition-new(workout-set-table)) {
|
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||||
|
return {
|
||||||
|
session: locals.session ?? (await locals.auth())
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import Header from '$lib/components/Header.svelte';
|
||||||
|
import UserHeader from '$lib/components/UserHeader.svelte';
|
||||||
|
import MapIcon from '@lucide/svelte/icons/map';
|
||||||
|
import Compass from '@lucide/svelte/icons/compass';
|
||||||
|
|
||||||
|
let { data, children } = $props();
|
||||||
|
let user = $derived(data.session?.user);
|
||||||
|
|
||||||
|
function isActive(path: string) {
|
||||||
|
const currentPath = page.url.pathname;
|
||||||
|
if (path === '/hikes') {
|
||||||
|
return currentPath === '/hikes' || currentPath === '/hikes/';
|
||||||
|
}
|
||||||
|
return currentPath.startsWith(path);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Header>
|
||||||
|
{#snippet links()}
|
||||||
|
<ul class="site_header">
|
||||||
|
<li style="--active-fill: var(--nord10)">
|
||||||
|
<a href={resolve('/hikes')} class:active={isActive('/hikes')}>
|
||||||
|
<MapIcon size={16} strokeWidth={1.5} class="nav-icon" />
|
||||||
|
<span class="nav-label">Alle Touren</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li style="--active-fill: var(--nord14)">
|
||||||
|
<a href={resolve('/hikes/route-builder')} class:active={isActive('/hikes/route-builder')}>
|
||||||
|
<Compass size={16} strokeWidth={1.5} class="nav-icon" />
|
||||||
|
<span class="nav-label">Routen-Builder</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet right_side()}
|
||||||
|
<UserHeader {user} />
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{@render children()}
|
||||||
|
</Header>
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
|
import HikeCard from '$lib/components/hikes/HikeCard.svelte';
|
||||||
|
import HikesFilterBar, { type HikesFilter } from '$lib/components/hikes/HikesFilterBar.svelte';
|
||||||
|
import HikesOverviewMap from '$lib/components/hikes/HikesOverviewMap.svelte';
|
||||||
|
import Seo from '$lib/components/Seo.svelte';
|
||||||
|
import type { Difficulty } from '$types/hikes';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
|
const { data }: PageProps = $props();
|
||||||
|
|
||||||
|
// Filter ceilings start wide-open so the initial render (SSR + first
|
||||||
|
// hydration pass) shows every hike. `$effect` below clamps them down
|
||||||
|
// to the actual data maxes once `data.hikes` is fully populated —
|
||||||
|
// reading `data.hikes` synchronously at script-init turned out to be
|
||||||
|
// fragile during dev hydration (it sporadically returned a one-hike
|
||||||
|
// subset, which then locked the filter to that one hike until the
|
||||||
|
// next navigation cycle).
|
||||||
|
const filter = $state<HikesFilter>({
|
||||||
|
maxDistanceKm: Number.POSITIVE_INFINITY,
|
||||||
|
maxDurationMin: Number.POSITIVE_INFINITY,
|
||||||
|
maxGainM: Number.POSITIVE_INFINITY,
|
||||||
|
maxLossM: Number.POSITIVE_INFINITY,
|
||||||
|
difficulties: new SvelteSet<Difficulty>(),
|
||||||
|
regions: new SvelteSet<string>()
|
||||||
|
});
|
||||||
|
|
||||||
|
// One-shot per mount: set the slider ceilings to the actual data maxes.
|
||||||
|
// Runs once after `data.hikes` is non-empty; the inner reads of every
|
||||||
|
// `distanceKm`/`durationMin`/etc. fall under the same effect so a
|
||||||
|
// subsequent data-only update would also refresh the defaults — but for
|
||||||
|
// this prerendered, static-data page that's effectively a no-op.
|
||||||
|
let filterDefaultsApplied = false;
|
||||||
|
$effect(() => {
|
||||||
|
if (filterDefaultsApplied) return;
|
||||||
|
if (data.hikes.length === 0) return;
|
||||||
|
filter.maxDistanceKm = Math.max(1, ...data.hikes.map((h) => Math.ceil(h.distanceKm)));
|
||||||
|
filter.maxDurationMin = Math.max(60, ...data.hikes.map((h) => h.durationMin ?? 0));
|
||||||
|
filter.maxGainM = Math.max(100, ...data.hikes.map((h) => h.elevationGainM));
|
||||||
|
filter.maxLossM = Math.max(100, ...data.hikes.map((h) => h.elevationLossM));
|
||||||
|
filterDefaultsApplied = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const visible = $derived.by(() => {
|
||||||
|
const out = [];
|
||||||
|
for (const h of data.hikes) {
|
||||||
|
if (h.distanceKm > filter.maxDistanceKm) continue;
|
||||||
|
if ((h.durationMin ?? 0) > filter.maxDurationMin) continue;
|
||||||
|
if (h.elevationGainM > filter.maxGainM) continue;
|
||||||
|
if (h.elevationLossM > filter.maxLossM) continue;
|
||||||
|
if (filter.difficulties.size > 0 && !filter.difficulties.has(h.difficulty)) continue;
|
||||||
|
if (filter.regions.size > 0 && (!h.region || !filter.regions.has(h.region))) continue;
|
||||||
|
out.push(h);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lightweight totals strip over the currently-filtered subset — gives
|
||||||
|
// the user a sense of what they're looking at without having to scan
|
||||||
|
// every card.
|
||||||
|
const totals = $derived.by(() => {
|
||||||
|
let km = 0;
|
||||||
|
let gain = 0;
|
||||||
|
for (const h of visible) {
|
||||||
|
km += h.distanceKm;
|
||||||
|
gain += h.elevationGainM;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
km: Math.round(km),
|
||||||
|
gain: Math.round(gain)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Seo
|
||||||
|
title="Wanderungen"
|
||||||
|
description="Wanderberichte mit interaktiver Karte, Höhenprofil und GPX-Track."
|
||||||
|
lang="de"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section class="hikes-page">
|
||||||
|
<section class="hero-map" aria-label="Übersicht">
|
||||||
|
<HikesOverviewMap hikes={visible} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="below-hero">
|
||||||
|
<header class="page-header">
|
||||||
|
<p class="subtitle">
|
||||||
|
<strong>{visible.length}</strong> von {data.hikes.length} Touren
|
||||||
|
</p>
|
||||||
|
{#if visible.length > 0}
|
||||||
|
<dl class="totals" aria-label="Gesamtsumme der gefilterten Touren">
|
||||||
|
<div><dt>Distanz</dt><dd>{totals.km} km</dd></div>
|
||||||
|
<div><dt>Aufstieg</dt><dd>{totals.gain.toLocaleString('de-CH')} m</dd></div>
|
||||||
|
</dl>
|
||||||
|
{/if}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<HikesFilterBar hikes={data.hikes} {filter} />
|
||||||
|
|
||||||
|
{#if visible.length === 0}
|
||||||
|
<p class="empty">Keine Wanderung entspricht den aktuellen Filtern.</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="grid">
|
||||||
|
{#each visible as hike (hike.slug)}
|
||||||
|
<li>
|
||||||
|
<HikeCard {hike} />
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.hikes-page {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin-inline: auto;
|
||||||
|
padding: 0 0 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Full-bleed hero, matching the detail-page hero: edge-to-edge via
|
||||||
|
* `calc(50% - 50vw)` and pulled up under the glass-blurred sticky nav
|
||||||
|
* with a negative top margin equal to the nav's height.
|
||||||
|
* `isolation: isolate` creates a stacking context so Leaflet's
|
||||||
|
* z-index:200+ panes can't escape this section and render over the
|
||||||
|
* sticky nav (which sits at z-index 100). The detail-page hero gets
|
||||||
|
* this same effect for free because it sets `view-transition-name`. */
|
||||||
|
.hero-map {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
width: 100vw;
|
||||||
|
margin-left: calc(50% - 50vw);
|
||||||
|
margin-right: calc(50% - 50vw);
|
||||||
|
margin-top: calc(-1 * (3rem + max(12px, env(safe-area-inset-top, 0px) + 4px)));
|
||||||
|
margin-bottom: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Push Leaflet's top-left controls below the sticky nav. */
|
||||||
|
.hero-map :global(.leaflet-top) {
|
||||||
|
top: calc(3rem + max(12px, env(safe-area-inset-top, 0px) + 4px) + 0.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem 1.5rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle strong {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.25rem;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals dt {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals dd {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 1.5rem 0 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { HIKES } from '$lib/data/hikes.generated';
|
||||||
|
|
||||||
|
export const prerender = true;
|
||||||
|
|
||||||
|
export const load = () => ({
|
||||||
|
hikes: HIKES.filter((h) => !h.hidden)
|
||||||
|
});
|
||||||
@@ -0,0 +1,635 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import HikeMap from '$lib/components/hikes/HikeMap.svelte';
|
||||||
|
import HikePhotoStrip from '$lib/components/hikes/HikePhotoStrip.svelte';
|
||||||
|
import ElevationProfile from '$lib/components/hikes/ElevationProfile.svelte';
|
||||||
|
import Seo from '$lib/components/Seo.svelte';
|
||||||
|
import { setHikeContext } from '$lib/components/hikes/hikeContext.svelte';
|
||||||
|
import { listScrollAnchors } from '$lib/components/hikes/scrollAnchors';
|
||||||
|
import { setHover, clearHover } from '$lib/components/hikes/hoverStore.svelte';
|
||||||
|
import { focused, setFocused } from '$lib/components/hikes/focusedImageStore.svelte';
|
||||||
|
import Route from '@lucide/svelte/icons/route';
|
||||||
|
import Clock from '@lucide/svelte/icons/clock';
|
||||||
|
import TrendingUp from '@lucide/svelte/icons/trending-up';
|
||||||
|
import TrendingDown from '@lucide/svelte/icons/trending-down';
|
||||||
|
import ArrowUpToLine from '@lucide/svelte/icons/arrow-up-to-line';
|
||||||
|
import ArrowDownToLine from '@lucide/svelte/icons/arrow-down-to-line';
|
||||||
|
import CalendarRange from '@lucide/svelte/icons/calendar-range';
|
||||||
|
import Download from '@lucide/svelte/icons/download';
|
||||||
|
import { buildGpx, type GpxWritePoint } from '$lib/gpx';
|
||||||
|
import type { HikeTrackPoint } from '$types/hikes';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
|
const { data }: PageProps = $props();
|
||||||
|
const { hike } = data;
|
||||||
|
const MdxComponent = $derived(data.MdxComponent as unknown as typeof import('svelte').SvelteComponent);
|
||||||
|
const showPrivate = $derived(!!data.session?.user);
|
||||||
|
|
||||||
|
let track = $state<HikeTrackPoint[] | null>(null);
|
||||||
|
let trackError = $state<string | null>(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
let aborted = false;
|
||||||
|
fetch(hike.trackUrl)
|
||||||
|
.then((r) => {
|
||||||
|
if (!r.ok) throw new Error(`Track fetch failed: ${r.status}`);
|
||||||
|
return r.json() as Promise<HikeTrackPoint[]>;
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
if (!aborted) track = data;
|
||||||
|
})
|
||||||
|
.catch((err: Error) => {
|
||||||
|
if (!aborted) trackError = err.message;
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
aborted = true;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const durationLabel = $derived(
|
||||||
|
hike.durationMin !== null && hike.durationMin > 0
|
||||||
|
? `${Math.floor(hike.durationMin / 60)}h ${hike.durationMin % 60}m`
|
||||||
|
: '—'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Map SAC tier to the painted-rectangle trail-marker colour scheme used
|
||||||
|
// in Switzerland: T1 = yellow Wanderweg, T2/T3 = white-red-white
|
||||||
|
// Bergwanderweg, T4–T6 = white-blue-white Alpinwanderweg.
|
||||||
|
const sacBand = $derived.by<'yellow' | 'red' | 'blue'>(() => {
|
||||||
|
if (hike.difficulty === 'T1') return 'yellow';
|
||||||
|
if (hike.difficulty === 'T2' || hike.difficulty === 'T3') return 'red';
|
||||||
|
return 'blue';
|
||||||
|
});
|
||||||
|
|
||||||
|
const MONTHS_DE_SHORT = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'];
|
||||||
|
const seasonLabel = $derived.by(() => {
|
||||||
|
const a = hike.seasonStart;
|
||||||
|
const b = hike.seasonEnd;
|
||||||
|
if (a == null || b == null) return null;
|
||||||
|
if (a < 1 || a > 12 || b < 1 || b > 12) return null;
|
||||||
|
return `${MONTHS_DE_SHORT[a - 1]}–${MONTHS_DE_SHORT[b - 1]}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter visibility once at the page level so the map and the photo strip
|
||||||
|
// operate on the same index space — focused indexes are positions in this
|
||||||
|
// shared array.
|
||||||
|
const visibleImagePoints = $derived(
|
||||||
|
showPrivate
|
||||||
|
? hike.imagePoints
|
||||||
|
: hike.imagePoints.filter((ip) => ip.visibility !== 'private')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Expose both the full chronological list and the visibility-filtered
|
||||||
|
// list to `<HikeImage>` instances embedded in the MDX body. The track
|
||||||
|
// is exposed too so each HikeImage can resolve its timestamp to a
|
||||||
|
// track index for the scroll-progress pin.
|
||||||
|
setHikeContext(() => ({
|
||||||
|
images: hike.imagePoints,
|
||||||
|
visibleImages: visibleImagePoints,
|
||||||
|
track
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Continuous trail-position tracking. As the reader scrolls through the
|
||||||
|
// content column, we sample every registered `<HikeImage>` anchor's
|
||||||
|
// viewport position and linearly interpolate between adjacent images'
|
||||||
|
// track indices using the viewport's vertical midpoint as the cursor.
|
||||||
|
// The result is pushed into the hover store, so the sticky map's pin
|
||||||
|
// glides along the trail just like it does for chart hovers.
|
||||||
|
$effect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const mq = window.matchMedia('(min-width: 1024px)');
|
||||||
|
if (!mq.matches) return;
|
||||||
|
|
||||||
|
let frame: number | null = null;
|
||||||
|
let lastHoverIdx = -1;
|
||||||
|
let lastFocusIdx: number | null = null;
|
||||||
|
|
||||||
|
function sample(): void {
|
||||||
|
frame = null;
|
||||||
|
const anchors = listScrollAnchors();
|
||||||
|
if (anchors.length === 0) return;
|
||||||
|
// Sort by current viewport-top — that's the natural reading order
|
||||||
|
// even if a couple of images were rendered out of chronological
|
||||||
|
// sequence in the prose.
|
||||||
|
const sorted = anchors
|
||||||
|
.map((a) => ({
|
||||||
|
top: a.element.getBoundingClientRect().top,
|
||||||
|
trackIdx: a.trackIdx,
|
||||||
|
visibleIdx: a.visibleIdx
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.top - b.top);
|
||||||
|
|
||||||
|
const anchorY = window.innerHeight / 2;
|
||||||
|
let trackIdx: number;
|
||||||
|
let visibleIdx: number;
|
||||||
|
|
||||||
|
if (anchorY <= sorted[0].top) {
|
||||||
|
trackIdx = sorted[0].trackIdx;
|
||||||
|
visibleIdx = sorted[0].visibleIdx;
|
||||||
|
} else if (anchorY >= sorted[sorted.length - 1].top) {
|
||||||
|
const last = sorted[sorted.length - 1];
|
||||||
|
trackIdx = last.trackIdx;
|
||||||
|
visibleIdx = last.visibleIdx;
|
||||||
|
} else {
|
||||||
|
// Find the bracketing pair and interpolate.
|
||||||
|
let lo = sorted[0];
|
||||||
|
let hi = sorted[sorted.length - 1];
|
||||||
|
for (let i = 0; i < sorted.length - 1; i++) {
|
||||||
|
if (anchorY >= sorted[i].top && anchorY < sorted[i + 1].top) {
|
||||||
|
lo = sorted[i];
|
||||||
|
hi = sorted[i + 1];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const span = hi.top - lo.top || 1;
|
||||||
|
const frac = (anchorY - lo.top) / span;
|
||||||
|
trackIdx = Math.round(lo.trackIdx + frac * (hi.trackIdx - lo.trackIdx));
|
||||||
|
// "Nearest" image — whichever bracket endpoint we're closer to.
|
||||||
|
visibleIdx = frac < 0.5 ? lo.visibleIdx : hi.visibleIdx;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trackIdx !== lastHoverIdx) {
|
||||||
|
lastHoverIdx = trackIdx;
|
||||||
|
setHover(trackIdx, 'scroll');
|
||||||
|
}
|
||||||
|
if (visibleIdx !== lastFocusIdx && focused.source !== 'strip' && focused.source !== 'map') {
|
||||||
|
lastFocusIdx = visibleIdx;
|
||||||
|
setFocused(visibleIdx, 'inline');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onScroll(): void {
|
||||||
|
if (frame !== null) return;
|
||||||
|
frame = requestAnimationFrame(sample);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('scroll', onScroll, { passive: true });
|
||||||
|
window.addEventListener('resize', onScroll, { passive: true });
|
||||||
|
// One initial sample so the pin sits at the right place on page load.
|
||||||
|
onScroll();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', onScroll);
|
||||||
|
window.removeEventListener('resize', onScroll);
|
||||||
|
if (frame !== null) cancelAnimationFrame(frame);
|
||||||
|
// Clear the scroll-driven hover so the pin disappears if the user
|
||||||
|
// navigates away from the page.
|
||||||
|
clearHover();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Client-side GPX export of just the track (no image waypoints). Built
|
||||||
|
// from the already-loaded JSON track so we don't hit the network again.
|
||||||
|
function downloadGpx(): void {
|
||||||
|
if (!track || track.length === 0) return;
|
||||||
|
const points: GpxWritePoint[] = track.map(([lng, lat, ele, t]) => ({
|
||||||
|
lat,
|
||||||
|
lng,
|
||||||
|
altitude: typeof ele === 'number' ? ele : undefined,
|
||||||
|
timestamp: typeof t === 'number' ? t : null
|
||||||
|
}));
|
||||||
|
const gpx = buildGpx({ name: hike.title, trackPoints: points });
|
||||||
|
const blob = new Blob([gpx], { type: 'application/gpx+xml' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${hike.slug}.gpx`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Seo
|
||||||
|
title={`${hike.title} · Wanderungen`}
|
||||||
|
description={hike.summary}
|
||||||
|
ogType="article"
|
||||||
|
ogImage={hike.cover.src || undefined}
|
||||||
|
ogImageAlt={hike.cover.alt || undefined}
|
||||||
|
lang="de"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<link
|
||||||
|
rel="preload"
|
||||||
|
as="fetch"
|
||||||
|
href={hike.trackUrl}
|
||||||
|
type="application/json"
|
||||||
|
crossorigin="anonymous"
|
||||||
|
/>
|
||||||
|
<link rel="preconnect" href="https://wmts.geo.admin.ch" crossorigin="anonymous" />
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<article class="hike-detail">
|
||||||
|
<!-- The map IS the hero: the trail is the most informative thing about a
|
||||||
|
hike, so we lead with it. Title overlays at the bottom-left. A second
|
||||||
|
HikeMap further down sticks in the scroll-area; both share state via
|
||||||
|
the focusedImageStore so they animate together. -->
|
||||||
|
<section class="hero-map" style="view-transition-name: hike-{hike.slug}">
|
||||||
|
{#if track && track.length > 0}
|
||||||
|
<HikeMap {track} imagePoints={visibleImagePoints} showPrivate />
|
||||||
|
{:else if trackError}
|
||||||
|
<div class="map-fallback">Track konnte nicht geladen werden: {trackError}</div>
|
||||||
|
{:else}
|
||||||
|
<div class="map-fallback">Track wird geladen…</div>
|
||||||
|
{/if}
|
||||||
|
<div class="hero-title">
|
||||||
|
<h1>{hike.title}</h1>
|
||||||
|
{#if hike.region}
|
||||||
|
<p class="region">
|
||||||
|
{hike.region}{hike.canton && hike.canton !== hike.region ? `, ${hike.canton}` : ''}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="metrics" aria-label="Tourendaten">
|
||||||
|
{#if hike.icon}
|
||||||
|
<img class="route-icon" src={hike.icon} alt="" aria-hidden="true" />
|
||||||
|
{/if}
|
||||||
|
<div class="metric">
|
||||||
|
<Route size={20} strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
<span class="value">{hike.distanceKm.toFixed(1)}<span class="value-unit">km</span></span>
|
||||||
|
<span class="unit">Distanz</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<Clock size={20} strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
<span class="value">{durationLabel}</span>
|
||||||
|
<span class="unit">Dauer</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<TrendingUp size={20} strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
<span class="value">{hike.elevationGainM}<span class="value-unit">m</span></span>
|
||||||
|
<span class="unit">Aufstieg</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<TrendingDown size={20} strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
<span class="value">{hike.elevationLossM}<span class="value-unit">m</span></span>
|
||||||
|
<span class="unit">Abstieg</span>
|
||||||
|
</div>
|
||||||
|
{#if hike.elevationMaxM !== null}
|
||||||
|
<div class="metric">
|
||||||
|
<ArrowUpToLine size={20} strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
<span class="value">{hike.elevationMaxM}<span class="value-unit">m</span></span>
|
||||||
|
<span class="unit">höchster</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if hike.elevationMinM !== null}
|
||||||
|
<div class="metric">
|
||||||
|
<ArrowDownToLine size={20} strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
<span class="value">{hike.elevationMinM}<span class="value-unit">m</span></span>
|
||||||
|
<span class="unit">tiefster</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="metric">
|
||||||
|
<span class="sac-marker sac-marker-{sacBand}" aria-hidden="true"></span>
|
||||||
|
<span class="value">{hike.difficulty}</span>
|
||||||
|
<span class="unit">SAC</span>
|
||||||
|
</div>
|
||||||
|
{#if seasonLabel}
|
||||||
|
<div class="metric">
|
||||||
|
<CalendarRange size={20} strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
<span class="value">{seasonLabel}</span>
|
||||||
|
<span class="unit">Saison</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if track && track.length > 0}
|
||||||
|
<section class="elev-area">
|
||||||
|
<ElevationProfile {track} />
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="download-btn"
|
||||||
|
onclick={downloadGpx}
|
||||||
|
disabled={!track || track.length === 0}
|
||||||
|
title="GPX-Datei mit nur dem Track (ohne Bilder) herunterladen"
|
||||||
|
>
|
||||||
|
<Download size={16} strokeWidth={2} aria-hidden="true" />
|
||||||
|
GPX herunterladen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if track && track.length > 0 && visibleImagePoints.length > 0}
|
||||||
|
<section class="strip-area">
|
||||||
|
<HikePhotoStrip images={visibleImagePoints} {track} />
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<section class="scroll-area">
|
||||||
|
<aside class="trail-col">
|
||||||
|
{#if track && track.length > 0}
|
||||||
|
<HikeMap {track} imagePoints={visibleImagePoints} showPrivate />
|
||||||
|
<ElevationProfile {track} />
|
||||||
|
{/if}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="content-col">
|
||||||
|
<MdxComponent />
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.hike-detail {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin-inline: auto;
|
||||||
|
padding: 0 0 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hero map is full-bleed: breaks out of the centered `.hike-detail`
|
||||||
|
* container to span the entire viewport width and extends *under* the
|
||||||
|
* sticky nav (which is glass-blurred and sits above with z-index). The
|
||||||
|
* `calc(50% - 50vw)` trick stretches a child of a centered parent
|
||||||
|
* edge-to-edge; the negative top margin pulls the map back up over
|
||||||
|
* the gap that the nav's height + top-margin would otherwise leave. */
|
||||||
|
.hero-map {
|
||||||
|
position: relative;
|
||||||
|
width: 100vw;
|
||||||
|
margin-left: calc(50% - 50vw);
|
||||||
|
margin-right: calc(50% - 50vw);
|
||||||
|
margin-top: calc(-1 * (3rem + max(12px, env(safe-area-inset-top, 0px) + 4px)));
|
||||||
|
margin-bottom: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-map :global(.map) {
|
||||||
|
height: clamp(360px, 60vh, 640px);
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Push Leaflet's top-left controls (zoom +/-) below the sticky nav so
|
||||||
|
* they aren't covered on narrow viewports where the nav spans the
|
||||||
|
* full width. The bottom-right controls (layer toggle, photo toggle,
|
||||||
|
* GPS) sit clear of the nav already. */
|
||||||
|
.hero-map :global(.leaflet-top) {
|
||||||
|
top: calc(3rem + max(12px, env(safe-area-inset-top, 0px) + 4px) + 0.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-title {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
padding: 1.5rem 1.5rem 1.25rem;
|
||||||
|
background: linear-gradient(to top, rgb(0 0 0 / 0.6), transparent);
|
||||||
|
color: white;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-title h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(1.5rem, 4vw, 2.2rem);
|
||||||
|
text-shadow: 0 2px 8px rgb(0 0 0 / 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.region {
|
||||||
|
margin: 0.2rem 0 0;
|
||||||
|
opacity: 0.9;
|
||||||
|
text-shadow: 0 1px 4px rgb(0 0 0 / 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem 2.25rem;
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-icon {
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
object-fit: contain;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto auto;
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
column-gap: 0.55rem;
|
||||||
|
row-gap: 0.05rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric :global(svg) {
|
||||||
|
grid-row: 1 / span 2;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics .value {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value-unit {
|
||||||
|
font-size: 0.7em;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-left: 0.15em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics .unit {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SAC trail-marker pictograms in landscape orientation.
|
||||||
|
* T1: yellow Wegweiser-style sign with a right-pointing arrow tip.
|
||||||
|
* T2/T3: white-red-white painted Bergwanderweg marker.
|
||||||
|
* T4–T6: white-blue-white painted Alpinwanderweg marker. */
|
||||||
|
.sac-marker {
|
||||||
|
grid-row: 1 / span 2;
|
||||||
|
width: 44px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sac-marker-yellow {
|
||||||
|
width: 32px;
|
||||||
|
background: #f5a623;
|
||||||
|
/* Pentagon → flat left, arrow point on the right, like a Swiss
|
||||||
|
* hiking-trail Wegweiser. Clip-path overrides any border so the
|
||||||
|
* outline is supplied by filter: drop-shadow instead. */
|
||||||
|
clip-path: polygon(0 0, 75% 0, 100% 50%, 75% 100%, 0 100%);
|
||||||
|
filter: drop-shadow(0 0 0.5px rgb(0 0 0 / 0.45));
|
||||||
|
}
|
||||||
|
|
||||||
|
.sac-marker-red {
|
||||||
|
border-radius: 2px;
|
||||||
|
box-shadow: 0 0 0 1px rgb(0 0 0 / 0.18);
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
#fff 0 25%,
|
||||||
|
#dc1d2a 25% 75%,
|
||||||
|
#fff 75% 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sac-marker-blue {
|
||||||
|
border-radius: 2px;
|
||||||
|
box-shadow: 0 0 0 1px rgb(0 0 0 / 0.18);
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
#fff 0 25%,
|
||||||
|
#2965c8 25% 75%,
|
||||||
|
#fff 75% 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 1rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 0.5rem 1.1rem;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
color var(--transition-fast),
|
||||||
|
border-color var(--transition-fast),
|
||||||
|
transform var(--transition-fast),
|
||||||
|
box-shadow var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-btn:hover:not(:disabled) {
|
||||||
|
color: var(--color-primary);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-btn:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-btn :global(svg) {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.elev-area {
|
||||||
|
padding: 0 1rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-area {
|
||||||
|
padding-inline: 1rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-area {
|
||||||
|
padding-inline: 1rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile: the hero map at the top is the only map; the secondary sticky
|
||||||
|
* map (and the elevation profile that lived next to it) are redundant
|
||||||
|
* since there's no scrollytelling without the two-column layout. */
|
||||||
|
.trail-col {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trail-col,
|
||||||
|
.content-col {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-col {
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-col :global(p) {
|
||||||
|
margin: 0 0 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-col :global(h2) {
|
||||||
|
margin: 2rem 0 0.75rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-col :global(blockquote) {
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
border-left: 3px solid var(--color-primary);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Desktop scrollytelling: a sticky trail column on the left holding a
|
||||||
|
* smaller copy of the map + elevation profile, with prose + inline
|
||||||
|
* images flowing on the right. Below 1024 px the columns stack and the
|
||||||
|
* trail loses its stickiness.
|
||||||
|
*
|
||||||
|
* For `position: sticky` to actually engage, the grid item's own height
|
||||||
|
* must be smaller than the row's resolved height — `align-self: start`
|
||||||
|
* stops the grid from stretching the cell to the row's full height
|
||||||
|
* (which would otherwise leave no scroll room for the sticky to move
|
||||||
|
* against). The trail-col contains only the secondary map + elevation
|
||||||
|
* here (the strip lives above, the photos inline), so it stays short. */
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.scroll-area {
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
gap: 2.5rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trail-col {
|
||||||
|
display: block;
|
||||||
|
position: sticky;
|
||||||
|
/* The global nav is itself sticky (3 rem tall, ~12 px top offset),
|
||||||
|
* so anchor the map below it with a small breathing gap. */
|
||||||
|
top: calc(3rem + max(12px, env(safe-area-inset-top, 0px) + 4px) + 0.75rem);
|
||||||
|
align-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trail-col :global(.map) {
|
||||||
|
height: 400px;
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trail-col :global(.elevation) {
|
||||||
|
height: 180px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-fallback {
|
||||||
|
padding: 4rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import { HIKES } from '$lib/data/hikes.generated';
|
||||||
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
|
// Not prerendered: the page needs the live session so private images can be
|
||||||
|
// gated behind login. Performance hit is small — the page is mostly hashed
|
||||||
|
// static assets (track JSON, image variants).
|
||||||
|
|
||||||
|
// Glob the .svx modules so Vite can pre-bundle them and we can resolve
|
||||||
|
// the matching one synchronously at load time.
|
||||||
|
const mdxModules = import.meta.glob<{ default: unknown; metadata?: Record<string, unknown> }>(
|
||||||
|
'/src/content/hikes/*/index.svx'
|
||||||
|
);
|
||||||
|
|
||||||
|
export const load: PageLoad = async ({ params }) => {
|
||||||
|
const hike = HIKES.find((h) => h.slug === params.slug);
|
||||||
|
if (!hike) throw error(404, 'Hike not found');
|
||||||
|
|
||||||
|
const modPath = `/src/content/hikes/${params.slug}/index.svx`;
|
||||||
|
const loader = mdxModules[modPath];
|
||||||
|
if (!loader) throw error(404, 'Hike content missing');
|
||||||
|
|
||||||
|
const mod = await loader();
|
||||||
|
|
||||||
|
return {
|
||||||
|
hike,
|
||||||
|
MdxComponent: mod.default,
|
||||||
|
mdxMetadata: mod.metadata ?? {}
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import { dev } from '$app/environment';
|
||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gates `/hikes/<slug>/private/<file>` image requests behind an authenticated
|
||||||
|
* session.
|
||||||
|
*
|
||||||
|
* Production: returns `X-Accel-Redirect` so nginx serves the bytes from
|
||||||
|
* `/var/www/static/hikes/<slug>/private/<file>` via its `internal` location
|
||||||
|
* `/protected-hikes/`. Node never touches the file.
|
||||||
|
*
|
||||||
|
* Dev (`vite dev`): no nginx in front, so X-Accel-Redirect would yield an
|
||||||
|
* empty 200. Read the file from the local `hikes-assets/` tree and stream
|
||||||
|
* it back. Auth check is identical either way.
|
||||||
|
*/
|
||||||
|
const MIME: Record<string, string> = {
|
||||||
|
'.avif': 'image/avif',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.png': 'image/png'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ locals, params }) => {
|
||||||
|
const session = locals.session ?? (await locals.auth());
|
||||||
|
if (!session?.user) {
|
||||||
|
throw error(401, 'Authentication required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { slug, file } = params;
|
||||||
|
// SvelteKit's `[file]` matcher excludes `/`, but reject literal `..` to be
|
||||||
|
// defensive if the route ever gets mounted under a different matcher.
|
||||||
|
if (!slug || !file || file.includes('/') || file.includes('..') || slug.includes('..')) {
|
||||||
|
throw error(400, 'Bad request.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dev) {
|
||||||
|
const filePath = path.join(process.cwd(), 'hikes-assets', slug, 'private', file);
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(filePath);
|
||||||
|
const mime = MIME[path.extname(file).toLowerCase()] ?? 'application/octet-stream';
|
||||||
|
return new Response(new Uint8Array(data), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': mime,
|
||||||
|
'Cache-Control': 'private, max-age=60'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw error(404, 'Image not found.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(null, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
// nginx replaces the body with the file contents; the `Content-Type`
|
||||||
|
// nginx sets at the `/protected-hikes/` location wins, so no point
|
||||||
|
// guessing one here. Cache headers there govern downstream caching.
|
||||||
|
'X-Accel-Redirect': `/protected-hikes/${slug}/private/${file}`,
|
||||||
|
'Cache-Control': 'no-store'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: LayoutServerLoad = async ({ locals, url }) => {
|
||||||
|
const session = locals.session ?? (await locals.auth());
|
||||||
|
if (!session?.user) {
|
||||||
|
throw redirect(303, `/login?callbackUrl=${encodeURIComponent(url.pathname + url.search)}`);
|
||||||
|
}
|
||||||
|
return { session };
|
||||||
|
};
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
import Seo from '$lib/components/Seo.svelte';
|
||||||
|
import EditMap from '$lib/components/hikes/route-builder/EditMap.svelte';
|
||||||
|
import WaypointTable from '$lib/components/hikes/route-builder/WaypointTable.svelte';
|
||||||
|
import ImageDropzone from '$lib/components/hikes/route-builder/ImageDropzone.svelte';
|
||||||
|
import { assembleTrackPoints, buildGpx, type GpxImageWaypoint } from '$lib/gpx';
|
||||||
|
import {
|
||||||
|
builder,
|
||||||
|
setRoutedSegments,
|
||||||
|
setElevations,
|
||||||
|
clearDraft,
|
||||||
|
reconcileSegments
|
||||||
|
} from '$lib/components/hikes/route-builder/builderStore.svelte';
|
||||||
|
|
||||||
|
let busy = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let routeRequestId = 0;
|
||||||
|
|
||||||
|
async function snapToRoute() {
|
||||||
|
const placed = builder.waypoints.filter((w) => !w.unplaced);
|
||||||
|
if (placed.length < 2) {
|
||||||
|
setRoutedSegments([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reqId = ++routeRequestId;
|
||||||
|
busy = true;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/hikes/route-builder/route', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
waypoints: placed.map((w) => ({ lat: w.lat, lng: w.lng })),
|
||||||
|
profile: builder.profile
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (reqId !== routeRequestId) return; // stale response
|
||||||
|
if (!res.ok) {
|
||||||
|
error = `Routing fehlgeschlagen (HTTP ${res.status}).`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
segments: Array<Array<[number, number, number?]>>;
|
||||||
|
source: string;
|
||||||
|
};
|
||||||
|
if (reqId !== routeRequestId) return;
|
||||||
|
setRoutedSegments(data.segments);
|
||||||
|
|
||||||
|
// If routing didn't return elevations, enrich via Swisstopo.
|
||||||
|
const flat = data.segments.flat();
|
||||||
|
const needsElevation = flat.some((p) => typeof p[2] !== 'number');
|
||||||
|
if (needsElevation) {
|
||||||
|
const elevRes = await fetch('/api/hikes/route-builder/elevation', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ coordinates: flat.map((p) => [p[0], p[1]]) })
|
||||||
|
});
|
||||||
|
if (reqId !== routeRequestId) return;
|
||||||
|
if (elevRes.ok) {
|
||||||
|
const elevData = (await elevRes.json()) as { elevations: (number | null)[] };
|
||||||
|
setElevations(elevData.elevations);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (reqId !== routeRequestId) return;
|
||||||
|
error = (err as Error).message;
|
||||||
|
} finally {
|
||||||
|
if (reqId === routeRequestId) busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reactor: any structural change (waypoints, profile, autoSnap toggle)
|
||||||
|
// reconciles `routedSegments` against the current waypoint list first —
|
||||||
|
// matching pairs keep their snapped data, new pairs get a straight-line
|
||||||
|
// placeholder. After that, if autoSnap is on, fire a debounced API call to
|
||||||
|
// refresh; server-side per-pair caching makes cached pairs free.
|
||||||
|
let snapDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
$effect(() => {
|
||||||
|
// Track waypoint structure + flags as deps.
|
||||||
|
builder.profile;
|
||||||
|
builder.autoSnap;
|
||||||
|
builder.waypoints.length;
|
||||||
|
for (const w of builder.waypoints) {
|
||||||
|
w.id; w.lat; w.lng;
|
||||||
|
}
|
||||||
|
|
||||||
|
// All side-effects that read/write `routedSegments` and `segmentSourceIds`
|
||||||
|
// must be untracked — otherwise the effect's own writes would feed back
|
||||||
|
// into its dependency set and trigger an update loop.
|
||||||
|
untrack(() => {
|
||||||
|
reconcileSegments();
|
||||||
|
|
||||||
|
if (snapDebounce) clearTimeout(snapDebounce);
|
||||||
|
if (builder.autoSnap) {
|
||||||
|
snapDebounce = setTimeout(() => snapToRoute(), 250);
|
||||||
|
} else {
|
||||||
|
// Keep whatever was already snapped. Cancel any in-flight request so
|
||||||
|
// a late response doesn't overwrite the linear placeholders we just
|
||||||
|
// reconciled.
|
||||||
|
routeRequestId++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function downloadGpx() {
|
||||||
|
const unplaced = builder.waypoints.filter((w) => w.unplaced);
|
||||||
|
if (unplaced.length > 0) {
|
||||||
|
error = `${unplaced.length} Bild${unplaced.length === 1 ? '' : 'er'} ohne Position. Bitte in der Wegpunkt-Liste platzieren oder entfernen.`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const placed = builder.waypoints.filter((w) => !w.unplaced);
|
||||||
|
const assembled = assembleTrackPoints({
|
||||||
|
waypoints: placed.map((w) => ({
|
||||||
|
lat: w.lat,
|
||||||
|
lng: w.lng,
|
||||||
|
altitude: w.altitude,
|
||||||
|
timestamp: w.timestamp ?? null
|
||||||
|
})),
|
||||||
|
routedSegments: builder.routedSegments
|
||||||
|
});
|
||||||
|
if (!assembled.ok) {
|
||||||
|
error = assembled.error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
error = null;
|
||||||
|
// Look up altitude for a placed-waypoint index from the routed segments.
|
||||||
|
// Mirrors the trkpt fallback in `assembleTrackPoints`: image waypoints
|
||||||
|
// almost never carry their own `altitude` (EXIF GPSAltitude is too noisy
|
||||||
|
// to trust), so without this fallback the GPX `<wpt>` would emit no
|
||||||
|
// `<ele>` and downstream tools render it as 0.
|
||||||
|
function routedAltitudeAt(idx: number): number | undefined {
|
||||||
|
const segs = builder.routedSegments;
|
||||||
|
const startSeg = segs[idx];
|
||||||
|
if (startSeg && startSeg.length > 0 && typeof startSeg[0][2] === 'number') {
|
||||||
|
return startSeg[0][2];
|
||||||
|
}
|
||||||
|
const prevSeg = idx > 0 ? segs[idx - 1] : undefined;
|
||||||
|
if (prevSeg && prevSeg.length > 0) {
|
||||||
|
const last = prevSeg[prevSeg.length - 1];
|
||||||
|
if (typeof last[2] === 'number') return last[2];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const imageWaypoints: GpxImageWaypoint[] = placed
|
||||||
|
.map((w, idx) => ({ w, idx }))
|
||||||
|
.filter(
|
||||||
|
(e): e is { w: typeof e.w & { imageHash: string }; idx: number } =>
|
||||||
|
typeof e.w.imageHash === 'string'
|
||||||
|
)
|
||||||
|
.map(({ w, idx }) => ({
|
||||||
|
lat: w.lat,
|
||||||
|
lng: w.lng,
|
||||||
|
altitude: typeof w.altitude === 'number' ? w.altitude : routedAltitudeAt(idx),
|
||||||
|
timestamp: w.timestamp ?? null,
|
||||||
|
hash: w.imageHash,
|
||||||
|
visibility: w.imageVisibility === 'private' ? 'private' : 'public'
|
||||||
|
}));
|
||||||
|
const gpx = buildGpx({
|
||||||
|
name: builder.name || 'Neue Wanderung',
|
||||||
|
trackPoints: assembled.points,
|
||||||
|
imageWaypoints
|
||||||
|
});
|
||||||
|
const blob = new Blob([gpx], { type: 'application/gpx+xml' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const slug = (builder.name || 'route').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '') || 'route';
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${slug}.gpx`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Placement coordination: which unplaced waypoint is currently waiting for
|
||||||
|
// a click on the map?
|
||||||
|
let pendingPlacementId = $state<string | null>(null);
|
||||||
|
|
||||||
|
function startPlacement(waypointId: string) {
|
||||||
|
pendingPlacementId = waypointId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelPlacement() {
|
||||||
|
pendingPlacementId = null;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Seo title="Routen-Builder · Wanderungen" description="Eigene Wanderrouten erstellen, exportieren und teilen." lang="de" />
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
|
<link rel="preconnect" href="https://wmts.geo.admin.ch" crossorigin="anonymous" />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<section class="builder">
|
||||||
|
<header class="header">
|
||||||
|
<input
|
||||||
|
class="name-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="Name der Tour…"
|
||||||
|
bind:value={builder.name}
|
||||||
|
/>
|
||||||
|
<div class="actions">
|
||||||
|
<select bind:value={builder.profile} disabled={!builder.autoSnap}>
|
||||||
|
<option value="hiking-mountain">Wandern (Berg)</option>
|
||||||
|
<option value="trekking">Trekking</option>
|
||||||
|
<option value="road">Strasse</option>
|
||||||
|
</select>
|
||||||
|
<label class="snap-toggle" class:active={builder.autoSnap}>
|
||||||
|
<input type="checkbox" bind:checked={builder.autoSnap} />
|
||||||
|
<span>Auf Wege snappen{busy ? ' …' : ''}</span>
|
||||||
|
</label>
|
||||||
|
<button type="button" class="primary" onclick={downloadGpx}>
|
||||||
|
GPX herunterladen
|
||||||
|
</button>
|
||||||
|
<button type="button" class="link" onclick={clearDraft}>Zurücksetzen</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<p class="err">{error}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div class="map-col">
|
||||||
|
<EditMap
|
||||||
|
{pendingPlacementId}
|
||||||
|
onPlacementCancel={cancelPlacement}
|
||||||
|
onPlacementComplete={cancelPlacement}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="side">
|
||||||
|
<WaypointTable
|
||||||
|
{pendingPlacementId}
|
||||||
|
onRequestPlacement={startPlacement}
|
||||||
|
onCancelPlacement={cancelPlacement}
|
||||||
|
/>
|
||||||
|
<ImageDropzone />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="hint">
|
||||||
|
Tipp: Klicke auf die Karte, um Wegpunkte hinzuzufügen. Bilder mit GPS-EXIF werden
|
||||||
|
automatisch als Wegpunkte verwendet. Der GPX-Export bleibt lokal — eine Veröffentlichung
|
||||||
|
als Wandereintrag erfordert einen Commit der Dateien unter
|
||||||
|
<code>src/content/hikes/<slug>/</code>.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.builder {
|
||||||
|
max-width: 1300px;
|
||||||
|
margin-inline: auto;
|
||||||
|
padding: 1rem 1rem 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-input {
|
||||||
|
flex: 1 1 240px;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions select,
|
||||||
|
.actions button {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 0.45rem 0.9rem;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions button.primary {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-text-on-primary);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions button.link {
|
||||||
|
background: transparent;
|
||||||
|
border-color: transparent;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions button:disabled,
|
||||||
|
.actions select:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snap-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 0.45rem 0.9rem;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snap-toggle.active {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-text-on-primary);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.snap-toggle input {
|
||||||
|
accent-color: var(--color-primary);
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.5fr) minmax(280px, 1fr);
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.err {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: color-mix(in oklab, var(--red) 12%, transparent);
|
||||||
|
color: var(--red);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
padding: 0.1em 0.35em;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// SAC hiking difficulty scale.
|
||||||
|
export type Difficulty = 'T1' | 'T2' | 'T3' | 'T4' | 'T5' | 'T6';
|
||||||
|
|
||||||
|
export type ImageVariant = {
|
||||||
|
src: string;
|
||||||
|
srcsetAvif: string;
|
||||||
|
srcsetWebp: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
dominantColor?: string;
|
||||||
|
alt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ImagePoint = {
|
||||||
|
src: string; // largest WebP delivered as the popup
|
||||||
|
thumbnail: string; // 240w WebP used inside the on-map popup
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
altitude?: number;
|
||||||
|
timestamp?: number; // unix ms from EXIF DateTimeOriginal
|
||||||
|
alt: string;
|
||||||
|
/** `'private'` images are hidden from anonymous viewers; logged-in users
|
||||||
|
* still see them. Omitted == public. */
|
||||||
|
visibility?: 'public' | 'private';
|
||||||
|
};
|
||||||
|
|
||||||
|
// [lng, lat, elevation?, unixMs?]
|
||||||
|
export type HikeTrackPoint = [number, number, number?, number?];
|
||||||
|
|
||||||
|
export type HikeManifestEntry = {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
date: string; // ISO date
|
||||||
|
summary: string;
|
||||||
|
author?: string;
|
||||||
|
tags: string[];
|
||||||
|
difficulty: Difficulty;
|
||||||
|
hidden?: boolean;
|
||||||
|
|
||||||
|
// Derived from GPX:
|
||||||
|
distanceKm: number;
|
||||||
|
durationMin: number | null;
|
||||||
|
elevationGainM: number;
|
||||||
|
elevationLossM: number;
|
||||||
|
/** Highest / lowest defined trkpt altitude in metres. `null` when no trkpt
|
||||||
|
* carries an `<ele>` value at all. */
|
||||||
|
elevationMaxM: number | null;
|
||||||
|
elevationMinM: number | null;
|
||||||
|
bbox: [number, number, number, number]; // [minLat, minLng, maxLat, maxLng]
|
||||||
|
centroid: [number, number];
|
||||||
|
previewPolyline: [number, number][];
|
||||||
|
|
||||||
|
// Reverse-geocoded from the centroid (Swisstopo):
|
||||||
|
region: string | null;
|
||||||
|
canton: string | null;
|
||||||
|
municipality: string | null;
|
||||||
|
|
||||||
|
// Recommended hiking-season window, 1-12 (Jan-Dec). When start > end the
|
||||||
|
// window wraps the new year (e.g. 11–3 for a winter route). Absent /
|
||||||
|
// null on both ends means no recommendation / year-round. Optional so
|
||||||
|
// older manifest entries (built before this field existed) still type-
|
||||||
|
// check without forcing a rebuild.
|
||||||
|
seasonStart?: number | null;
|
||||||
|
seasonEnd?: number | null;
|
||||||
|
|
||||||
|
// Track + cover:
|
||||||
|
trackUrl: string; // /hikes/<slug>/track.<hash>.json
|
||||||
|
pointCount: number;
|
||||||
|
cover: ImageVariant;
|
||||||
|
|
||||||
|
/** Optional per-route icon (URL under `/hikes/<slug>/`). Sourced from
|
||||||
|
* `icon.svg` / `icon.png` / `icon.jpg` / `icon.jpeg` / `icon.webp` in
|
||||||
|
* the hike's content directory. SVG passes through; raster is re-encoded
|
||||||
|
* to a small WebP. */
|
||||||
|
icon?: string;
|
||||||
|
|
||||||
|
// Geo-tagged photos shown as map markers on the detail page:
|
||||||
|
imagePoints: ImagePoint[];
|
||||||
|
};
|
||||||
+11
-3
@@ -1,11 +1,19 @@
|
|||||||
import adapter from '@sveltejs/adapter-node';
|
import adapter from '@sveltejs/adapter-node';
|
||||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
import { mdsvex } from 'mdsvex';
|
||||||
|
|
||||||
/** @type {import('@sveltejs/kit').Config} */
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
const config = {
|
const config = {
|
||||||
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
|
extensions: ['.svelte', '.svx'],
|
||||||
// for more information about preprocessors
|
preprocess: [
|
||||||
preprocess: [vitePreprocess()],
|
vitePreprocess(),
|
||||||
|
mdsvex({
|
||||||
|
extensions: ['.svx'],
|
||||||
|
layout: {
|
||||||
|
hike: 'src/lib/components/hikes/HikeMdxLayout.svelte'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
],
|
||||||
kit: {
|
kit: {
|
||||||
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
|
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
|
||||||
// If your environment is not supported or you settled on a specific environment, switch out the adapter.
|
// If your environment is not supported or you settled on a specific environment, switch out the adapter.
|
||||||
|
|||||||
+49
-2
@@ -1,5 +1,49 @@
|
|||||||
import { sveltekit } from '@sveltejs/kit/vite';
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig, type Plugin } from 'vite';
|
||||||
|
import { createReadStream, promises as fs } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
/** In `vite dev`, hike image binaries live in `hikes-assets/` (outside `/static`
|
||||||
|
* so they aren't bundled into the Node build). In production nginx serves them
|
||||||
|
* directly from `/var/www/static/hikes/`; the SvelteKit dev server has no
|
||||||
|
* nginx in front, so we intercept `/hikes/<slug>/images/<file>` here and
|
||||||
|
* stream the file off disk. Private images (`/hikes/<slug>/private/<file>`)
|
||||||
|
* intentionally fall through to the SvelteKit endpoint, which enforces auth. */
|
||||||
|
function hikeImagesDevPlugin(): Plugin {
|
||||||
|
const ROOT = path.resolve(process.cwd(), 'hikes-assets');
|
||||||
|
const MIME: Record<string, string> = {
|
||||||
|
'.avif': 'image/avif',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.svg': 'image/svg+xml'
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
name: 'hike-images-dev',
|
||||||
|
apply: 'serve',
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use(async (req, res, next) => {
|
||||||
|
const url = req.url ?? '';
|
||||||
|
const m = url.match(/^\/hikes\/([^/]+)\/images\/([^/?#]+)(?:[?#].*)?$/);
|
||||||
|
if (!m) return next();
|
||||||
|
const [, slug, file] = m;
|
||||||
|
if (slug.includes('..') || file.includes('..')) return next();
|
||||||
|
const filePath = path.join(ROOT, slug, 'images', file);
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(filePath);
|
||||||
|
const mime = MIME[path.extname(file).toLowerCase()] ?? 'application/octet-stream';
|
||||||
|
res.setHeader('Content-Type', mime);
|
||||||
|
res.setHeader('Content-Length', String(stat.size));
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||||
|
createReadStream(filePath).pipe(res);
|
||||||
|
} catch {
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
css: {
|
css: {
|
||||||
@@ -14,7 +58,7 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
allowedHosts: ["bocken.org"]
|
allowedHosts: ["bocken.org"]
|
||||||
},
|
},
|
||||||
plugins: [sveltekit()],
|
plugins: [hikeImagesDevPlugin(), sveltekit()],
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
exclude: ['barcode-detector']
|
exclude: ['barcode-detector']
|
||||||
},
|
},
|
||||||
@@ -33,6 +77,9 @@ export default defineConfig({
|
|||||||
if (id.includes('barcode-detector') || id.includes('zxing-wasm')) {
|
if (id.includes('barcode-detector') || id.includes('zxing-wasm')) {
|
||||||
return 'barcode';
|
return 'barcode';
|
||||||
}
|
}
|
||||||
|
if (id.includes('/leaflet/')) {
|
||||||
|
return 'leaflet';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user