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:
2026-05-18 21:13:00 +02:00
parent 928774084f
commit f3d16d5187
52 changed files with 8817 additions and 103 deletions
+15
View File
@@ -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
+54 -4
View File
@@ -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
View File
@@ -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",
+86
View File
@@ -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
+846
View File
@@ -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
View File
@@ -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
View File
@@ -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
); );
+656
View File
@@ -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>
+326
View File
@@ -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,
// T4T6 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>
+137
View File
@@ -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>
+810
View File
@@ -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 = '&copy; <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 (18451864): 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, '&quot;');
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 =
'&copy; <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 =
'&copy; <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);
});
}
+37
View 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
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/** 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>
`;
}
+4
View File
@@ -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'
+4
View File
@@ -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'
+15
View File
@@ -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('');
}
+119
View File
@@ -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;
}
+375
View File
@@ -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 };
}
+5
View File
@@ -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;
} }
} }
+7
View File
@@ -0,0 +1,7 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
return {
session: locals.session ?? (await locals.auth())
};
};
+44
View File
@@ -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>
+220
View File
@@ -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>
+7
View File
@@ -0,0 +1,7 @@
import { HIKES } from '$lib/data/hikes.generated';
export const prerender = true;
export const load = () => ({
hikes: HIKES.filter((h) => !h.hidden)
});
+635
View File
@@ -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, T4T6 = 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.
* T4T6: 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>
+30
View File
@@ -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 };
};
+373
View File
@@ -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/&lt;slug&gt;/</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>
+79
View File
@@ -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. 113 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
View File
@@ -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
View File
@@ -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';
}
} }
} }
} }