Compare commits
68 Commits
5fd8027d3e
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
467f9a4e71
|
|||
|
8bd794bccb
|
|||
|
f52d6b4d4b
|
|||
|
9b5cfe5e49
|
|||
|
9fe9d95e36
|
|||
|
cd7912fa8f
|
|||
|
fb54f6907f
|
|||
|
94c8212078
|
|||
|
ac76bfba34
|
|||
|
0f6c50f854
|
|||
|
8a67f5fba8
|
|||
|
b49a299371
|
|||
|
f1c0304b14
|
|||
|
164fdb2916
|
|||
|
ae4adc4023
|
|||
|
17ccfa1b41
|
|||
|
dfc3142eeb
|
|||
|
fdea8416a0
|
|||
|
399d57217a
|
|||
|
0e70e30738
|
|||
|
1918d240db
|
|||
|
316a340494
|
|||
|
59b4630746
|
|||
|
8459327717
|
|||
|
a4c2efe4f3
|
|||
|
cb16b25444
|
|||
|
583d1b724c
|
|||
|
3fc539e6fb
|
|||
|
c2862f4c21
|
|||
|
38c3df8187
|
|||
|
530308033b
|
|||
|
c155fc33b4
|
|||
|
a2869c1d87
|
|||
|
a8902dcf11
|
|||
|
c9b2773de4
|
|||
|
909b02049d
|
|||
|
d4a8288ecf
|
|||
|
4114b0109f
|
|||
|
169f8798f3
|
|||
|
8f843833e0
|
|||
|
35872d731a
|
|||
|
2347a02fcb
|
|||
|
5540d37c72
|
|||
|
6483c55fce
|
|||
|
603240bf93
|
|||
|
53695b8244
|
|||
|
48d971c216
|
|||
|
bb1d494c48
|
|||
|
896e42f5d9
|
|||
|
7bede8cd64
|
|||
|
3b524e9c70
|
|||
|
59f40b9f05
|
|||
|
7b7fbed472
|
|||
|
e3ccd96c7b
|
|||
|
a1aa722512
|
|||
|
706dedbdc5
|
|||
|
2a8721fde0
|
|||
|
3331536ddd
|
|||
|
d957c746d5
|
|||
|
cfdd58fb18
|
|||
|
2c3886296c
|
|||
|
c082da700d
|
|||
|
fe08e06a02
|
|||
|
fd2d8a58d9
|
|||
|
f3d16d5187
|
|||
|
928774084f
|
|||
|
8c09b0b2f4
|
|||
|
5ac56db46c
|
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Pre-commit: normalise hike track altitudes.
|
||||
#
|
||||
# Any added/modified src/content/hikes/<slug>/track.gpx is run through
|
||||
# scripts/fix-altitudes.ts (swisstopo swissALTI3D heights at each exact point)
|
||||
# and re-staged, so committed tracks always carry corrected elevation instead of
|
||||
# raw phone-GPS noise. Commits that don't touch a track.gpx are a fast no-op.
|
||||
#
|
||||
# Network failures degrade gracefully: fix-altitudes keeps a point's original
|
||||
# elevation when it can't resolve it, exits 0, and the commit proceeds.
|
||||
#
|
||||
# Caveat: a touched track.gpx is re-staged in full, so partial (`git add -p`)
|
||||
# staging of a track.gpx won't survive. These files are generated, so that's fine.
|
||||
set -euo pipefail
|
||||
|
||||
# Staged Added/Copied/Modified track.gpx paths, NUL-delimited so non-ASCII slug
|
||||
# dirs (e.g. "…pfäffikersee") come through as raw bytes, unquoted.
|
||||
files=()
|
||||
while IFS= read -r -d '' f; do
|
||||
case "$f" in
|
||||
src/content/hikes/*/track.gpx) files+=("$f") ;;
|
||||
esac
|
||||
done < <(git diff --cached --name-only -z --diff-filter=ACM -- src/content/hikes)
|
||||
|
||||
if [ ${#files[@]} -eq 0 ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Map each path to its <slug> (the directory under src/content/hikes/).
|
||||
slugs=()
|
||||
for f in "${files[@]}"; do
|
||||
s=${f#src/content/hikes/}
|
||||
slugs+=("${s%/track.gpx}")
|
||||
done
|
||||
|
||||
echo "[pre-commit] fix-altitudes: ${slugs[*]}"
|
||||
pnpm exec vite-node scripts/fix-altitudes.ts "${slugs[@]}"
|
||||
|
||||
# Re-stage so the corrected elevations are what actually gets committed.
|
||||
git add -- "${files[@]}"
|
||||
@@ -7,6 +7,7 @@ node_modules
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
.env_*
|
||||
!.env.example
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
@@ -15,6 +16,34 @@ data/usda/
|
||||
# Loyalty-card barcodes (regenerated by scripts/generate-loyalty-cards.ts from env)
|
||||
static/shopping/supercard.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
|
||||
# Tile-proxy build artefacts + secrets (the source tree itself is tracked).
|
||||
# The binary is dropped next to Cargo.toml by `make build`; its name happens
|
||||
# to collide with the directory it lives in, so the path is fully qualified
|
||||
# here to avoid the nested-gitignore quirk that previously hid the source.
|
||||
/tile-proxy/tile-proxy
|
||||
/tile-proxy/target/
|
||||
/tile-proxy/.env
|
||||
# Private image build outputs (regenerated by scripts/build-private-images.ts).
|
||||
# Sources are private + large, so they're ignored too — only the README is kept.
|
||||
private-assets/
|
||||
src/lib/data/privateImages.generated.ts
|
||||
src/lib/assets/private-images/*
|
||||
!src/lib/assets/private-images/README.md
|
||||
# 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/*.keystore
|
||||
# Android: ignore build output and caches, track source files
|
||||
@@ -22,3 +51,9 @@ src-tauri/gen/android/.gradle/
|
||||
src-tauri/gen/android/app/build/
|
||||
src-tauri/gen/android/buildSrc/.gradle/
|
||||
src-tauri/gen/android/buildSrc/build/
|
||||
|
||||
# Hike content: track the writing (index.svx), route (track.gpx) and icons,
|
||||
# but not the source photos (huge; re-encoded into static assets at build time).
|
||||
src/content/hikes/*/images/
|
||||
src/content/hikes/*/private/
|
||||
src/content/hikes/*/cover.*
|
||||
|
||||
@@ -173,7 +173,6 @@ Generated: 2025-11-18
|
||||
- `EditButton.svelte` - Edit button (floating)
|
||||
- `FavoriteButton.svelte` - Toggle favorite
|
||||
- `Card.svelte` (259 lines) ⚠️ Large - Recipe card with hover effects, tags, category
|
||||
- `CardAdd.svelte` - Add recipe card placeholder
|
||||
- `FormSection.svelte` - Styled form section wrapper
|
||||
- `Header.svelte` - Page header
|
||||
- `UserHeader.svelte` - User-specific header
|
||||
@@ -190,7 +189,6 @@ Generated: 2025-11-18
|
||||
|
||||
#### Recipe-Specific Components
|
||||
- `Recipes.svelte` - Recipe list display
|
||||
- `RecipeEditor.svelte` - Recipe editing form
|
||||
- `RecipeNote.svelte` - Recipe notes display
|
||||
- `EditRecipe.svelte` - Edit recipe modal
|
||||
- `EditRecipeNote.svelte` - Edit recipe notes
|
||||
|
||||
@@ -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] 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?)
|
||||
[ ] 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
|
||||
[ ] coop and migros cards on shopping list for scanning
|
||||
[ ] login icon from lucide in header
|
||||
[x] on /fitness/check-in, Make the Period ended button a lot more prominent in the period tracker component.
|
||||
[x] swap heart emoji on recipe favorites to lucide icon
|
||||
[x] coop and migros cards on shopping list for scanning
|
||||
[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
|
||||
|
||||
@@ -39,3 +47,45 @@ Refactor `src/lib/components/Search.svelte` to use the new `SearchInput.svelte`
|
||||
Files involved:
|
||||
- `src/lib/components/Search.svelte` - refactor to use SearchInput
|
||||
- `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.
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"name": "homepage",
|
||||
"version": "1.69.5",
|
||||
"version": "1.96.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"prepare": "git config core.hooksPath .githooks || true",
|
||||
"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 && pnpm exec vite-node scripts/build-private-images.ts",
|
||||
"build": "vite build",
|
||||
"postbuild": "pnpm exec vite-node scripts/build-error-page.ts",
|
||||
"postbuild": "pnpm exec vite-node scripts/build-error-page.ts && UV_THREADPOOL_SIZE=12 pnpm exec vite-node scripts/precompress.ts",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
@@ -23,12 +24,15 @@
|
||||
"test:e2e:docker:run": "docker run --rm --network host -v $(pwd):/app -w /app -e CI=true mcr.microsoft.com/playwright:v1.56.1-noble /bin/bash -c 'npm install -g pnpm@9.0.0 && pnpm install --frozen-lockfile && pnpm run build && pnpm exec playwright test --project=chromium'",
|
||||
"deploy": "bash scripts/deploy.sh",
|
||||
"deploy:dry": "bash scripts/deploy.sh --dry-run",
|
||||
"photos:push": "bash scripts/hike-photos.sh push",
|
||||
"photos:pull": "bash scripts/hike-photos.sh pull",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"packageManager": "pnpm@9.0.0",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.56.1",
|
||||
"@sveltejs/adapter-auto": "^7.0.1",
|
||||
"@sveltejs/enhanced-img": "^0.10.4",
|
||||
"@sveltejs/kit": "^2.56.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"@tauri-apps/cli": "^2.10.1",
|
||||
@@ -40,6 +44,7 @@
|
||||
"@vitest/ui": "^4.1.2",
|
||||
"bwip-js": "^4.10.1",
|
||||
"jsdom": "^27.2.0",
|
||||
"mdsvex": "^0.12.7",
|
||||
"svelte": "^5.55.1",
|
||||
"svelte-check": "^4.4.6",
|
||||
"tslib": "^2.8.1",
|
||||
@@ -59,6 +64,7 @@
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-adapter-date-fns": "^3.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"exifr": "^7.1.3",
|
||||
"file-type": "^19.0.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"mongoose": "^9.4.1",
|
||||
|
||||
@@ -38,6 +38,9 @@ importers:
|
||||
date-fns:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0
|
||||
exifr:
|
||||
specifier: ^7.1.3
|
||||
version: 7.1.3
|
||||
file-type:
|
||||
specifier: ^19.0.0
|
||||
version: 19.6.0
|
||||
@@ -66,6 +69,9 @@ importers:
|
||||
'@sveltejs/adapter-auto':
|
||||
specifier: ^7.0.1
|
||||
version: 7.0.1(@sveltejs/kit@2.56.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0)))(svelte@5.55.1)(typescript@6.0.2)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0)))
|
||||
'@sveltejs/enhanced-img':
|
||||
specifier: ^0.10.4
|
||||
version: 0.10.4(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0)))(rollup@4.60.1)(svelte@5.55.1)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0))
|
||||
'@sveltejs/kit':
|
||||
specifier: ^2.56.1
|
||||
version: 2.56.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0)))(svelte@5.55.1)(typescript@6.0.2)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0))
|
||||
@@ -99,6 +105,9 @@ importers:
|
||||
jsdom:
|
||||
specifier: ^27.2.0
|
||||
version: 27.2.0
|
||||
mdsvex:
|
||||
specifier: ^0.12.7
|
||||
version: 0.12.7(svelte@5.55.1)
|
||||
svelte:
|
||||
specifier: ^5.55.1
|
||||
version: 5.55.1
|
||||
@@ -919,6 +928,13 @@ packages:
|
||||
peerDependencies:
|
||||
'@sveltejs/kit': ^2.4.0
|
||||
|
||||
'@sveltejs/enhanced-img@0.10.4':
|
||||
resolution: {integrity: sha512-Am5nmAKUo7Nboqq7Dhtfn7dcXA087d7gIz6Vecn1opB41aJ680+0q9U9KvEcMgduOyeiwckTIOQOx4Mmq9GcvA==}
|
||||
peerDependencies:
|
||||
'@sveltejs/vite-plugin-svelte': ^6.0.0 || ^7.0.0
|
||||
svelte: ^5.0.0
|
||||
vite: ^6.3.0 || >=7.0.0
|
||||
|
||||
'@sveltejs/kit@2.56.1':
|
||||
resolution: {integrity: sha512-9hDOl3yUh8UXWt+mN29dbcdrW0vNwPvMqi01y2Mw+ceErNIISh8MeEY7fXT2Dx1CjC/kfsVqrbxw7DifYr4hsg==}
|
||||
engines: {node: '>=18.13'}
|
||||
@@ -1079,6 +1095,9 @@ packages:
|
||||
'@types/leaflet@1.9.21':
|
||||
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':
|
||||
resolution: {integrity: sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==}
|
||||
|
||||
@@ -1091,6 +1110,9 @@ packages:
|
||||
'@types/trusted-types@2.0.7':
|
||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||
|
||||
'@types/unist@2.0.11':
|
||||
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
|
||||
|
||||
'@types/webidl-conversions@7.0.0':
|
||||
resolution: {integrity: sha512-xTE1E+YF4aWPJJeUzaZI5DRntlkY3+BCVJi0axFptnjGmAoWxkyREIh/XMrfxVLejwQxMCfDXdICo0VLxThrog==}
|
||||
|
||||
@@ -1341,6 +1363,9 @@ packages:
|
||||
estree-walker@3.0.3:
|
||||
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||
|
||||
exifr@7.1.3:
|
||||
resolution: {integrity: sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==}
|
||||
|
||||
expect-type@1.3.0:
|
||||
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -1433,6 +1458,10 @@ packages:
|
||||
ieee754@1.2.1:
|
||||
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
||||
|
||||
imagetools-core@9.1.0:
|
||||
resolution: {integrity: sha512-xQjs+2vrxLnAjCq+omuNkd5UQTld9/bP8+YT0LyYTlKfuSQtgUBvqhUwGugzSAh6sCdN+LnROMuLswn5hZ9Fhg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
indent-string@4.0.0:
|
||||
resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1585,6 +1614,11 @@ packages:
|
||||
mdn-data@2.12.2:
|
||||
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:
|
||||
resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==}
|
||||
|
||||
@@ -1738,6 +1772,13 @@ packages:
|
||||
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
|
||||
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:
|
||||
resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -1887,6 +1928,11 @@ packages:
|
||||
svelte: ^4.0.0 || ^5.0.0-next.0
|
||||
typescript: '>=5.0.0'
|
||||
|
||||
svelte-parse-markup@0.1.5:
|
||||
resolution: {integrity: sha512-T6mqZrySltPCDwfKXWQ6zehipVLk4GWfH1zCMGgRtLlOIFPuw58ZxVYxVvotMJgJaurKi1i14viB2GIRKXeJTQ==}
|
||||
peerDependencies:
|
||||
svelte: ^3.0.0 || ^4.0.0 || ^5.0.0-next.1
|
||||
|
||||
svelte@5.55.1:
|
||||
resolution: {integrity: sha512-QjvU7EFemf6mRzdMGlAFttMWtAAVXrax61SZYHdkD6yoVGQ89VeyKfZD4H1JrV1WLmJBxWhFch9H6ig/87VGjw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1968,6 +2014,25 @@ packages:
|
||||
undici-types@6.21.0:
|
||||
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-imagetools@9.0.3:
|
||||
resolution: {integrity: sha512-FwjApRNZyN+RucPW9Z9kf0dyzyi3r3zlDfrTnzHXNaYpmT3pZ5w//d6QkApy1iypbDm+3fq+Gwfv+PYA4j4uYw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
vite-node@6.0.0:
|
||||
resolution: {integrity: sha512-oj4PVrT+pDh6GYf5wfUXkcZyekYS8kKPfLPXVl8qe324Ec6l4K2DUKNadRbZ3LQl0qGcDz+PyOo7ZAh00Y+JjQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -2702,6 +2767,19 @@ snapshots:
|
||||
'@sveltejs/kit': 2.56.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0)))(svelte@5.55.1)(typescript@6.0.2)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0))
|
||||
rollup: 4.60.1
|
||||
|
||||
'@sveltejs/enhanced-img@0.10.4(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0)))(rollup@4.60.1)(svelte@5.55.1)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0))':
|
||||
dependencies:
|
||||
'@sveltejs/vite-plugin-svelte': 7.0.0(svelte@5.55.1)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0))
|
||||
magic-string: 0.30.21
|
||||
sharp: 0.34.5
|
||||
svelte: 5.55.1
|
||||
svelte-parse-markup: 0.1.5(svelte@5.55.1)
|
||||
vite: 8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0)
|
||||
vite-imagetools: 9.0.3(rollup@4.60.1)
|
||||
zimmerframe: 1.1.2
|
||||
transitivePeerDependencies:
|
||||
- rollup
|
||||
|
||||
'@sveltejs/kit@2.56.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0)))(svelte@5.55.1)(typescript@6.0.2)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0))':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.0.0
|
||||
@@ -2847,6 +2925,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/geojson': 7946.0.16
|
||||
|
||||
'@types/mdast@4.0.4':
|
||||
dependencies:
|
||||
'@types/unist': 2.0.11
|
||||
|
||||
'@types/node-cron@3.0.11': {}
|
||||
|
||||
'@types/node@22.18.0':
|
||||
@@ -2857,6 +2939,8 @@ snapshots:
|
||||
|
||||
'@types/trusted-types@2.0.7': {}
|
||||
|
||||
'@types/unist@2.0.11': {}
|
||||
|
||||
'@types/webidl-conversions@7.0.0': {}
|
||||
|
||||
'@types/whatwg-url@13.0.0':
|
||||
@@ -3096,6 +3180,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
|
||||
exifr@7.1.3: {}
|
||||
|
||||
expect-type@1.3.0: {}
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.3):
|
||||
@@ -3188,6 +3274,8 @@ snapshots:
|
||||
|
||||
ieee754@1.2.1: {}
|
||||
|
||||
imagetools-core@9.1.0: {}
|
||||
|
||||
indent-string@4.0.0: {}
|
||||
|
||||
ip@2.0.1:
|
||||
@@ -3321,6 +3409,16 @@ snapshots:
|
||||
|
||||
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: {}
|
||||
|
||||
min-indent@1.0.1: {}
|
||||
@@ -3442,6 +3540,10 @@ snapshots:
|
||||
ansi-styles: 5.2.0
|
||||
react-is: 17.0.2
|
||||
|
||||
prism-svelte@0.4.7: {}
|
||||
|
||||
prismjs@1.30.0: {}
|
||||
|
||||
protobufjs@7.5.4:
|
||||
dependencies:
|
||||
'@protobufjs/aspromise': 1.1.2
|
||||
@@ -3670,6 +3772,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- picomatch
|
||||
|
||||
svelte-parse-markup@0.1.5(svelte@5.55.1):
|
||||
dependencies:
|
||||
svelte: 5.55.1
|
||||
|
||||
svelte@5.55.1:
|
||||
dependencies:
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
@@ -3752,6 +3858,36 @@ snapshots:
|
||||
|
||||
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-imagetools@9.0.3(rollup@4.60.1):
|
||||
dependencies:
|
||||
'@rollup/pluginutils': 5.3.0(rollup@4.60.1)
|
||||
imagetools-core: 9.1.0
|
||||
sharp: 0.34.5
|
||||
transitivePeerDependencies:
|
||||
- rollup
|
||||
|
||||
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:
|
||||
cac: 7.0.0
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Build script for private (auth-gated) images rendered via `<Image private>`.
|
||||
*
|
||||
* Public images use @sveltejs/enhanced-img, which emits PUBLIC hashed assets
|
||||
* into the client bundle — fine for anything anyone may see. Private images
|
||||
* must not be publicly reachable, so they can't go through enhanced-img. This
|
||||
* script mirrors the hikes private pipeline instead:
|
||||
*
|
||||
* 1. Scan `src/lib/assets/private-images/` (recursively) for raster sources.
|
||||
* 2. Encode each into AVIF + WebP at multiple widths with sharp, named by
|
||||
* content hash, into `private-assets/` — a tree OUTSIDE the client bundle
|
||||
* and outside `/static`, so SvelteKit/Vite never serve it directly.
|
||||
* 3. Emit `src/lib/data/privateImages.generated.ts`: a manifest mapping each
|
||||
* source path to its responsive variant, with URLs under `/private-images/`
|
||||
* (the auth-gated endpoint at src/routes/private-images/[...file]/+server.ts).
|
||||
*
|
||||
* Deploy rsyncs `private-assets/` to the server, where nginx serves it only via
|
||||
* an `internal` location (`/protected-images/`) reachable through X-Accel-Redirect
|
||||
* from the endpoint — never publicly. In dev the endpoint streams from disk.
|
||||
*/
|
||||
|
||||
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 type { PrivateImageVariant } from '../src/types/images.js';
|
||||
|
||||
const ROOT = path.resolve(process.cwd());
|
||||
const SRC_DIR = path.join(ROOT, 'src', 'lib', 'assets', 'private-images');
|
||||
// Encoded output. Sibling of `hikes-assets/` and, like it, gitignored + rsynced
|
||||
// to the server by scripts/deploy.sh (never bundled, never under /static).
|
||||
const OUT_DIR = path.join(ROOT, 'private-assets');
|
||||
const MANIFEST_OUT = path.join(ROOT, 'src', 'lib', 'data', 'privateImages.generated.ts');
|
||||
|
||||
// Same responsive ladder + qualities as the hikes encoder, for consistency.
|
||||
const IMAGE_WIDTHS = [480, 960, 1600] as const;
|
||||
const AVIF_QUALITY = 55;
|
||||
const WEBP_QUALITY = 82;
|
||||
const RASTER_RE = /\.(jpe?g|png|webp|avif|tiff?|gif|heic|heif)$/i;
|
||||
// Sharp releases the JS thread while libvips runs, so a small pool ~linearly
|
||||
// speeds up encoding. Cap at 4 to avoid thrashing smaller boxes.
|
||||
const 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;
|
||||
}
|
||||
|
||||
async function walk(dir: string): Promise<string[]> {
|
||||
let entries: import('node:fs').Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
let out: string[] = [];
|
||||
for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) out = out.concat(await walk(full));
|
||||
else if (RASTER_RE.test(e.name)) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function encode(
|
||||
srcPath: string
|
||||
): Promise<{ key: string; variant: PrivateImageVariant; outNames: string[] }> {
|
||||
const buffer = await fs.readFile(srcPath);
|
||||
// Content hash names the output files: an existing file is byte-identical, so
|
||||
// re-encodes are skipped and stale ones get swept. The source basename is
|
||||
// dropped so original filenames don't leak into the (guessable) URLs.
|
||||
const hash = crypto.createHash('sha256').update(buffer).digest('hex').slice(0, 8);
|
||||
|
||||
const meta = await sharp(buffer).metadata();
|
||||
const intrinsicW = meta.width ?? IMAGE_WIDTHS[IMAGE_WIDTHS.length - 1];
|
||||
const intrinsicH = meta.height ?? 0;
|
||||
|
||||
let widths = IMAGE_WIDTHS.filter((w) => w <= intrinsicW);
|
||||
if (widths.length === 0) widths = [intrinsicW];
|
||||
|
||||
await fs.mkdir(OUT_DIR, { recursive: true });
|
||||
|
||||
type Job = { w: number; fmt: 'avif' | 'webp'; file: string; quality: number };
|
||||
const jobs: Job[] = [];
|
||||
const avif: string[] = [];
|
||||
const webp: string[] = [];
|
||||
const outNames: string[] = [];
|
||||
let largestWebp = '';
|
||||
|
||||
for (const w of widths) {
|
||||
const avifName = `${hash}.${w}.avif`;
|
||||
const webpName = `${hash}.${w}.webp`;
|
||||
jobs.push({ w, fmt: 'avif', file: path.join(OUT_DIR, avifName), quality: AVIF_QUALITY });
|
||||
jobs.push({ w, fmt: 'webp', file: path.join(OUT_DIR, webpName), quality: WEBP_QUALITY });
|
||||
avif.push(`/private-images/${avifName} ${w}w`);
|
||||
webp.push(`/private-images/${webpName} ${w}w`);
|
||||
largestWebp = `/private-images/${webpName}`;
|
||||
outNames.push(avifName, webpName);
|
||||
}
|
||||
|
||||
const presence = await Promise.all(jobs.map((j) => pathExists(j.file)));
|
||||
const pending = jobs.filter((_, i) => !presence[i]);
|
||||
await Promise.all(
|
||||
pending.map(async (j) => {
|
||||
const pipeline = sharp(buffer).rotate().resize({ width: j.w, withoutEnlargement: true });
|
||||
if (j.fmt === 'avif') await pipeline.avif({ quality: j.quality }).toFile(j.file);
|
||||
else await pipeline.webp({ quality: j.quality }).toFile(j.file);
|
||||
})
|
||||
);
|
||||
|
||||
const largestW = widths[widths.length - 1];
|
||||
const scale = largestW / intrinsicW;
|
||||
const height = Math.round((intrinsicH || largestW) * scale);
|
||||
// Manifest key: source path relative to SRC_DIR, forward-slashed, so a caller
|
||||
// writes <Image src="blog/cover.jpg" private />.
|
||||
const key = path.relative(SRC_DIR, srcPath).split(path.sep).join('/');
|
||||
|
||||
return {
|
||||
key,
|
||||
variant: {
|
||||
src: largestWebp,
|
||||
srcsetAvif: avif.join(', '),
|
||||
srcsetWebp: webp.join(', '),
|
||||
width: largestW,
|
||||
height
|
||||
},
|
||||
outNames
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const files = await walk(SRC_DIR);
|
||||
if (files.length > 0) {
|
||||
console.log(`[build-private-images] encoding ${files.length} image(s) (concurrency=${CONCURRENCY})…`);
|
||||
}
|
||||
|
||||
const results = await runWithConcurrency(files, CONCURRENCY, (f) => encode(f));
|
||||
|
||||
const manifest: Record<string, PrivateImageVariant> = {};
|
||||
const keep = new Set<string>();
|
||||
for (const r of results) {
|
||||
manifest[r.key] = r.variant;
|
||||
for (const n of r.outNames) keep.add(n);
|
||||
}
|
||||
|
||||
// Sweep encodes from prior builds whose source was removed or changed.
|
||||
if (await pathExists(OUT_DIR)) {
|
||||
const existing = await fs.readdir(OUT_DIR);
|
||||
const orphans = existing.filter((f) => !keep.has(f));
|
||||
if (orphans.length > 0) {
|
||||
await Promise.all(orphans.map((f) => fs.unlink(path.join(OUT_DIR, f)).catch(() => {})));
|
||||
console.log(`[build-private-images] removed ${orphans.length} orphaned file(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(MANIFEST_OUT), { recursive: true });
|
||||
const banner =
|
||||
'// AUTO-GENERATED by scripts/build-private-images.ts — do not edit by hand.\n' +
|
||||
"import type { PrivateImageVariant } from '$types/images';\n\n";
|
||||
const body = `export const PRIVATE_IMAGES: Record<string, PrivateImageVariant> = ${JSON.stringify(
|
||||
manifest,
|
||||
null,
|
||||
2
|
||||
)};\n`;
|
||||
await fs.writeFile(MANIFEST_OUT, banner + body);
|
||||
|
||||
console.log(
|
||||
`[build-private-images] wrote ${Object.keys(manifest).length} entry(ies) to ${path.relative(ROOT, MANIFEST_OUT)}`
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[build-private-images] Fatal:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -17,6 +17,18 @@ REMOTE_USER_GROUP="${REMOTE_USER_GROUP:-homepage:homepage}"
|
||||
SERVICE="${SERVICE:-homepage.service}"
|
||||
ERROR_PAGES_DIR="${ERROR_PAGES_DIR:-/var/www/errors}"
|
||||
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}"
|
||||
# Private (auth-gated) images for <Image private>. Built into ./private-assets/
|
||||
# and served by nginx ONLY via an `internal` location reached through the
|
||||
# endpoint's X-Accel-Redirect — add this once to the server's nginx config:
|
||||
# location /protected-images/ { internal; alias /var/www/static/private-images/; }
|
||||
PRIVATE_ASSETS_DIR="${PRIVATE_ASSETS_DIR:-/var/www/static/private-images}"
|
||||
PRIVATE_ASSETS_OWNER="${PRIVATE_ASSETS_OWNER:-http:http}"
|
||||
|
||||
DRY=""
|
||||
if [[ "${1:-}" == "--dry-run" ]]; then
|
||||
@@ -39,7 +51,25 @@ echo " node $local_node (match)"
|
||||
echo ":: Installing deps (frozen lockfile)"
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
echo ":: Building"
|
||||
# Build against production env, NOT the dev .env. SvelteKit's
|
||||
# `$env/static/private` (IMAGE_DIR, DB creds, …) is inlined at BUILD time, so a
|
||||
# build that picks up the dev .env ships dev values to prod — e.g. the relative
|
||||
# IMAGE_DIR="./imgs/" that resolves under the service's dist/ cwd instead of the
|
||||
# real served image dir. We export .env_prod into the environment; real env vars
|
||||
# take precedence over .env files in Vite/SvelteKit's env loading, so this wins
|
||||
# for the whole `pnpm build` lifecycle (prebuild vite-node scripts + build).
|
||||
PROD_ENV="${PROD_ENV:-.env_prod}"
|
||||
if [[ ! -f "$PROD_ENV" ]]; then
|
||||
echo "!! $PROD_ENV not found in $(pwd) — refusing to build with the dev .env."
|
||||
echo " Create $PROD_ENV with production values (IMAGE_DIR must be an"
|
||||
echo " ABSOLUTE path to the served recipe-image dir, DB creds, etc.)."
|
||||
exit 1
|
||||
fi
|
||||
echo ":: Building (env from $PROD_ENV)"
|
||||
set -a
|
||||
# shellcheck source=/dev/null
|
||||
source "$PROD_ENV"
|
||||
set +a
|
||||
pnpm build
|
||||
|
||||
if [[ ! -d build ]]; then
|
||||
@@ -74,13 +104,31 @@ ssh "$REMOTE" "mkdir -p $ERROR_PAGES_DIR"
|
||||
rsync -az --delete $DRY --info=progress2 \
|
||||
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 [[ -d private-assets ]]; then
|
||||
echo ":: Syncing private-assets/ → $REMOTE:$PRIVATE_ASSETS_DIR/"
|
||||
ssh "$REMOTE" "mkdir -p $PRIVATE_ASSETS_DIR"
|
||||
rsync -az --delete $DRY --info=progress2 \
|
||||
private-assets/ "$REMOTE:$PRIVATE_ASSETS_DIR/"
|
||||
else
|
||||
echo ":: No private-assets/ dir — skipping auth-gated image sync"
|
||||
fi
|
||||
|
||||
if [[ -n "$DRY" ]]; then
|
||||
echo ":: Dry run complete — no service restart"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
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 && if [[ -d $PRIVATE_ASSETS_DIR ]]; then chown -R $PRIVATE_ASSETS_OWNER $PRIVATE_ASSETS_DIR; fi"
|
||||
|
||||
echo ":: Restarting $SERVICE"
|
||||
ssh "$REMOTE" "systemctl restart $SERVICE"
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* One-shot fetch of the 26 Swiss cantonal coats of arms (Wappen) from
|
||||
* Wikimedia Commons into `static/cantons/<iso-code>.svg`. Files are
|
||||
* public-domain Swiss official insignia (PD-CH-coat-of-arms); we keep
|
||||
* the source filename in a header comment for traceability.
|
||||
*
|
||||
* Re-run with `pnpm exec vite-node scripts/download-cantons.ts` to refresh
|
||||
* any missing files. Existing files are left alone — the cantonal arms
|
||||
* don't change.
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
type CantonEntry = {
|
||||
code: string; // ISO 3166-2:CH (lowercase for filename)
|
||||
commonsFile: string; // Commons filename WITHOUT the `File:` prefix
|
||||
};
|
||||
|
||||
// Names follow the "Wappen <German-name> matt.svg" convention used across
|
||||
// almost all cantons on Commons. The handful of exceptions (Basel-Stadt,
|
||||
// Basel-Landschaft, the two Appenzells) are spelt out explicitly. If a
|
||||
// fetch returns 404 the script logs the failure and continues so the
|
||||
// remaining cantons still land.
|
||||
const CANTONS: CantonEntry[] = [
|
||||
{ code: 'ag', commonsFile: 'Wappen Aargau matt.svg' },
|
||||
{ code: 'ai', commonsFile: 'Wappen Appenzell Innerrhoden matt.svg' },
|
||||
{ code: 'ar', commonsFile: 'Wappen Appenzell Ausserrhoden matt.svg' },
|
||||
{ code: 'be', commonsFile: 'Wappen Bern matt.svg' },
|
||||
{ code: 'bl', commonsFile: 'Wappen Basel-Landschaft matt.svg' },
|
||||
{ code: 'bs', commonsFile: 'Wappen Basel-Stadt matt.svg' },
|
||||
{ code: 'fr', commonsFile: 'Wappen Freiburg matt.svg' },
|
||||
{ code: 'ge', commonsFile: 'Wappen Genf matt.svg' },
|
||||
{ code: 'gl', commonsFile: 'Wappen Glarus matt.svg' },
|
||||
{ code: 'gr', commonsFile: 'Wappen Graubünden matt.svg' },
|
||||
{ code: 'ju', commonsFile: 'Wappen Jura matt.svg' },
|
||||
{ code: 'lu', commonsFile: 'Wappen Luzern matt.svg' },
|
||||
{ code: 'ne', commonsFile: 'Wappen Neuenburg matt.svg' },
|
||||
{ code: 'nw', commonsFile: 'Wappen Nidwalden matt.svg' },
|
||||
{ code: 'ow', commonsFile: 'Wappen Obwalden matt.svg' },
|
||||
{ code: 'sg', commonsFile: 'Wappen St. Gallen matt.svg' },
|
||||
{ code: 'sh', commonsFile: 'Wappen Schaffhausen matt.svg' },
|
||||
{ code: 'so', commonsFile: 'Wappen Solothurn matt.svg' },
|
||||
{ code: 'sz', commonsFile: 'Wappen Schwyz matt.svg' },
|
||||
{ code: 'tg', commonsFile: 'Wappen Thurgau matt.svg' },
|
||||
{ code: 'ti', commonsFile: 'Wappen Tessin matt.svg' },
|
||||
{ code: 'ur', commonsFile: 'Wappen Uri matt.svg' },
|
||||
{ code: 'vd', commonsFile: 'Wappen Waadt matt.svg' },
|
||||
{ code: 'vs', commonsFile: 'Wappen Wallis matt.svg' },
|
||||
{ code: 'zg', commonsFile: 'Wappen Zug matt.svg' },
|
||||
{ code: 'zh', commonsFile: 'Wappen Zürich matt.svg' }
|
||||
];
|
||||
|
||||
const OUT_DIR = path.resolve(process.cwd(), 'static', 'cantons');
|
||||
const UA = 'bocken-homepage cantons-downloader (https://bocken.org)';
|
||||
|
||||
async function exists(p: string): Promise<boolean> {
|
||||
try { await fs.access(p); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
/** Resolve a Commons `File:Foo.svg` to its actual upload.wikimedia.org URL
|
||||
* via the public API. Returns null on failure (typo in filename, etc.). */
|
||||
async function resolveCommonsUrl(file: string): Promise<string | null> {
|
||||
const url =
|
||||
'https://commons.wikimedia.org/w/api.php' +
|
||||
'?action=query&format=json&prop=imageinfo&iiprop=url' +
|
||||
'&titles=' + encodeURIComponent('File:' + file);
|
||||
const res = await fetch(url, { headers: { 'User-Agent': UA } });
|
||||
if (!res.ok) return null;
|
||||
const json = (await res.json()) as {
|
||||
query?: { pages?: Record<string, { imageinfo?: Array<{ url?: string }> }> };
|
||||
};
|
||||
const pages = json.query?.pages;
|
||||
if (!pages) return null;
|
||||
for (const page of Object.values(pages)) {
|
||||
const u = page.imageinfo?.[0]?.url;
|
||||
if (u) return u;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function downloadCanton(c: CantonEntry): Promise<'ok' | 'cached' | 'failed'> {
|
||||
const outPath = path.join(OUT_DIR, `${c.code}.svg`);
|
||||
if (await exists(outPath)) return 'cached';
|
||||
|
||||
const url = await resolveCommonsUrl(c.commonsFile);
|
||||
if (!url) {
|
||||
console.warn(`[cantons] ${c.code}: could not resolve Commons file "${c.commonsFile}"`);
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
const res = await fetch(url, { headers: { 'User-Agent': UA } });
|
||||
if (!res.ok) {
|
||||
console.warn(`[cantons] ${c.code}: HTTP ${res.status} fetching ${url}`);
|
||||
return 'failed';
|
||||
}
|
||||
const body = await res.text();
|
||||
// Don't prepend anything: most of these files start with an `<?xml … ?>`
|
||||
// declaration, and that MUST be the very first thing in the file or
|
||||
// strict XML parsers (including browsers loading via `<img>`) reject
|
||||
// the document. Provenance is tracked in the CANTONS table above
|
||||
// instead — keep it out of the file bytes.
|
||||
await fs.writeFile(outPath, body);
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await fs.mkdir(OUT_DIR, { recursive: true });
|
||||
|
||||
let ok = 0, cached = 0, failed = 0;
|
||||
for (const c of CANTONS) {
|
||||
const r = await downloadCanton(c);
|
||||
if (r === 'ok') ok++;
|
||||
else if (r === 'cached') cached++;
|
||||
else failed++;
|
||||
if (r === 'ok') console.log(`[cantons] ${c.code}: downloaded`);
|
||||
else if (r === 'cached') console.log(`[cantons] ${c.code}: cached`);
|
||||
}
|
||||
console.log(`[cantons] done — ${ok} downloaded, ${cached} cached, ${failed} failed`);
|
||||
if (failed > 0) process.exitCode = 1;
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[cantons] fatal:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Re-derive track-point altitudes from a real terrain model.
|
||||
*
|
||||
* Phone GPS altitude is noisy (often ±10-20 m), which throws off the elevation
|
||||
* profile and the ascend/descend stats. This script keeps every point's exact
|
||||
* lat/lon and only rewrites its `<ele>`, sourcing the height from swisstopo's
|
||||
* swissALTI3D / DHM25 combined model (~0.5-2 m vertical accuracy) at that exact
|
||||
* coordinate.
|
||||
*
|
||||
* 1. Collect every `<wpt>` and `<trkpt>` in each `track.gpx`.
|
||||
* 2. Convert WGS84 → LV95 (swisstopo approximate formula, ~1 m horizontal —
|
||||
* negligible for an elevation lookup).
|
||||
* 3. Ask swisstopo for the height of each distinct point (one batched
|
||||
* `profile.json` POST per ~1000 points; per-point `height` as a fallback),
|
||||
* cached on disk so re-runs and shared points are free.
|
||||
* 4. Surgically replace each point's `<ele>` value, leaving coordinates,
|
||||
* timestamps, `<bocken:image>` extensions and all formatting untouched.
|
||||
*
|
||||
* swisstopo only covers Switzerland: points outside CH keep their original
|
||||
* elevation and are reported as skipped.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm exec vite-node scripts/fix-altitudes.ts [slug...] [--dry-run]
|
||||
* (no slug → every hike under src/content/hikes/)
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const ROOT = path.resolve(process.cwd());
|
||||
const CONTENT_DIR = path.join(ROOT, 'src', 'content', 'hikes');
|
||||
const CACHE_DIR = path.join(ROOT, 'scripts', '.cache');
|
||||
const CACHE_FILE = path.join(CACHE_DIR, 'swisstopo-elevation.json');
|
||||
|
||||
const PROFILE_URL = 'https://api3.geo.admin.ch/rest/services/profile.json';
|
||||
const HEIGHT_URL = 'https://api3.geo.admin.ch/rest/services/height';
|
||||
// swisstopo's profile service handles a few thousand vertices per call; keep
|
||||
// chunks well under that so the POST body and response stay modest.
|
||||
const PROFILE_CHUNK = 1000;
|
||||
|
||||
// Matches a <wpt>/<trkpt> opening tag and its immediate <ele> child. The route
|
||||
// builder always writes `<ele>` as the first child (verified across every
|
||||
// track.gpx), so a single capture group around the value is enough to rewrite.
|
||||
const POINT_ELE_RE =
|
||||
/(<(?:wpt|trkpt)\s+lat="([^"]+)"\s+lon="([^"]+)"[^>]*>\s*<ele>)([^<]*)(<\/ele>)/g;
|
||||
|
||||
type Cache = Record<string, number>;
|
||||
|
||||
/** WGS84 (lat/lon, degrees) → CH1903+/LV95 (E, N), swisstopo approx formula. */
|
||||
function wgs84ToLV95(lat: number, lon: number): [number, number] {
|
||||
const phi = (lat * 3600 - 169028.66) / 10000;
|
||||
const lam = (lon * 3600 - 26782.5) / 10000;
|
||||
const E =
|
||||
2600072.37 +
|
||||
211455.93 * lam -
|
||||
10938.51 * lam * phi -
|
||||
0.36 * lam * phi * phi -
|
||||
44.54 * lam ** 3;
|
||||
const N =
|
||||
1200147.07 +
|
||||
308807.95 * phi +
|
||||
3745.25 * lam * lam +
|
||||
76.63 * phi * phi -
|
||||
194.56 * lam * lam * phi +
|
||||
119.79 * phi ** 3;
|
||||
return [Math.round(E * 100) / 100, Math.round(N * 100) / 100];
|
||||
}
|
||||
|
||||
const enKey = (E: number, N: number): string => `${E.toFixed(2)},${N.toFixed(2)}`;
|
||||
|
||||
async function loadCache(): Promise<Cache> {
|
||||
try {
|
||||
return JSON.parse(await fs.readFile(CACHE_FILE, 'utf-8'));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCache(cache: Cache): Promise<void> {
|
||||
await fs.mkdir(CACHE_DIR, { recursive: true });
|
||||
await fs.writeFile(CACHE_FILE, JSON.stringify(cache));
|
||||
}
|
||||
|
||||
/** Batched height lookup. Returns a map of `enKey` → height for resolved points. */
|
||||
async function fetchProfile(coords: [number, number][]): Promise<Map<string, number>> {
|
||||
const out = new Map<string, number>();
|
||||
if (coords.length < 2) return out;
|
||||
const body = new URLSearchParams({
|
||||
geom: JSON.stringify({ type: 'LineString', coordinates: coords }),
|
||||
sr: '2056',
|
||||
distinct_points: 'true',
|
||||
nb_points: String(coords.length),
|
||||
offset: '0'
|
||||
});
|
||||
const res = await fetch(PROFILE_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body
|
||||
});
|
||||
if (!res.ok) throw new Error(`profile.json HTTP ${res.status}`);
|
||||
const rows = (await res.json()) as Array<{
|
||||
alts?: Record<string, number | null>;
|
||||
easting: number;
|
||||
northing: number;
|
||||
}>;
|
||||
for (const r of rows) {
|
||||
const h = r.alts?.COMB ?? r.alts?.DTM2 ?? r.alts?.DTM25;
|
||||
if (typeof h === 'number') out.set(enKey(r.easting, r.northing), h);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Single-point fallback (also the only option for a 1-point chunk). */
|
||||
async function fetchHeight(E: number, N: number): Promise<number | null> {
|
||||
try {
|
||||
const res = await fetch(`${HEIGHT_URL}?easting=${E}&northing=${N}&sr=2056`);
|
||||
if (!res.ok) return null;
|
||||
const j = (await res.json()) as { height?: string | number; success?: boolean };
|
||||
if (j.success === false) return null;
|
||||
const h = typeof j.height === 'string' ? parseFloat(j.height) : j.height;
|
||||
return typeof h === 'number' && Number.isFinite(h) ? h : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type PointKey = string; // `${latStr},${lonStr}` exactly as written in the file
|
||||
|
||||
async function fixTrack(slug: string, cache: Cache, dryRun: boolean): Promise<void> {
|
||||
const file = path.join(CONTENT_DIR, slug, 'track.gpx');
|
||||
let text: string;
|
||||
try {
|
||||
text = await fs.readFile(file, 'utf-8');
|
||||
} catch {
|
||||
console.warn(`[fix-altitudes] ${slug}: no track.gpx, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Distinct points, keyed by the exact lat/lon strings in the file so the
|
||||
// rewrite can match without any float round-tripping.
|
||||
const points = new Map<PointKey, { lat: number; lon: number; E: number; N: number }>();
|
||||
for (const m of text.matchAll(POINT_ELE_RE)) {
|
||||
const key = `${m[2]},${m[3]}`;
|
||||
if (!points.has(key)) {
|
||||
const lat = parseFloat(m[2]);
|
||||
const lon = parseFloat(m[3]);
|
||||
const [E, N] = wgs84ToLV95(lat, lon);
|
||||
points.set(key, { lat, lon, E, N });
|
||||
}
|
||||
}
|
||||
if (points.size === 0) {
|
||||
console.warn(`[fix-altitudes] ${slug}: no points found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve heights for any points not already cached.
|
||||
const uncached = [...points.values()].filter((p) => cache[enKey(p.E, p.N)] === undefined);
|
||||
if (uncached.length > 0) {
|
||||
for (let i = 0; i < uncached.length; i += PROFILE_CHUNK) {
|
||||
const chunk = uncached.slice(i, i + PROFILE_CHUNK);
|
||||
let resolved = new Map<string, number>();
|
||||
try {
|
||||
resolved = await fetchProfile(chunk.map((p) => [p.E, p.N] as [number, number]));
|
||||
} catch (err) {
|
||||
console.warn(`[fix-altitudes] ${slug}: profile batch failed (${String(err)}), falling back per-point`);
|
||||
}
|
||||
for (const p of chunk) {
|
||||
const k = enKey(p.E, p.N);
|
||||
let h = resolved.get(k);
|
||||
if (h === undefined) h = (await fetchHeight(p.E, p.N)) ?? undefined;
|
||||
if (h !== undefined) cache[k] = h;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rewrite each <ele> in place; tally changes and out-of-CH skips.
|
||||
let updated = 0;
|
||||
let skipped = 0;
|
||||
let maxDelta = 0;
|
||||
const fixed = text.replace(POINT_ELE_RE, (full, open, latStr, lonStr, oldEle, close) => {
|
||||
const p = points.get(`${latStr},${lonStr}`)!;
|
||||
const h = cache[enKey(p.E, p.N)];
|
||||
if (h === undefined) {
|
||||
skipped++;
|
||||
return full; // outside CH coverage — keep original elevation
|
||||
}
|
||||
const newEle = h.toFixed(1);
|
||||
const old = parseFloat(oldEle);
|
||||
if (Number.isFinite(old)) maxDelta = Math.max(maxDelta, Math.abs(h - old));
|
||||
if (newEle !== oldEle.trim()) updated++;
|
||||
return `${open}${newEle}${close}`;
|
||||
});
|
||||
|
||||
const summary =
|
||||
`${points.size} distinct pts · ${updated} ele rewritten · ` +
|
||||
`max Δ ${maxDelta.toFixed(1)} m` +
|
||||
(skipped > 0 ? ` · ${skipped} kept (outside CH)` : '');
|
||||
if (dryRun) {
|
||||
console.log(`[fix-altitudes] ${slug}: ${summary} (dry-run, not written)`);
|
||||
return;
|
||||
}
|
||||
if (fixed !== text) {
|
||||
await fs.writeFile(file, fixed);
|
||||
console.log(`[fix-altitudes] ${slug}: ${summary}`);
|
||||
} else {
|
||||
console.log(`[fix-altitudes] ${slug}: already up to date (${summary})`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
const dryRun = args.includes('--dry-run');
|
||||
const slugArgs = args.filter((a) => !a.startsWith('--'));
|
||||
|
||||
let slugs = slugArgs;
|
||||
if (slugs.length === 0) {
|
||||
const entries = await fs.readdir(CONTENT_DIR, { withFileTypes: true });
|
||||
slugs = entries
|
||||
.filter((e) => e.isDirectory() && !e.name.startsWith('TODO-'))
|
||||
.map((e) => e.name)
|
||||
.sort();
|
||||
}
|
||||
|
||||
const cache = await loadCache();
|
||||
for (const slug of slugs) {
|
||||
await fixTrack(slug, cache, dryRun);
|
||||
}
|
||||
await saveCache(cache);
|
||||
console.log(`[fix-altitudes] done (${slugs.length} track(s), cache: ${Object.keys(cache).length} pts)`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[fix-altitudes] Fatal:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env bash
|
||||
# Sync hike *source* photos to/from a backup server, keeping them out of git.
|
||||
#
|
||||
# The repo tracks each hike's index.svx + track.gpx — the manifest of which
|
||||
# images a hike uses (by content hash) and where they sit on the route. The
|
||||
# original JPEGs are large and live here instead of in git. `push` backs the
|
||||
# local photos up; `pull` restores them so any machine can run build-hikes and
|
||||
# reproduce the encoded static assets.
|
||||
#
|
||||
# Only photo files are transferred — images/, private/ and root cover.* —
|
||||
# mirroring the .gitignore rules; the text files stay in git and are skipped.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/hike-photos.sh push [--dry-run] [--delete]
|
||||
# scripts/hike-photos.sh pull [--dry-run] [--delete]
|
||||
#
|
||||
# --dry-run show what would transfer, change nothing
|
||||
# --delete mirror exactly (remove extra files on the destination) — careful
|
||||
#
|
||||
# Config (env vars, with defaults):
|
||||
# REMOTE SSH host (default root@bocken.org)
|
||||
# HIKE_PHOTOS_DIR remote dir for originals (default /var/backups/hike-photos)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REMOTE="${REMOTE:-root@bocken.org}"
|
||||
HIKE_PHOTOS_DIR="${HIKE_PHOTOS_DIR:-/var/backups/hike-photos}"
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
LOCAL="src/content/hikes/"
|
||||
REMOTE_PATH="$REMOTE:$HIKE_PHOTOS_DIR/"
|
||||
|
||||
cmd="${1:-}"
|
||||
shift || true
|
||||
|
||||
EXTRA=()
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--dry-run) EXTRA+=(--dry-run); echo ":: DRY RUN — nothing will be transferred" ;;
|
||||
--delete) EXTRA+=(--delete) ;;
|
||||
*) echo "!! Unknown option: $arg" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Transfer only the photo files: descend into each hike dir, take images/,
|
||||
# private/ and a root cover.*, drop everything else (index.svx, track.gpx,
|
||||
# icon.svg — those are versioned in git). Empty dirs (e.g. text-only TODO
|
||||
# drafts) are pruned so the backup stays clean.
|
||||
FILTERS=(
|
||||
--prune-empty-dirs
|
||||
--include='/*/'
|
||||
--include='/*/images/'
|
||||
--include='/*/images/**'
|
||||
--include='/*/private/'
|
||||
--include='/*/private/**'
|
||||
--include='/*/cover.*'
|
||||
--exclude='*'
|
||||
)
|
||||
|
||||
case "$cmd" in
|
||||
push)
|
||||
echo ":: Pushing hike photos → $REMOTE_PATH"
|
||||
ssh "$REMOTE" "mkdir -p '$HIKE_PHOTOS_DIR'"
|
||||
rsync -avh --info=progress2 "${EXTRA[@]}" "${FILTERS[@]}" "$LOCAL" "$REMOTE_PATH"
|
||||
;;
|
||||
pull)
|
||||
echo ":: Pulling hike photos ← $REMOTE_PATH"
|
||||
mkdir -p "$LOCAL"
|
||||
rsync -avh --info=progress2 "${EXTRA[@]}" "${FILTERS[@]}" "$REMOTE_PATH" "$LOCAL"
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 {push|pull} [--dry-run] [--delete]" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ":: done"
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Postbuild: precompress static build output for nginx `gzip_static` /
|
||||
* `brotli_static`.
|
||||
*
|
||||
* Replaces adapter-node's `precompress: true`, which brotli-q11 + gzips EVERY
|
||||
* file in build/client single-threaded — including ~90 MB of already-compressed
|
||||
* jpg/mp4/png/webp/woff2 (zero gain) and 20 MB+ text blobs at q11 (~30 s each).
|
||||
*
|
||||
* This version instead:
|
||||
* - only touches compressible text types (skips binaries entirely),
|
||||
* - tunes brotli quality down for large files (q11 is wildly slow past a few MB
|
||||
* for marginal ratio gains over q10/q9),
|
||||
* - runs gzip + brotli concurrently across the libuv threadpool,
|
||||
* - skips files that already have a .br/.gz sibling (e.g. the error pages the
|
||||
* build-error-page step emits), so it's idempotent.
|
||||
*
|
||||
* Run: pnpm exec vite-node scripts/precompress.ts
|
||||
*/
|
||||
|
||||
// The async gzip/brotli calls run on libuv's threadpool. Its size must be set
|
||||
// before the pool is first used — by the time this module runs under vite-node
|
||||
// the pool is already up, so postbuild sets UV_THREADPOOL_SIZE on the command
|
||||
// line (the authoritative knob). This line is just a fallback default for
|
||||
// direct `vite-node scripts/precompress.ts` runs and won't override an
|
||||
// already-set value.
|
||||
import os from 'node:os';
|
||||
const CORES = Math.max(1, os.cpus().length);
|
||||
process.env.UV_THREADPOOL_SIZE ||= String(Math.min(CORES, 12));
|
||||
|
||||
import { readdir, readFile, writeFile, stat } from 'node:fs/promises';
|
||||
import { join, resolve, dirname, extname, basename } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { gzip, brotliCompress, constants as zlib } from 'node:zlib';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const gzipAsync = promisify(gzip);
|
||||
const brotliAsync = promisify(brotliCompress);
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const TARGET_DIRS = ['build/client', 'build/prerendered'];
|
||||
|
||||
// Only these extensions are worth compressing; everything else (images, video,
|
||||
// fonts, archives) is already compressed and skipped.
|
||||
const COMPRESSIBLE = new Set([
|
||||
'.js', '.mjs', '.cjs', '.css', '.html', '.htm', '.json', '.map',
|
||||
'.svg', '.xml', '.txt', '.tsv', '.csv', '.wasm', '.webmanifest', '.ico'
|
||||
]);
|
||||
|
||||
// Server-side-only data that nonetheless lands in build/client and is read back
|
||||
// from disk server-side (never delivered to a browser). A .br/.gz sibling for
|
||||
// these is dead weight nginx never serves — and they're the largest, slowest
|
||||
// files in the tree, so skipping them is where almost all the time goes. They
|
||||
// must still exist UNCOMPRESSED for the server reads, so we skip rather than
|
||||
// remove them. Two kinds:
|
||||
// - bible TSVs: read via src/lib/server/staticAsset.ts → resolveStaticAsset
|
||||
// - ML embedding JSONs: `?url`-imported by $lib/server/{nutritionMatcher,
|
||||
// shoppingCategorizer}.ts and read via SvelteKit's read(); emitted into
|
||||
// _app/immutable/assets/ with a content hash (…Embeddings.<hash>.json).
|
||||
const SERVER_ONLY_NAMES = new Set(['allioli.tsv', 'drb.tsv']);
|
||||
const SERVER_ONLY_RE = /embeddings\.[^/]*\.json$/i;
|
||||
function isServerOnly(file: string): boolean {
|
||||
const base = basename(file);
|
||||
return SERVER_ONLY_NAMES.has(base) || SERVER_ONLY_RE.test(base);
|
||||
}
|
||||
|
||||
// Don't bother compressing tiny files — overhead/headers outweigh the savings.
|
||||
const MIN_BYTES = 1024;
|
||||
|
||||
/** Pick a brotli quality that balances ratio against time for large files. */
|
||||
function brotliQuality(size: number): number {
|
||||
if (size > 4 * 1024 * 1024) return 9; // >4 MB: q9 (q11 would take 30 s+)
|
||||
if (size > 1024 * 1024) return 10; // 1–4 MB
|
||||
return 11; // small files: max ratio, still fast
|
||||
}
|
||||
|
||||
async function* walk(dir: string): AsyncGenerator<string> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return; // dir doesn't exist (e.g. no prerendered output) — skip
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) yield* walk(full);
|
||||
else if (entry.isFile()) yield full;
|
||||
}
|
||||
}
|
||||
|
||||
async function collect(): Promise<string[]> {
|
||||
const files: string[] = [];
|
||||
for (const rel of TARGET_DIRS) {
|
||||
for await (const f of walk(join(ROOT, rel))) {
|
||||
const ext = extname(f).toLowerCase();
|
||||
if (!COMPRESSIBLE.has(ext)) continue;
|
||||
if (f.endsWith('.gz') || f.endsWith('.br')) continue;
|
||||
if (isServerOnly(f)) continue;
|
||||
files.push(f);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
async function exists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let saved = 0;
|
||||
let written = 0;
|
||||
|
||||
async function compressOne(file: string): Promise<void> {
|
||||
const buf = await readFile(file);
|
||||
if (buf.length < MIN_BYTES) return;
|
||||
|
||||
const jobs: Promise<void>[] = [];
|
||||
|
||||
if (!(await exists(file + '.gz'))) {
|
||||
jobs.push(
|
||||
gzipAsync(buf, { level: zlib.Z_BEST_COMPRESSION }).then(async (out) => {
|
||||
if (out.length < buf.length) {
|
||||
await writeFile(file + '.gz', out);
|
||||
written++;
|
||||
saved += buf.length - out.length;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (!(await exists(file + '.br'))) {
|
||||
jobs.push(
|
||||
brotliAsync(buf, {
|
||||
params: {
|
||||
[zlib.BROTLI_PARAM_QUALITY]: brotliQuality(buf.length),
|
||||
[zlib.BROTLI_PARAM_SIZE_HINT]: buf.length
|
||||
}
|
||||
}).then(async (out) => {
|
||||
if (out.length < buf.length) {
|
||||
await writeFile(file + '.br', out);
|
||||
written++;
|
||||
saved += buf.length - out.length;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(jobs);
|
||||
}
|
||||
|
||||
/** Run `tasks` with at most `limit` in flight at once. */
|
||||
async function pool<T>(items: T[], limit: number, fn: (item: T) => Promise<void>): Promise<void> {
|
||||
let i = 0;
|
||||
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
||||
while (i < items.length) {
|
||||
const idx = i++;
|
||||
await fn(items[idx]);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
}
|
||||
|
||||
const t0 = Date.now();
|
||||
const files = await collect();
|
||||
console.log(`[precompress] ${files.length} compressible files, ${CORES} cores`);
|
||||
await pool(files, CORES, compressOne);
|
||||
console.log(
|
||||
`[precompress] wrote ${written} files, saved ${(saved / 1048576).toFixed(1)} MB in ${(
|
||||
(Date.now() - t0) / 1000
|
||||
).toFixed(1)}s`
|
||||
);
|
||||
@@ -0,0 +1,424 @@
|
||||
/**
|
||||
* Build-time static hero-map renderer for individual hikes.
|
||||
*
|
||||
* Fetches the Swisstopo raster tiles covering each hike's bbox, composites
|
||||
* them into one PNG via sharp, draws the trail polyline + start/end markers
|
||||
* on top, and emits a single WebP. The result is served as `<img>` in the
|
||||
* detail page's hero so the user sees an exact replica of the live map
|
||||
* during the few hundred milliseconds it takes Leaflet to dynamic-import,
|
||||
* fetch tiles, and render — eliminating the perceived load delay.
|
||||
*
|
||||
* Tiles are content-cached on disk; rendered heroes are name-cached by
|
||||
* content hash so a re-build with unchanged GPX is a no-op.
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const TILE_SIZE = 256;
|
||||
const TILE_CACHE_DIR = path.resolve(process.cwd(), 'scripts', '.cache', 'swisstopo-tiles');
|
||||
// Swisstopo serves the WMTS tiles from wmts10–wmts100. Spread across a
|
||||
// couple of sub-domains so we don't hammer a single origin during initial
|
||||
// build (browsers see different hosts; the disk cache makes follow-up
|
||||
// builds a non-event regardless).
|
||||
const SUBDOMAINS = ['wmts10', 'wmts20'] as const;
|
||||
const USER_AGENT = 'bocken-homepage build-hikes';
|
||||
|
||||
function tileUrl(sub: string, layer: string, z: number, x: number, y: number): string {
|
||||
return `https://${sub}.geo.admin.ch/1.0.0/${layer}/default/current/3857/${z}/${x}/${y}.jpeg`;
|
||||
}
|
||||
|
||||
/** Web Mercator: lng/lat → absolute pixel coordinate at a given zoom. */
|
||||
export function lngLatToPx(lng: number, lat: number, zoom: number): { x: number; y: number } {
|
||||
const n = 2 ** zoom;
|
||||
const x = ((lng + 180) / 360) * n * TILE_SIZE;
|
||||
const latRad = (lat * Math.PI) / 180;
|
||||
const y =
|
||||
((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) * n * TILE_SIZE;
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
async function pathExists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** `null` = network failure (we'll count it against the abort threshold).
|
||||
* `'blank'` = HTTP 4xx, i.e. the tile is intentionally not served — for
|
||||
* the Swisstopo Pixelkarte that means we're outside Switzerland's bbox.
|
||||
* The overview hero canvas extends into DE/IT/FR, so we treat blanks as
|
||||
* "OK, just nothing there" rather than failures. */
|
||||
type TileResult = Buffer | 'blank' | null;
|
||||
|
||||
async function fetchTile(
|
||||
layer: string,
|
||||
z: number,
|
||||
x: number,
|
||||
y: number
|
||||
): Promise<TileResult> {
|
||||
const key = `${layer.replace(/[^a-z0-9]/gi, '_')}_${z}_${x}_${y}.jpeg`;
|
||||
const cachePath = path.join(TILE_CACHE_DIR, key);
|
||||
try {
|
||||
return await fs.readFile(cachePath);
|
||||
} catch { /* miss */ }
|
||||
|
||||
const sub = SUBDOMAINS[(x + y) % SUBDOMAINS.length];
|
||||
try {
|
||||
const res = await fetch(tileUrl(sub, layer, z, x, y), {
|
||||
headers: { 'User-Agent': USER_AGENT }
|
||||
});
|
||||
if (!res.ok) {
|
||||
// 4xx means "we don't serve this tile" (out-of-bounds for the
|
||||
// Swiss data set). Anything else (5xx) is a real failure.
|
||||
if (res.status >= 400 && res.status < 500) return 'blank';
|
||||
if (process.env.STATIC_MAP_DEBUG) {
|
||||
console.warn(`[staticHikeMap] tile ${z}/${x}/${y} HTTP ${res.status}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
await fs.mkdir(TILE_CACHE_DIR, { recursive: true });
|
||||
await fs.writeFile(cachePath, buf);
|
||||
return buf;
|
||||
} catch (err) {
|
||||
if (process.env.STATIC_MAP_DEBUG) {
|
||||
console.warn(`[staticHikeMap] tile ${z}/${x}/${y} error:`, err);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeSvgNumber(n: number): string {
|
||||
// Keep SVG path compact but precise enough for 1600 px rendering.
|
||||
return n.toFixed(1);
|
||||
}
|
||||
|
||||
export interface RenderStaticMapPhotoMarker {
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
export interface StaticMapPose {
|
||||
zoom: number;
|
||||
centerLat: number;
|
||||
centerLng: number;
|
||||
/** Origin in zoom-pixel space — top-left of the output canvas. The
|
||||
* renderer needs it; the caller doesn't, but exposing it keeps the
|
||||
* `computePose` ↔ `renderStaticMap` interface stateless. */
|
||||
originX: number;
|
||||
originY: number;
|
||||
}
|
||||
|
||||
export interface ComputeStaticMapPoseOpts {
|
||||
bbox: [number, number, number, number];
|
||||
/** Canvas dimensions for centering / tile fetching. */
|
||||
width?: number;
|
||||
height?: number;
|
||||
paddingPx?: number;
|
||||
/** Reference dimensions used purely for zoom selection. Defaults to
|
||||
* `width × height` — but pass the expected *display* size (not the
|
||||
* rendered canvas size) when you want zoom to match Leaflet's
|
||||
* `fitBounds` at the user's viewport. The renderer still draws the
|
||||
* full `width × height` canvas around the chosen zoom, so wider
|
||||
* viewports get more context without the bbox being cropped on
|
||||
* smaller ones. */
|
||||
fitWidth?: number;
|
||||
fitHeight?: number;
|
||||
/** Upper bound on the zoom search — mirrors Leaflet's `fitBounds({ maxZoom })`.
|
||||
* Use this when the live map clamps its zoom so the static hero doesn't
|
||||
* land at a more detailed level than Leaflet will ever show. */
|
||||
maxZoom?: number;
|
||||
}
|
||||
|
||||
/** Pure-math pass: pick the zoom + centre + canvas origin that the static
|
||||
* renderer would use for these inputs. Identical for light- and dark-
|
||||
* themed renders, so callers can compute it once and re-use. */
|
||||
export function computeStaticMapPose(opts: ComputeStaticMapPoseOpts): StaticMapPose | null {
|
||||
const width = opts.width ?? 1600;
|
||||
const height = opts.height ?? 1000;
|
||||
const paddingPx = opts.paddingPx ?? 24;
|
||||
const fitWidth = opts.fitWidth ?? width;
|
||||
const fitHeight = opts.fitHeight ?? height;
|
||||
const maxZoom = opts.maxZoom ?? 18;
|
||||
|
||||
const [minLat, minLng, maxLat, maxLng] = opts.bbox;
|
||||
if (
|
||||
!Number.isFinite(minLat) || !Number.isFinite(minLng) ||
|
||||
!Number.isFinite(maxLat) || !Number.isFinite(maxLng)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const innerW = Math.max(1, fitWidth - 2 * paddingPx);
|
||||
const innerH = Math.max(1, fitHeight - 2 * paddingPx);
|
||||
|
||||
// Pick the highest integer zoom where the bbox fits inside the
|
||||
// reference inner rectangle. This mirrors Leaflet's `fitBounds`
|
||||
// integer-zoom search, so a viewport matching `fitWidth × fitHeight`
|
||||
// will choose the same zoom Leaflet does for the same bbox.
|
||||
let zoom = 7;
|
||||
for (let z = maxZoom; z >= 7; z--) {
|
||||
const tl = lngLatToPx(minLng, maxLat, z);
|
||||
const br = lngLatToPx(maxLng, minLat, z);
|
||||
if (br.x - tl.x <= innerW && br.y - tl.y <= innerH) {
|
||||
zoom = z;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const centerLat = (minLat + maxLat) / 2;
|
||||
const centerLng = (minLng + maxLng) / 2;
|
||||
const c = lngLatToPx(centerLng, centerLat, zoom);
|
||||
const originX = Math.round(c.x - width / 2);
|
||||
const originY = Math.round(c.y - height / 2);
|
||||
|
||||
return { zoom, centerLat, centerLng, originX, originY };
|
||||
}
|
||||
|
||||
export interface RenderStaticMapOpts {
|
||||
/** Pre-computed pose (zoom + centre + origin). Get this via
|
||||
* `computeStaticMapPose(...)`. Shared by light- and dark-themed
|
||||
* renders so both variants align perfectly. */
|
||||
pose: StaticMapPose;
|
||||
/** Track polyline as `[lat, lng]` tuples (any length). */
|
||||
polyline: Array<[number, number]>;
|
||||
color: string;
|
||||
outputPath: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
/** Swisstopo WMTS layer ID. Defaults to the schematic Pixelkarte (the
|
||||
* same base layer Leaflet starts with on the detail page). */
|
||||
layer?: string;
|
||||
/** Optional image-point markers to burn into the SVG overlay alongside
|
||||
* the start/end dots. Pass only the points safe to render in a public-
|
||||
* facing image — private photos should be filtered out by the caller. */
|
||||
photoMarkers?: RenderStaticMapPhotoMarker[];
|
||||
/** Fill colour for the photo marker dots. Should match the live
|
||||
* HikePhoto marker styling (`--color-primary`). */
|
||||
photoMarkerColor?: string;
|
||||
/** Border colour for the photo marker dots — matches the live
|
||||
* `.hike-photo-marker .badge` `border-color: var(--color-surface)` so
|
||||
* the static blends in with the active theme's surface colour. */
|
||||
photoMarkerBorderColor?: string;
|
||||
/** Stroke colour of the Lucide `camera` icon inside the badge. Matches
|
||||
* the live badge's `color: var(--color-text-on-primary)` — white on
|
||||
* the light theme's mid-blue primary, dark on the dark theme's light-
|
||||
* blue primary. */
|
||||
photoMarkerIconColor?: string;
|
||||
}
|
||||
|
||||
/** Fetch every Swisstopo tile covering the canvas at the given pose, then
|
||||
* composite them into a single PNG buffer. Returns `null` when fewer than
|
||||
* half the tiles arrive (a patchy hero is worse than no hero). Shared by
|
||||
* `renderStaticMap` (per-hike hero) and `renderOverviewMap` (the /hikes
|
||||
* landing-page hero) so both pull the same tile cache and use the same
|
||||
* fallback colour. */
|
||||
async function composeBaseMap(
|
||||
pose: StaticMapPose,
|
||||
width: number,
|
||||
height: number,
|
||||
layer: string
|
||||
): Promise<Buffer | null> {
|
||||
const { zoom, originX, originY } = pose;
|
||||
|
||||
const minTileX = Math.floor(originX / TILE_SIZE);
|
||||
const maxTileX = Math.floor((originX + width - 1) / TILE_SIZE);
|
||||
const minTileY = Math.floor(originY / TILE_SIZE);
|
||||
const maxTileY = Math.floor((originY + height - 1) / TILE_SIZE);
|
||||
|
||||
// Parallel tile fetches — disk cache makes follow-up builds essentially
|
||||
// free, but the first build pulls ~6–20 tiles per per-hike hero and
|
||||
// considerably more for the overview hero.
|
||||
const tileJobs: Array<{ tx: number; ty: number; left: number; top: number }> = [];
|
||||
for (let ty = minTileY; ty <= maxTileY; ty++) {
|
||||
for (let tx = minTileX; tx <= maxTileX; tx++) {
|
||||
tileJobs.push({
|
||||
tx,
|
||||
ty,
|
||||
left: tx * TILE_SIZE - originX,
|
||||
top: ty * TILE_SIZE - originY
|
||||
});
|
||||
}
|
||||
}
|
||||
const tileBufs = await Promise.all(
|
||||
tileJobs.map(async (job) => ({
|
||||
job,
|
||||
buf: await fetchTile(layer, zoom, job.tx, job.ty)
|
||||
}))
|
||||
);
|
||||
|
||||
const composites: Array<{ input: Buffer; left: number; top: number }> = [];
|
||||
let networkFailures = 0;
|
||||
for (const { job, buf } of tileBufs) {
|
||||
if (buf === null) {
|
||||
networkFailures++;
|
||||
continue;
|
||||
}
|
||||
if (buf === 'blank') continue; // out-of-bounds, draw the fallback grey
|
||||
composites.push({ input: buf, left: job.left, top: job.top });
|
||||
}
|
||||
// Network-failure threshold (not "fewer than half present"): blank
|
||||
// out-of-bounds tiles are an expected outcome for the overview hero
|
||||
// that extends past Switzerland's edges, so they don't count against
|
||||
// the abort threshold.
|
||||
if (networkFailures > tileJobs.length / 2) return null;
|
||||
|
||||
// Tile composite is identical regardless of UI theme — we deliberately
|
||||
// don't invert the Pixelkarte for dark mode (its colour palette doesn't
|
||||
// survive a naive invert). Only the SVG overlay above changes per theme.
|
||||
return sharp({
|
||||
create: { width, height, channels: 3, background: { r: 235, g: 235, b: 235 } }
|
||||
})
|
||||
.composite(composites)
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
/** Render and write a single static hero map at the given pose. Returns
|
||||
* `false` on failure (zero tiles fetched, degenerate inputs). */
|
||||
export async function renderStaticMap(opts: RenderStaticMapOpts): Promise<boolean> {
|
||||
const width = opts.width ?? 1600;
|
||||
const height = opts.height ?? 1000;
|
||||
const layer = opts.layer ?? 'ch.swisstopo.pixelkarte-farbe';
|
||||
const { zoom, originX, originY } = opts.pose;
|
||||
|
||||
if (opts.polyline.length < 2) return false;
|
||||
|
||||
const mapBuf = await composeBaseMap(opts.pose, width, height, layer);
|
||||
if (!mapBuf) return false;
|
||||
|
||||
// SVG overlay — polyline + photo markers + start/end dots.
|
||||
const pathParts: string[] = [];
|
||||
for (let i = 0; i < opts.polyline.length; i++) {
|
||||
const [lat, lng] = opts.polyline[i];
|
||||
const p = lngLatToPx(lng, lat, zoom);
|
||||
const px = p.x - originX;
|
||||
const py = p.y - originY;
|
||||
pathParts.push((i === 0 ? 'M' : 'L') + escapeSvgNumber(px) + ',' + escapeSvgNumber(py));
|
||||
}
|
||||
const start = opts.polyline[0];
|
||||
const end = opts.polyline[opts.polyline.length - 1];
|
||||
const startP = lngLatToPx(start[1], start[0], zoom);
|
||||
const endP = lngLatToPx(end[1], end[0], zoom);
|
||||
const sx = escapeSvgNumber(startP.x - originX);
|
||||
const sy = escapeSvgNumber(startP.y - originY);
|
||||
const ex = escapeSvgNumber(endP.x - originX);
|
||||
const ey = escapeSvgNumber(endP.y - originY);
|
||||
|
||||
const photoMarkerColor = opts.photoMarkerColor ?? '#5e81ac';
|
||||
const photoMarkerBorderColor = opts.photoMarkerBorderColor ?? '#eceff4';
|
||||
const photoMarkerIconColor = opts.photoMarkerIconColor ?? '#fff';
|
||||
// Match HikeMap's `.hike-photo-marker .badge` — 28 px Nord-blue circle
|
||||
// with a 2 px theme-surface border, holding a 14 px theme-on-primary
|
||||
// Lucide `camera` icon. The camera icon paths are the literal Lucide
|
||||
// source (lucide-camera).
|
||||
const photoMarkers = (opts.photoMarkers ?? [])
|
||||
.map((m) => {
|
||||
const p = lngLatToPx(m.lng, m.lat, zoom);
|
||||
const cx = escapeSvgNumber(p.x - originX);
|
||||
const cy = escapeSvgNumber(p.y - originY);
|
||||
return (
|
||||
`<g transform="translate(${cx} ${cy})">` +
|
||||
`<circle r="14" fill="${photoMarkerColor}" stroke="${photoMarkerBorderColor}" stroke-width="2"/>` +
|
||||
`<g transform="translate(-7 -7) scale(0.5833)" stroke="${photoMarkerIconColor}" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">` +
|
||||
`<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"/>` +
|
||||
`</g>` +
|
||||
`</g>`
|
||||
);
|
||||
})
|
||||
.join('');
|
||||
|
||||
const overlay = Buffer.from(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
|
||||
`<path d="${pathParts.join(' ')}" fill="none" stroke="${opts.color}" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.95"/>` +
|
||||
photoMarkers +
|
||||
`<circle cx="${sx}" cy="${sy}" r="9" fill="#a3be8c" stroke="#fff" stroke-width="3"/>` +
|
||||
`<circle cx="${ex}" cy="${ey}" r="9" fill="#bf616a" stroke="#fff" stroke-width="3"/>` +
|
||||
`</svg>`
|
||||
);
|
||||
|
||||
await sharp(mapBuf)
|
||||
.composite([{ input: overlay, left: 0, top: 0 }])
|
||||
.webp({ quality: 78 })
|
||||
.toFile(opts.outputPath);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Overview hero (one image for the whole /hikes index page).
|
||||
// Same tile composite as `renderStaticMap`, but the overlay draws many
|
||||
// polylines (one per hike, coloured by SAC tier) and no per-route start /
|
||||
// end / photo markers — the map is a finder, not a detail view.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RenderOverviewPolyline {
|
||||
points: Array<[number, number]>;
|
||||
color: string;
|
||||
/** Indices where a new disconnected sub-path begins (multi-day stage gaps
|
||||
* >1 km), so the line isn't drawn across an overnight transfer. */
|
||||
breaks?: number[];
|
||||
}
|
||||
|
||||
export interface RenderOverviewMapOpts {
|
||||
pose: StaticMapPose;
|
||||
polylines: RenderOverviewPolyline[];
|
||||
outputPath: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
layer?: string;
|
||||
}
|
||||
|
||||
export async function renderOverviewMap(opts: RenderOverviewMapOpts): Promise<boolean> {
|
||||
const width = opts.width ?? 1600;
|
||||
const height = opts.height ?? 1000;
|
||||
const layer = opts.layer ?? 'ch.swisstopo.pixelkarte-farbe';
|
||||
const { zoom, originX, originY } = opts.pose;
|
||||
|
||||
const drawable = opts.polylines.filter((p) => p.points.length >= 2);
|
||||
if (drawable.length === 0) return false;
|
||||
|
||||
const mapBuf = await composeBaseMap(opts.pose, width, height, layer);
|
||||
if (!mapBuf) return false;
|
||||
|
||||
// One <path> per hike polyline. The overview map is rendered fairly
|
||||
// zoomed-out, so even ≤150-point preview polylines stay compact.
|
||||
const paths = drawable
|
||||
.map((line) => {
|
||||
const breakSet = new Set(line.breaks ?? []);
|
||||
const parts: string[] = [];
|
||||
for (let i = 0; i < line.points.length; i++) {
|
||||
const [lat, lng] = line.points[i];
|
||||
const p = lngLatToPx(lng, lat, zoom);
|
||||
const px = p.x - originX;
|
||||
const py = p.y - originY;
|
||||
// Start a fresh sub-path at index 0 and at every stage break.
|
||||
const cmd = i === 0 || breakSet.has(i) ? 'M' : 'L';
|
||||
parts.push(cmd + escapeSvgNumber(px) + ',' + escapeSvgNumber(py));
|
||||
}
|
||||
return (
|
||||
`<path d="${parts.join(' ')}" fill="none" stroke="${line.color}" ` +
|
||||
`stroke-width="4" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.9"/>`
|
||||
);
|
||||
})
|
||||
.join('');
|
||||
|
||||
const overlay = Buffer.from(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
|
||||
paths +
|
||||
`</svg>`
|
||||
);
|
||||
|
||||
await sharp(mapBuf)
|
||||
.composite([{ input: overlay, left: 0, top: 0 }])
|
||||
.webp({ quality: 78 })
|
||||
.toFile(opts.outputPath);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -464,6 +464,99 @@ a:focus-visible {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
HIKES TRANSITIONS
|
||||
Cards + filter fly in/out vertically, clicked card morphs into the hero
|
||||
map (cross-fade between thumbnail and map), and the whole below-map panel
|
||||
(an opaque sheet) slides up from the bottom. Page chrome under the hero
|
||||
cross-fades so nothing snaps in at transition end. Lives in app.css (not
|
||||
the page component) so the rules are still loaded on the OLD side of a
|
||||
nav AWAY from /hikes.
|
||||
============================================ */
|
||||
|
||||
@keyframes hikes-fly-up {
|
||||
from { transform: translateY(100vh); }
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
@keyframes hikes-fly-down {
|
||||
from { transform: translateY(0); }
|
||||
to { transform: translateY(100vh); }
|
||||
}
|
||||
@keyframes hikes-root-fade-out {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
@keyframes hikes-root-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
/* Only-child hike-fly-in pseudos (unpaired cards/filter on enter or exit):
|
||||
* kill UA's default fade, switch blend mode so the custom fly animation
|
||||
* shows clean motion against the rest of the page. */
|
||||
::view-transition-old(.hike-fly-in):only-child,
|
||||
::view-transition-new(.hike-fly-in):only-child {
|
||||
animation: none;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
|
||||
/* Paired (card ↔ hero): keep UA cross-fade so the card thumbnail dissolves
|
||||
* into the hero map — otherwise the new image would just cover the old one
|
||||
* and the thumbnail would vanish silently at t=0. Stretch the duration to
|
||||
* match the group so the fade ends exactly when the morph does. */
|
||||
::view-transition-old(.hike-fly-in):not(:only-child),
|
||||
::view-transition-new(.hike-fly-in):not(:only-child) {
|
||||
animation-duration: 550ms;
|
||||
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Group (the morphing bbox) timing. */
|
||||
::view-transition-group(.hike-fly-in) {
|
||||
animation-duration: 550ms;
|
||||
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Cards + filter rise from below the viewport on enter. */
|
||||
html.vt-enter-hikes::view-transition-new(.hike-fly-in):only-child {
|
||||
animation: hikes-fly-up 700ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
|
||||
}
|
||||
|
||||
/* Cards + filter drop off the bottom on exit. */
|
||||
html.vt-exit-hikes::view-transition-old(.hike-fly-in):only-child {
|
||||
animation: hikes-fly-down 700ms cubic-bezier(0.4, 0.1, 0.4, 1) both;
|
||||
}
|
||||
|
||||
/* Everything below the hero map on a detail page — stage nav, photo strip,
|
||||
* metrics, tags, elevation chart, scroll area, meta footer — slides up from
|
||||
* the bottom on enter and back down on any exit, as one panel. The wrapper
|
||||
* carries `view-transition-name: hike-below-map` and an opaque background, so
|
||||
* the whole sheet (background included) moves; the hero map morphs separately
|
||||
* above, and the rest of the page chrome cross-fades via the root-pseudo rule. */
|
||||
html.vt-enter-hike-detail::view-transition-new(hike-below-map):only-child {
|
||||
animation: hikes-fly-up 700ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
|
||||
}
|
||||
html.vt-enter-hikes::view-transition-old(hike-below-map):only-child,
|
||||
html.vt-exit-hike-detail::view-transition-old(hike-below-map):only-child {
|
||||
animation: hikes-fly-down 700ms cubic-bezier(0.4, 0.1, 0.4, 1) both;
|
||||
}
|
||||
|
||||
/* Cross-fade the rest of the page (root pseudo) during hike transitions so
|
||||
* the destination's chrome — metrics + content + footer on the detail page,
|
||||
* overview hero + credit on the index — phases in instead of snapping in
|
||||
* at the end of the morph. Overrides the global rule above; scope keeps
|
||||
* other routes' transitions on their existing instant-swap behavior. */
|
||||
html.vt-enter-hike-detail::view-transition-old(root),
|
||||
html.vt-enter-hikes::view-transition-old(root),
|
||||
html.vt-exit-hikes::view-transition-old(root),
|
||||
html.vt-exit-hike-detail::view-transition-old(root) {
|
||||
animation: hikes-root-fade-out 450ms ease-out both;
|
||||
}
|
||||
html.vt-enter-hike-detail::view-transition-new(root),
|
||||
html.vt-enter-hikes::view-transition-new(root),
|
||||
html.vt-exit-hikes::view-transition-new(root),
|
||||
html.vt-exit-hike-detail::view-transition-new(root) {
|
||||
animation: hikes-root-fade-in 450ms ease-out both;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
RECIPE GRID
|
||||
Responsive card grid used across recipe pages
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
title: Flims Gletschermühlen
|
||||
date: 2024-07-14
|
||||
author: Alexander
|
||||
difficulty: T2
|
||||
tags: [Graubünden, Flims, Sommer]
|
||||
seasons: 5-8
|
||||
summary:
|
||||
heroAlt:
|
||||
---
|
||||
|
||||
<script>
|
||||
import HikeImage from '$lib/components/hikes/HikeImage.svelte';
|
||||
import JourneyPlanner from '$lib/components/hikes/JourneyPlanner.svelte';
|
||||
import Private from '$lib/components/Private.svelte';
|
||||
</script>
|
||||
|
||||
## Anreise
|
||||
|
||||
Start bei Bargis. Anreise am besten via Bus von Flims.
|
||||
<JourneyPlanner from="<current location>" to="Fidaz, Bargis" toFixed time="07:00" target="arrival"/>
|
||||
|
||||
<HikeImage idx={0} />
|
||||
<HikeImage idx={1} />
|
||||
<HikeImage idx={2} />
|
||||
<HikeImage idx={3} />
|
||||
<HikeImage idx={4} />
|
||||
<HikeImage idx={5} />
|
||||
<HikeImage idx={6} />
|
||||
<HikeImage idx={7} />
|
||||
<HikeImage idx={8} />
|
||||
<HikeImage idx={9} />
|
||||
<HikeImage idx={10} />
|
||||
<HikeImage idx={11} />
|
||||
<HikeImage idx={12} />
|
||||
<HikeImage idx={13} />
|
||||
<HikeImage idx={14} />
|
||||
<HikeImage idx={15} />
|
||||
<HikeImage idx={16} />
|
||||
<HikeImage idx={17} />
|
||||
<HikeImage idx={18} />
|
||||
<HikeImage idx={19} />
|
||||
<HikeImage idx={20} />
|
||||
<HikeImage idx={21} />
|
||||
<HikeImage idx={22} />
|
||||
<HikeImage idx={23} />
|
||||
<HikeImage idx={24} />
|
||||
<HikeImage idx={25} />
|
||||
<HikeImage idx={26} />
|
||||
<HikeImage idx={27} />
|
||||
<HikeImage idx={28} />
|
||||
<HikeImage idx={29} />
|
||||
<HikeImage idx={30} />
|
||||
<HikeImage idx={31} />
|
||||
<HikeImage idx={32} />
|
||||
<HikeImage idx={33} />
|
||||
<HikeImage idx={34} />
|
||||
<HikeImage idx={35} />
|
||||
<HikeImage idx={36} />
|
||||
<HikeImage idx={37} />
|
||||
<HikeImage idx={38} />
|
||||
<HikeImage idx={39} />
|
||||
<HikeImage idx={40} />
|
||||
<HikeImage idx={41} />
|
||||
<HikeImage idx={42} />
|
||||
<HikeImage idx={43} />
|
||||
<HikeImage idx={44} />
|
||||
<HikeImage idx={45} />
|
||||
<HikeImage idx={46} />
|
||||
<HikeImage idx={47} />
|
||||
<HikeImage idx={48} />
|
||||
<HikeImage idx={49} />
|
||||
<HikeImage idx={50} />
|
||||
<HikeImage idx={51} />
|
||||
<HikeImage idx={52} />
|
||||
<HikeImage idx={53} />
|
||||
<HikeImage idx={54} />
|
||||
<HikeImage idx={55} />
|
||||
<HikeImage idx={56} />
|
||||
<HikeImage idx={57} />
|
||||
<HikeImage idx={58} />
|
||||
<HikeImage idx={59} />
|
||||
<HikeImage idx={60} />
|
||||
<HikeImage idx={61} />
|
||||
<HikeImage idx={62} />
|
||||
<HikeImage idx={63} />
|
||||
<HikeImage idx={64} />
|
||||
<HikeImage idx={65} />
|
||||
<HikeImage idx={66} />
|
||||
<HikeImage idx={67} />
|
||||
<HikeImage idx={68} />
|
||||
<HikeImage idx={69} />
|
||||
<HikeImage idx={70} />
|
||||
<HikeImage idx={71} />
|
||||
<HikeImage idx={72} />
|
||||
<HikeImage idx={73} />
|
||||
<HikeImage idx={74} />
|
||||
<HikeImage idx={75} />
|
||||
|
||||
## Abreise
|
||||
Via Bus oder Auto wieder nach Hause. Wenn man nicht abgeholt wird wie wir, muss man noch etwas weiter laufen bis nach Trin.
|
||||
<JourneyPlanner from="Trin, Quadris" fromFixed to="<current location>" time="15:30" target="departure"/>
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
title: Schlittelausflug Brün
|
||||
date: 2024-12-25
|
||||
author: Alexander
|
||||
difficulty: T1
|
||||
tags: [Graubünden, Flims, Winter, Schlitteln]
|
||||
seasons: 12-2
|
||||
summary:
|
||||
heroAlt:
|
||||
---
|
||||
|
||||
<script>
|
||||
import HikeImage from '$lib/components/hikes/HikeImage.svelte';
|
||||
import JourneyPlanner from '$lib/components/hikes/JourneyPlanner.svelte';
|
||||
import Private from '$lib/components/Private.svelte';
|
||||
</script>
|
||||
|
||||
## Übersicht
|
||||
|
||||
Ein netter Ausflug zum Schlitteln, wenn man bereits in Flims ist.
|
||||
Aufstieg ca. 1 Stunde mit wunderschöner Winterlandschaft.
|
||||
|
||||
## Anreise
|
||||
|
||||
Start direkt in Brün. Eine Anreise mit Bus (Linie 404) ist möglich, ein direktes Anfahren mit Auto wäre jedoch zu empfehlen.
|
||||
Es empfiehlt sich ca. um 11 Uhr in Brün anzukommen, da durch die Nähe zum Piz Riein ausserhalb der Mittagszeit es schnell schattig werden kann.
|
||||
<JourneyPlanner from="<current location>" to="Valendas, Brün Dorf" toFixed time="11:00" target="arrival"/>
|
||||
|
||||
<HikeImage idx={0} />
|
||||
<HikeImage idx={1} />
|
||||
|
||||
<HikeImage idx={2} />
|
||||
<HikeImage idx={3} />
|
||||
|
||||
<HikeImage idx={4} />
|
||||
<HikeImage idx={5} />
|
||||
<HikeImage idx={6} />
|
||||
|
||||
<HikeImage idx={7} />
|
||||
<HikeImage idx={8} />
|
||||
<HikeImage idx={9} />
|
||||
<HikeImage idx={10} />
|
||||
|
||||
<HikeImage src="PXL_20241225_121635285.jpg" alt="Anna auf dem Weg runter" private />
|
||||
<HikeImage src="PXL_20241225_122938851.jpg" alt="Wieder in Brün" private />
|
||||
<HikeImage src="PXL_20241225_122942649.jpg" alt="Wieder in Brün" />
|
||||
|
||||
## Abreise
|
||||
Via Bus (Linie 404) oder Auto.
|
||||
<JourneyPlanner from="Valendas, Brün Dorf" fromFixed to="<current location>" time="12:30" target="departure"/>
|
||||
@@ -0,0 +1,917 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="Bocken Route Builder" xmlns="http://www.topografix.com/GPX/1/1" xmlns:bocken="https://bocken.org/gpx/v1">
|
||||
<wpt lat="46.778422" lon="9.30542">
|
||||
<ele>1289.9</ele>
|
||||
<time>2024-12-25T11:00:27.000Z</time>
|
||||
<extensions>
|
||||
<bocken:image hash="33736035" visibility="private"/>
|
||||
</extensions>
|
||||
</wpt>
|
||||
<wpt lat="46.781754" lon="9.304902">
|
||||
<ele>1321.3</ele>
|
||||
<time>2024-12-25T11:02:57.000Z</time>
|
||||
<extensions>
|
||||
<bocken:image hash="b50be014"/>
|
||||
</extensions>
|
||||
</wpt>
|
||||
<wpt lat="46.78252" lon="9.305407">
|
||||
<ele>1327.1</ele>
|
||||
<time>2024-12-25T11:04:19.000Z</time>
|
||||
<extensions>
|
||||
<bocken:image hash="be3138c8"/>
|
||||
</extensions>
|
||||
</wpt>
|
||||
<wpt lat="46.780856" lon="9.307209">
|
||||
<ele>1356.5</ele>
|
||||
<time>2024-12-25T11:09:30.000Z</time>
|
||||
<extensions>
|
||||
<bocken:image hash="d4b01559"/>
|
||||
</extensions>
|
||||
</wpt>
|
||||
<wpt lat="46.780487" lon="9.307823">
|
||||
<ele>1363.9</ele>
|
||||
<time>2024-12-25T11:11:11.000Z</time>
|
||||
<extensions>
|
||||
<bocken:image hash="64b8ebe0"/>
|
||||
</extensions>
|
||||
</wpt>
|
||||
<wpt lat="46.781086" lon="9.310293">
|
||||
<ele>1444.3</ele>
|
||||
<time>2024-12-25T11:24:33.000Z</time>
|
||||
<extensions>
|
||||
<bocken:image hash="ace73886"/>
|
||||
</extensions>
|
||||
</wpt>
|
||||
<wpt lat="46.781736" lon="9.310631">
|
||||
<ele>1453.9</ele>
|
||||
<time>2024-12-25T11:27:15.000Z</time>
|
||||
<extensions>
|
||||
<bocken:image hash="2e3de268"/>
|
||||
</extensions>
|
||||
</wpt>
|
||||
<wpt lat="46.781776" lon="9.311906">
|
||||
<ele>1495.3</ele>
|
||||
<time>2024-12-25T11:33:44.000Z</time>
|
||||
<extensions>
|
||||
<bocken:image hash="e8cd91ea"/>
|
||||
</extensions>
|
||||
</wpt>
|
||||
<wpt lat="46.77932" lon="9.314403">
|
||||
<ele>1540.4</ele>
|
||||
<time>2024-12-25T11:42:13.000Z</time>
|
||||
<extensions>
|
||||
<bocken:image hash="f03708bf"/>
|
||||
</extensions>
|
||||
</wpt>
|
||||
<wpt lat="46.777217" lon="9.317179">
|
||||
<ele>1580.1</ele>
|
||||
<time>2024-12-25T11:50:16.000Z</time>
|
||||
<extensions>
|
||||
<bocken:image hash="0bf223b8" visibility="private"/>
|
||||
</extensions>
|
||||
</wpt>
|
||||
<wpt lat="46.780061" lon="9.317093">
|
||||
<ele>1614.9</ele>
|
||||
<time>2024-12-25T12:00:12.000Z</time>
|
||||
<extensions>
|
||||
<bocken:image hash="b0be80dd"/>
|
||||
</extensions>
|
||||
</wpt>
|
||||
<trk>
|
||||
<name>Etappe 1</name>
|
||||
<trkseg>
|
||||
<trkpt lat="46.778422" lon="9.30542">
|
||||
<ele>1289.9</ele>
|
||||
<time>2024-12-25T11:00:27.000Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778416" lon="9.305474">
|
||||
<ele>1290.1</ele>
|
||||
<time>2024-12-25T11:00:28.504Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778416" lon="9.305543">
|
||||
<ele>1290.3</ele>
|
||||
<time>2024-12-25T11:00:30.402Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.77843" lon="9.305573">
|
||||
<ele>1290.3</ele>
|
||||
<time>2024-12-25T11:00:31.400Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778532" lon="9.305683">
|
||||
<ele>1291.2</ele>
|
||||
<time>2024-12-25T11:00:36.492Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778563" lon="9.305731">
|
||||
<ele>1291.5</ele>
|
||||
<time>2024-12-25T11:00:38.307Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778709" lon="9.305979">
|
||||
<ele>1293.7</ele>
|
||||
<time>2024-12-25T11:00:47.301Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778762" lon="9.306037">
|
||||
<ele>1294.3</ele>
|
||||
<time>2024-12-25T11:00:49.961Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778818" lon="9.306061">
|
||||
<ele>1294.8</ele>
|
||||
<time>2024-12-25T11:00:52.305Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778869" lon="9.306064">
|
||||
<ele>1295.2</ele>
|
||||
<time>2024-12-25T11:00:54.355Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779004" lon="9.306009">
|
||||
<ele>1296.5</ele>
|
||||
<time>2024-12-25T11:00:59.983Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779116" lon="9.305952">
|
||||
<ele>1297.1</ele>
|
||||
<time>2024-12-25T11:01:04.747Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779221" lon="9.305905">
|
||||
<ele>1297.3</ele>
|
||||
<time>2024-12-25T11:01:09.157Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779305" lon="9.305897">
|
||||
<ele>1297.5</ele>
|
||||
<time>2024-12-25T11:01:12.538Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779651" lon="9.305939">
|
||||
<ele>1301.6</ele>
|
||||
<time>2024-12-25T11:01:26.481Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779773" lon="9.305926">
|
||||
<ele>1303.0</ele>
|
||||
<time>2024-12-25T11:01:31.394Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779851" lon="9.305896">
|
||||
<ele>1304.2</ele>
|
||||
<time>2024-12-25T11:01:34.633Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779954" lon="9.305841">
|
||||
<ele>1305.4</ele>
|
||||
<time>2024-12-25T11:01:39.037Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780225" lon="9.30561">
|
||||
<ele>1309.0</ele>
|
||||
<time>2024-12-25T11:01:51.639Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780626" lon="9.305371">
|
||||
<ele>1313.5</ele>
|
||||
<time>2024-12-25T11:02:09.033Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780671" lon="9.305355">
|
||||
<ele>1314.0</ele>
|
||||
<time>2024-12-25T11:02:10.893Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780816" lon="9.305363">
|
||||
<ele>1314.8</ele>
|
||||
<time>2024-12-25T11:02:16.720Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780929" lon="9.30533">
|
||||
<ele>1315.3</ele>
|
||||
<time>2024-12-25T11:02:21.348Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780989" lon="9.305304">
|
||||
<ele>1315.5</ele>
|
||||
<time>2024-12-25T11:02:23.861Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781243" lon="9.305123">
|
||||
<ele>1316.5</ele>
|
||||
<time>2024-12-25T11:02:35.212Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781504" lon="9.304991">
|
||||
<ele>1318.9</ele>
|
||||
<time>2024-12-25T11:02:46.304Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781617" lon="9.30491">
|
||||
<ele>1320.1</ele>
|
||||
<time>2024-12-25T11:02:51.360Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781668" lon="9.304888">
|
||||
<ele>1320.6</ele>
|
||||
<time>2024-12-25T11:02:53.495Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781706" lon="9.304886">
|
||||
<ele>1320.9</ele>
|
||||
<time>2024-12-25T11:02:55.022Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781754" lon="9.304902">
|
||||
<ele>1321.3</ele>
|
||||
<time>2024-12-25T11:02:57.000Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781813" lon="9.304937">
|
||||
<ele>1321.8</ele>
|
||||
<time>2024-12-25T11:03:02.723Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.78206" lon="9.305146">
|
||||
<ele>1323.5</ele>
|
||||
<time>2024-12-25T11:03:28.379Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782117" lon="9.305184">
|
||||
<ele>1323.9</ele>
|
||||
<time>2024-12-25T11:03:34.011Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782334" lon="9.305263">
|
||||
<ele>1325.3</ele>
|
||||
<time>2024-12-25T11:03:54.110Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.78248" lon="9.305283">
|
||||
<ele>1326.1</ele>
|
||||
<time>2024-12-25T11:04:07.290Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782509" lon="9.305298">
|
||||
<ele>1326.4</ele>
|
||||
<time>2024-12-25T11:04:10.055Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782535" lon="9.30533">
|
||||
<ele>1326.6</ele>
|
||||
<time>2024-12-25T11:04:13.111Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782546" lon="9.305368">
|
||||
<ele>1326.8</ele>
|
||||
<time>2024-12-25T11:04:15.650Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.78252" lon="9.305407">
|
||||
<ele>1327.1</ele>
|
||||
<time>2024-12-25T11:04:19.000Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782529" lon="9.305446">
|
||||
<ele>1327.2</ele>
|
||||
<time>2024-12-25T11:04:22.651Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782505" lon="9.305472">
|
||||
<ele>1327.5</ele>
|
||||
<time>2024-12-25T11:04:26.523Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782475" lon="9.305481">
|
||||
<ele>1327.8</ele>
|
||||
<time>2024-12-25T11:04:30.491Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782437" lon="9.305473">
|
||||
<ele>1327.9</ele>
|
||||
<time>2024-12-25T11:04:35.466Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782332" lon="9.305419">
|
||||
<ele>1328.5</ele>
|
||||
<time>2024-12-25T11:04:49.890Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782273" lon="9.305398">
|
||||
<ele>1328.9</ele>
|
||||
<time>2024-12-25T11:04:57.758Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782008" lon="9.305388">
|
||||
<ele>1331.5</ele>
|
||||
<time>2024-12-25T11:05:32.106Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781851" lon="9.30536">
|
||||
<ele>1333.6</ele>
|
||||
<time>2024-12-25T11:05:52.600Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781795" lon="9.305358">
|
||||
<ele>1334.5</ele>
|
||||
<time>2024-12-25T11:05:59.858Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781736" lon="9.305378">
|
||||
<ele>1335.4</ele>
|
||||
<time>2024-12-25T11:06:07.706Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781714" lon="9.305402">
|
||||
<ele>1335.8</ele>
|
||||
<time>2024-12-25T11:06:11.264Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781688" lon="9.30546">
|
||||
<ele>1336.6</ele>
|
||||
<time>2024-12-25T11:06:17.415Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781662" lon="9.305689">
|
||||
<ele>1338.7</ele>
|
||||
<time>2024-12-25T11:06:38.011Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781641" lon="9.305796">
|
||||
<ele>1339.7</ele>
|
||||
<time>2024-12-25T11:06:47.887Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781615" lon="9.305873">
|
||||
<ele>1340.4</ele>
|
||||
<time>2024-12-25T11:06:55.505Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781592" lon="9.305919">
|
||||
<ele>1341.0</ele>
|
||||
<time>2024-12-25T11:07:00.559Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781448" lon="9.306123">
|
||||
<ele>1343.6</ele>
|
||||
<time>2024-12-25T11:07:26.554Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781411" lon="9.306188">
|
||||
<ele>1344.4</ele>
|
||||
<time>2024-12-25T11:07:34.054Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781245" lon="9.306508">
|
||||
<ele>1348.1</ele>
|
||||
<time>2024-12-25T11:08:09.674Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781134" lon="9.306686">
|
||||
<ele>1350.4</ele>
|
||||
<time>2024-12-25T11:08:31.035Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781063" lon="9.306817">
|
||||
<ele>1351.9</ele>
|
||||
<time>2024-12-25T11:08:45.859Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780882" lon="9.307183">
|
||||
<ele>1356.1</ele>
|
||||
<time>2024-12-25T11:09:25.916Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780856" lon="9.307209">
|
||||
<ele>1356.5</ele>
|
||||
<time>2024-12-25T11:09:30.000Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780806" lon="9.307293">
|
||||
<ele>1357.5</ele>
|
||||
<time>2024-12-25T11:09:43.673Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780634" lon="9.307515">
|
||||
<ele>1360.3</ele>
|
||||
<time>2024-12-25T11:10:24.855Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780549" lon="9.307703">
|
||||
<ele>1362.4</ele>
|
||||
<time>2024-12-25T11:10:52.532Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780487" lon="9.307823">
|
||||
<ele>1363.9</ele>
|
||||
<time>2024-12-25T11:11:11.000Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780461" lon="9.30794">
|
||||
<ele>1365.1</ele>
|
||||
<time>2024-12-25T11:11:21.979Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780403" lon="9.308115">
|
||||
<ele>1366.9</ele>
|
||||
<time>2024-12-25T11:11:39.333Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780344" lon="9.308364">
|
||||
<ele>1368.9</ele>
|
||||
<time>2024-12-25T11:12:02.851Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780309" lon="9.308474">
|
||||
<ele>1370.0</ele>
|
||||
<time>2024-12-25T11:12:13.678Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780172" lon="9.308786">
|
||||
<ele>1373.6</ele>
|
||||
<time>2024-12-25T11:12:46.761Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.78013" lon="9.308907">
|
||||
<ele>1374.7</ele>
|
||||
<time>2024-12-25T11:12:58.870Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780088" lon="9.309049">
|
||||
<ele>1376.1</ele>
|
||||
<time>2024-12-25T11:13:12.676Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780027" lon="9.309317">
|
||||
<ele>1378.7</ele>
|
||||
<time>2024-12-25T11:13:37.885Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779979" lon="9.30956">
|
||||
<ele>1381.0</ele>
|
||||
<time>2024-12-25T11:14:00.460Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779951" lon="9.309722">
|
||||
<ele>1382.5</ele>
|
||||
<time>2024-12-25T11:14:15.373Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779923" lon="9.309842">
|
||||
<ele>1383.7</ele>
|
||||
<time>2024-12-25T11:14:26.689Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779794" lon="9.310237">
|
||||
<ele>1388.0</ele>
|
||||
<time>2024-12-25T11:15:05.752Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779698" lon="9.310591">
|
||||
<ele>1391.1</ele>
|
||||
<time>2024-12-25T11:15:39.737Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.77967" lon="9.310656">
|
||||
<ele>1391.9</ele>
|
||||
<time>2024-12-25T11:15:46.592Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779627" lon="9.310722">
|
||||
<ele>1392.6</ele>
|
||||
<time>2024-12-25T11:15:54.723Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779532" lon="9.3108">
|
||||
<ele>1394.3</ele>
|
||||
<time>2024-12-25T11:16:08.929Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779468" lon="9.310839">
|
||||
<ele>1395.0</ele>
|
||||
<time>2024-12-25T11:16:17.969Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779411" lon="9.310862">
|
||||
<ele>1395.8</ele>
|
||||
<time>2024-12-25T11:16:25.677Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779353" lon="9.310881">
|
||||
<ele>1396.5</ele>
|
||||
<time>2024-12-25T11:16:33.425Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778964" lon="9.310958">
|
||||
<ele>1402.1</ele>
|
||||
<time>2024-12-25T11:17:24.593Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778797" lon="9.311025">
|
||||
<ele>1404.1</ele>
|
||||
<time>2024-12-25T11:17:47.167Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778656" lon="9.311056">
|
||||
<ele>1405.3</ele>
|
||||
<time>2024-12-25T11:18:05.753Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778631" lon="9.311068">
|
||||
<ele>1405.4</ele>
|
||||
<time>2024-12-25T11:18:09.183Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778612" lon="9.311084">
|
||||
<ele>1405.6</ele>
|
||||
<time>2024-12-25T11:18:12.042Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778598" lon="9.311108">
|
||||
<ele>1405.8</ele>
|
||||
<time>2024-12-25T11:18:14.856Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778598" lon="9.31116">
|
||||
<ele>1406.2</ele>
|
||||
<time>2024-12-25T11:18:19.498Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778609" lon="9.311186">
|
||||
<ele>1406.3</ele>
|
||||
<time>2024-12-25T11:18:22.226Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778629" lon="9.31121">
|
||||
<ele>1406.4</ele>
|
||||
<time>2024-12-25T11:18:25.600Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778651" lon="9.311221">
|
||||
<ele>1406.6</ele>
|
||||
<time>2024-12-25T11:18:28.631Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.77884" lon="9.311188">
|
||||
<ele>1408.2</ele>
|
||||
<time>2024-12-25T11:18:53.442Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778933" lon="9.3112">
|
||||
<ele>1409.4</ele>
|
||||
<time>2024-12-25T11:19:05.611Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778978" lon="9.311215">
|
||||
<ele>1409.9</ele>
|
||||
<time>2024-12-25T11:19:11.628Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779154" lon="9.311334">
|
||||
<ele>1412.5</ele>
|
||||
<time>2024-12-25T11:19:36.908Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779327" lon="9.311421">
|
||||
<ele>1415.2</ele>
|
||||
<time>2024-12-25T11:20:00.758Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779497" lon="9.311468">
|
||||
<ele>1417.4</ele>
|
||||
<time>2024-12-25T11:20:23.310Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779841" lon="9.311512">
|
||||
<ele>1422.3</ele>
|
||||
<time>2024-12-25T11:21:08.321Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779942" lon="9.311522">
|
||||
<ele>1423.7</ele>
|
||||
<time>2024-12-25T11:21:21.516Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780057" lon="9.311506">
|
||||
<ele>1425.2</ele>
|
||||
<time>2024-12-25T11:21:36.573Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780112" lon="9.311482">
|
||||
<ele>1426.0</ele>
|
||||
<time>2024-12-25T11:21:44.056Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780204" lon="9.311423">
|
||||
<ele>1427.5</ele>
|
||||
<time>2024-12-25T11:21:57.153Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.78027" lon="9.311369">
|
||||
<ele>1428.7</ele>
|
||||
<time>2024-12-25T11:22:07.014Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.78032" lon="9.311315">
|
||||
<ele>1429.5</ele>
|
||||
<time>2024-12-25T11:22:15.120Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780368" lon="9.311245">
|
||||
<ele>1430.4</ele>
|
||||
<time>2024-12-25T11:22:23.962Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780629" lon="9.310815">
|
||||
<ele>1435.7</ele>
|
||||
<time>2024-12-25T11:23:15.251Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780899" lon="9.310423">
|
||||
<ele>1441.3</ele>
|
||||
<time>2024-12-25T11:24:04.878Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780968" lon="9.310341">
|
||||
<ele>1442.6</ele>
|
||||
<time>2024-12-25T11:24:16.474Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781011" lon="9.310305">
|
||||
<ele>1443.2</ele>
|
||||
<time>2024-12-25T11:24:22.934Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781048" lon="9.310287">
|
||||
<ele>1443.8</ele>
|
||||
<time>2024-12-25T11:24:28.018Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781086" lon="9.310293">
|
||||
<ele>1444.3</ele>
|
||||
<time>2024-12-25T11:24:33.000Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.78109" lon="9.310279">
|
||||
<ele>1444.6</ele>
|
||||
<time>2024-12-25T11:24:35.394Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781125" lon="9.310282">
|
||||
<ele>1444.8</ele>
|
||||
<time>2024-12-25T11:24:43.475Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.78119" lon="9.310304">
|
||||
<ele>1445.8</ele>
|
||||
<time>2024-12-25T11:24:58.853Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781608" lon="9.310537">
|
||||
<ele>1451.9</ele>
|
||||
<time>2024-12-25T11:26:41.977Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781736" lon="9.310631">
|
||||
<ele>1453.9</ele>
|
||||
<time>2024-12-25T11:27:15.000Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781992" lon="9.310887">
|
||||
<ele>1458.1</ele>
|
||||
<time>2024-12-25T11:27:54.242Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782265" lon="9.311167">
|
||||
<ele>1463.0</ele>
|
||||
<time>2024-12-25T11:28:36.436Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782316" lon="9.311231">
|
||||
<ele>1463.9</ele>
|
||||
<time>2024-12-25T11:28:44.941Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782351" lon="9.311289">
|
||||
<ele>1464.6</ele>
|
||||
<time>2024-12-25T11:28:51.636Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782389" lon="9.311378">
|
||||
<ele>1465.7</ele>
|
||||
<time>2024-12-25T11:29:00.720Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782576" lon="9.311913">
|
||||
<ele>1471.2</ele>
|
||||
<time>2024-12-25T11:29:52.743Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782683" lon="9.312188">
|
||||
<ele>1474.0</ele>
|
||||
<time>2024-12-25T11:30:20.137Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782742" lon="9.312381">
|
||||
<ele>1475.9</ele>
|
||||
<time>2024-12-25T11:30:38.442Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782786" lon="9.312543">
|
||||
<ele>1477.2</ele>
|
||||
<time>2024-12-25T11:30:53.536Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782798" lon="9.312602">
|
||||
<ele>1477.6</ele>
|
||||
<time>2024-12-25T11:30:58.867Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782798" lon="9.312636">
|
||||
<ele>1477.8</ele>
|
||||
<time>2024-12-25T11:31:01.812Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782789" lon="9.312662">
|
||||
<ele>1477.9</ele>
|
||||
<time>2024-12-25T11:31:04.335Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782746" lon="9.312686">
|
||||
<ele>1478.3</ele>
|
||||
<time>2024-12-25T11:31:10.157Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782721" lon="9.312689">
|
||||
<ele>1478.7</ele>
|
||||
<time>2024-12-25T11:31:13.330Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782699" lon="9.312678">
|
||||
<ele>1478.9</ele>
|
||||
<time>2024-12-25T11:31:16.271Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782678" lon="9.312654">
|
||||
<ele>1479.2</ele>
|
||||
<time>2024-12-25T11:31:19.643Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.78246" lon="9.312333">
|
||||
<ele>1483.2</ele>
|
||||
<time>2024-12-25T11:31:58.799Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782285" lon="9.312031">
|
||||
<ele>1487.3</ele>
|
||||
<time>2024-12-25T11:32:33.063Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782246" lon="9.311979">
|
||||
<ele>1488.2</ele>
|
||||
<time>2024-12-25T11:32:39.743Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782196" lon="9.31192">
|
||||
<ele>1489.2</ele>
|
||||
<time>2024-12-25T11:32:47.873Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782137" lon="9.311873">
|
||||
<ele>1490.0</ele>
|
||||
<time>2024-12-25T11:32:56.373Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782088" lon="9.311841">
|
||||
<ele>1490.7</ele>
|
||||
<time>2024-12-25T11:33:03.162Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.782035" lon="9.311823">
|
||||
<ele>1491.5</ele>
|
||||
<time>2024-12-25T11:33:10.045Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781976" lon="9.311818">
|
||||
<ele>1492.1</ele>
|
||||
<time>2024-12-25T11:33:17.519Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.78192" lon="9.311833">
|
||||
<ele>1492.8</ele>
|
||||
<time>2024-12-25T11:33:24.720Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781776" lon="9.311906">
|
||||
<ele>1495.3</ele>
|
||||
<time>2024-12-25T11:33:44.000Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781729" lon="9.311954">
|
||||
<ele>1496.1</ele>
|
||||
<time>2024-12-25T11:33:53.503Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781335" lon="9.312383">
|
||||
<ele>1503.6</ele>
|
||||
<time>2024-12-25T11:35:14.942Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781145" lon="9.312623">
|
||||
<ele>1507.2</ele>
|
||||
<time>2024-12-25T11:35:56.570Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781083" lon="9.312733">
|
||||
<ele>1508.8</ele>
|
||||
<time>2024-12-25T11:36:12.736Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.781002" lon="9.31291">
|
||||
<ele>1511.1</ele>
|
||||
<time>2024-12-25T11:36:36.893Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.78095" lon="9.312999">
|
||||
<ele>1512.4</ele>
|
||||
<time>2024-12-25T11:36:50.168Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780902" lon="9.31306">
|
||||
<ele>1513.2</ele>
|
||||
<time>2024-12-25T11:37:00.712Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.78086" lon="9.31309">
|
||||
<ele>1514.1</ele>
|
||||
<time>2024-12-25T11:37:08.460Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780694" lon="9.31326">
|
||||
<ele>1517.2</ele>
|
||||
<time>2024-12-25T11:37:42.057Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780601" lon="9.313378">
|
||||
<ele>1519.0</ele>
|
||||
<time>2024-12-25T11:38:02.472Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.78048" lon="9.313565">
|
||||
<ele>1521.6</ele>
|
||||
<time>2024-12-25T11:38:31.666Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780447" lon="9.313604">
|
||||
<ele>1522.2</ele>
|
||||
<time>2024-12-25T11:38:38.701Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780285" lon="9.313719">
|
||||
<ele>1524.7</ele>
|
||||
<time>2024-12-25T11:39:08.549Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780238" lon="9.313764">
|
||||
<ele>1525.6</ele>
|
||||
<time>2024-12-25T11:39:17.862Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780095" lon="9.313951">
|
||||
<ele>1528.0</ele>
|
||||
<time>2024-12-25T11:39:49.670Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780029" lon="9.314036">
|
||||
<ele>1529.3</ele>
|
||||
<time>2024-12-25T11:40:04.252Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779972" lon="9.314074">
|
||||
<ele>1530.3</ele>
|
||||
<time>2024-12-25T11:40:14.635Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.77993" lon="9.314088">
|
||||
<ele>1530.8</ele>
|
||||
<time>2024-12-25T11:40:21.774Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.77964" lon="9.314135">
|
||||
<ele>1535.1</ele>
|
||||
<time>2024-12-25T11:41:10.123Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779527" lon="9.31417">
|
||||
<ele>1536.7</ele>
|
||||
<time>2024-12-25T11:41:29.265Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779487" lon="9.314194">
|
||||
<ele>1537.3</ele>
|
||||
<time>2024-12-25T11:41:36.431Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779441" lon="9.314237">
|
||||
<ele>1538.0</ele>
|
||||
<time>2024-12-25T11:41:45.481Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779396" lon="9.314295">
|
||||
<ele>1539.0</ele>
|
||||
<time>2024-12-25T11:41:55.427Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.77932" lon="9.314403">
|
||||
<ele>1540.4</ele>
|
||||
<time>2024-12-25T11:42:13.000Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779313" lon="9.314438">
|
||||
<ele>1540.8</ele>
|
||||
<time>2024-12-25T11:42:16.830Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.77928" lon="9.314478">
|
||||
<ele>1541.3</ele>
|
||||
<time>2024-12-25T11:42:23.410Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779212" lon="9.314531">
|
||||
<ele>1542.4</ele>
|
||||
<time>2024-12-25T11:42:35.235Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779139" lon="9.314551">
|
||||
<ele>1543.4</ele>
|
||||
<time>2024-12-25T11:42:46.630Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778993" lon="9.314513">
|
||||
<ele>1545.5</ele>
|
||||
<time>2024-12-25T11:43:09.381Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778945" lon="9.314511">
|
||||
<ele>1546.2</ele>
|
||||
<time>2024-12-25T11:43:16.748Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778818" lon="9.314557">
|
||||
<ele>1547.9</ele>
|
||||
<time>2024-12-25T11:43:36.823Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778609" lon="9.314607">
|
||||
<ele>1550.6</ele>
|
||||
<time>2024-12-25T11:44:09.314Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.77849" lon="9.314662">
|
||||
<ele>1552.3</ele>
|
||||
<time>2024-12-25T11:44:28.463Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778114" lon="9.314981">
|
||||
<ele>1558.2</ele>
|
||||
<time>2024-12-25T11:45:35.177Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778062" lon="9.315019">
|
||||
<ele>1558.7</ele>
|
||||
<time>2024-12-25T11:45:44.097Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.777899" lon="9.315109">
|
||||
<ele>1561.1</ele>
|
||||
<time>2024-12-25T11:46:10.832Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.777872" lon="9.315135">
|
||||
<ele>1561.6</ele>
|
||||
<time>2024-12-25T11:46:15.794Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.777853" lon="9.315164">
|
||||
<ele>1562.0</ele>
|
||||
<time>2024-12-25T11:46:20.011Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.77783" lon="9.315238">
|
||||
<ele>1562.8</ele>
|
||||
<time>2024-12-25T11:46:28.548Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.777777" lon="9.315595">
|
||||
<ele>1566.2</ele>
|
||||
<time>2024-12-25T11:47:06.927Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.777752" lon="9.315719">
|
||||
<ele>1567.4</ele>
|
||||
<time>2024-12-25T11:47:20.508Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.777724" lon="9.315821">
|
||||
<ele>1568.2</ele>
|
||||
<time>2024-12-25T11:47:32.053Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.777662" lon="9.315984">
|
||||
<ele>1569.9</ele>
|
||||
<time>2024-12-25T11:47:51.643Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.777446" lon="9.316396">
|
||||
<ele>1573.9</ele>
|
||||
<time>2024-12-25T11:48:46.157Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.777371" lon="9.316581">
|
||||
<ele>1575.4</ele>
|
||||
<time>2024-12-25T11:49:08.744Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.777344" lon="9.316674">
|
||||
<ele>1576.2</ele>
|
||||
<time>2024-12-25T11:49:19.357Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.777256" lon="9.317067">
|
||||
<ele>1579.0</ele>
|
||||
<time>2024-12-25T11:50:02.799Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.777217" lon="9.317179">
|
||||
<ele>1580.1</ele>
|
||||
<time>2024-12-25T11:50:16.000Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.777218" lon="9.317208">
|
||||
<ele>1580.3</ele>
|
||||
<time>2024-12-25T11:50:20.041Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.77726" lon="9.31725">
|
||||
<ele>1579.6</ele>
|
||||
<time>2024-12-25T11:50:30.385Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.777316" lon="9.317282">
|
||||
<ele>1578.8</ele>
|
||||
<time>2024-12-25T11:50:42.606Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.777683" lon="9.317418">
|
||||
<ele>1580.2</ele>
|
||||
<time>2024-12-25T11:51:59.551Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.777914" lon="9.317508">
|
||||
<ele>1583.2</ele>
|
||||
<time>2024-12-25T11:52:48.136Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778206" lon="9.317509">
|
||||
<ele>1590.3</ele>
|
||||
<time>2024-12-25T11:53:47.475Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.778592" lon="9.317407">
|
||||
<ele>1598.0</ele>
|
||||
<time>2024-12-25T11:55:07.191Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.77922" lon="9.317416">
|
||||
<ele>1607.9</ele>
|
||||
<time>2024-12-25T11:57:14.818Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.779428" lon="9.317344">
|
||||
<ele>1609.4</ele>
|
||||
<time>2024-12-25T11:57:58.258Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.77965" lon="9.317225">
|
||||
<ele>1612.7</ele>
|
||||
<time>2024-12-25T11:58:46.316Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.77985" lon="9.31718">
|
||||
<ele>1613.6</ele>
|
||||
<time>2024-12-25T11:59:27.439Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780045" lon="9.317101">
|
||||
<ele>1614.9</ele>
|
||||
<time>2024-12-25T12:00:08.563Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780061" lon="9.317093">
|
||||
<ele>1614.9</ele>
|
||||
<time>2024-12-25T12:00:12.000Z</time>
|
||||
</trkpt>
|
||||
<trkpt lat="46.780092" lon="9.317082">
|
||||
<ele>1614.9</ele>
|
||||
<time>2024-12-25T12:02:09.000Z</time>
|
||||
</trkpt>
|
||||
</trkseg>
|
||||
</trk>
|
||||
</gpx>
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
title: Monte Generosa
|
||||
date: 2024-09-02
|
||||
author: Alexander
|
||||
difficulty: T4
|
||||
tags: [Tessin, Schweiz, Sommer, Schwierig]
|
||||
seasons: 5-9
|
||||
summary: Eine anspruchsvolle aber kurze Gipfelbesteigung
|
||||
heroAlt: Blick auf die Felswand, den schwierigsten Teil der Route
|
||||
---
|
||||
|
||||
<script>
|
||||
import HikeImage from '$lib/components/hikes/HikeImage.svelte';
|
||||
import JourneyPlanner from '$lib/components/hikes/JourneyPlanner.svelte';
|
||||
</script>
|
||||
|
||||
## Übersicht
|
||||
|
||||
Eine tiefe T4-Wanderung mit starkem Anstieg konsistent. Der letzte Aufstieg ist sehr exponiert, benötigt Hände und hat die Gefahr eines über 100 m Absturz. Nichts für Bergsteigeranfänger.
|
||||
Es empfiehlt sich, das Wetter explizit für Monte Generosa zu überprüfen.
|
||||
Wir wurden im schwierigsten Teil von Regen überrascht, was wohl zur nervenzerreibendsten Wanderung meines Lebens geführt hat.
|
||||
|
||||
## Anreise
|
||||
|
||||
Anreise via Bus (Linie 541) nach Rovio, Paese.
|
||||
<JourneyPlanner from="<current location>" to="Rovio, Paese" toFixed target="arrival" time="09:00" />
|
||||
|
||||
## Anfang
|
||||
|
||||
Man fängt in Rovio an und geniesst noch kurz die engen Gassen des Dorfes.
|
||||
<HikeImage idx={0} />
|
||||
<HikeImage idx={1} />
|
||||
|
||||
Dann fängt der erste Waldteil an. Anfangs noch auf einem recht guten Waldweg. Später geht es über zu einem Pfad mit einem deutlich stärkeren Anstieg. Der Pfad ist sehr gut markiert.
|
||||
<HikeImage idx={2} />
|
||||
<HikeImage idx={3} />
|
||||
<HikeImage idx={4} />
|
||||
<HikeImage idx={5} />
|
||||
<HikeImage idx={6} />
|
||||
<HikeImage idx={7} />
|
||||
<HikeImage idx={8} />
|
||||
<HikeImage idx={9} />
|
||||
|
||||
## Letzte Rast
|
||||
|
||||
Vor dem eigentlichen T4-Stück kann man sich noch gut bei einer kleinen Hütte ausruhen und die schöne Aussicht geniessen.
|
||||
Wie auf den Bildern zu sehen, waren hier bereits grössere Wolken voll um uns herum.
|
||||
|
||||
<HikeImage idx={10} />
|
||||
<HikeImage idx={11} />
|
||||
<HikeImage idx={12} />
|
||||
<HikeImage idx={13} />
|
||||
<HikeImage idx={14} />
|
||||
<HikeImage idx={15} />
|
||||
<HikeImage idx={16} />
|
||||
|
||||
Blick auf die Steilwand, welche man links vom Bild besteigen wird.
|
||||
|
||||
<HikeImage idx={17} />
|
||||
|
||||
Wegen starkem Regen und Schwierigkeit der Strecke fehlen hier Bilder.
|
||||
|
||||
## Ankunft auf der Bergstation
|
||||
|
||||
Oben auf der Station angekommen, wurde das Wetter wieder besser und hat einen schönen Ausblick ermöglicht.
|
||||
Ein guter Punkt, um ein ordentliches Dessert zu geniessen.
|
||||
|
||||
<HikeImage idx={18} />
|
||||
<HikeImage idx={19} />
|
||||
<HikeImage idx={20} />
|
||||
|
||||
## Heimreise
|
||||
|
||||
Von der Bergstation fährt eine Zahnradbahn nach Capolago-Riva S.Vitale. Von dort via Zug geht es schnell nach Lugano oder zu anderen Orten.
|
||||
<JourneyPlanner from="Generoso Vetta" to="<current location>" fromFixed target=departure time="14:30" />
|
||||
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,68 @@
|
||||
---
|
||||
title: Spaziergang um den Pfäffikersee
|
||||
date: 2024-07-27
|
||||
author: Alexander
|
||||
difficulty: T1
|
||||
tags: [Zürich, See, Familie, Sommer]
|
||||
summary: Ein entspannter Spaziergang zum Juckerhof um den Pfäffikersee.
|
||||
heroAlt: Blick auf den Kirchturm Seegräben durch das Schilf
|
||||
seasons: 4-9
|
||||
---
|
||||
|
||||
<script>
|
||||
import HikeImage from '$lib/components/hikes/HikeImage.svelte';
|
||||
import JourneyPlanner from '$lib/components/hikes/JourneyPlanner.svelte';
|
||||
</script>
|
||||
|
||||
## Übersicht
|
||||
|
||||
Ein wunderschöner Spaziergang um den Pfäffikersee mit Ziel Juckerhof.
|
||||
Dort kann man ein kleines Picknick geniessen und auch z.B. selber Heidelbeeren pflücken.
|
||||
|
||||
## Anfahrt
|
||||
|
||||
Anreise via Zug nach Pfäffikon, ZH.
|
||||
<JourneyPlanner from="<current location>" to="Pfäffikon ZH, Bahnhof" toFixed time="09:00" target="arrival" />
|
||||
|
||||
<HikeImage idx={0} />
|
||||
<HikeImage idx={1} />
|
||||
<HikeImage idx={2} />
|
||||
|
||||
## Römisches Kastell
|
||||
|
||||
Auf dem Weg kommt man an einer Ruine einer römischen Festungsanlage vorbei.
|
||||
<HikeImage idx={3} />
|
||||
<HikeImage idx={4} />
|
||||
<HikeImage idx={5} />
|
||||
<HikeImage idx={6} />
|
||||
<HikeImage idx={7} />
|
||||
<HikeImage idx={8} />
|
||||
<HikeImage idx={9} />
|
||||
<HikeImage idx={10} />
|
||||
<HikeImage idx={11} />
|
||||
<HikeImage idx={12} />
|
||||
<HikeImage idx={13} />
|
||||
<HikeImage idx={14} />
|
||||
|
||||
## Juckerhof
|
||||
|
||||
Nach ca. 1,5 Stunden ist das eigentliche Ziel des Spaziergangs erreicht: der Juckerhof.
|
||||
Hier kann man eine kleine Verpflegung sich direkt am Hof holen und auf den hübschen Wiesen geniessen.
|
||||
|
||||
<HikeImage idx={15} />
|
||||
<HikeImage idx={16} />
|
||||
<HikeImage idx={17} />
|
||||
<HikeImage idx={18} />
|
||||
<HikeImage idx={19} />
|
||||
|
||||
### Heidelbeerpflücken
|
||||
|
||||
Es ist auch möglich selber Heidelbeeren zu pflücken und die hübsche Aussicht zu geniessen.
|
||||
<HikeImage src="PXL_20240727_113858845.jpg" />
|
||||
<HikeImage src="PXL_20240727_113854798.jpg" />
|
||||
<HikeImage src="PXL_20240727_113902155.jpg" private />
|
||||
|
||||
## Abreise
|
||||
|
||||
Via Zug von Aathal, 30 Minuten vom Juckerhof.
|
||||
<JourneyPlanner from="Aathal" to="<current location>" fromFixed time="14:00" target="departure" />
|
||||
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,74 @@
|
||||
---
|
||||
title: Wanderung durch das Verzascatal
|
||||
date: 2024-09-01
|
||||
author: Alexander
|
||||
difficulty: T1
|
||||
tags: [Tessin, Panorama, Familie, Spätsommer]
|
||||
summary: Eine schöne Aussichtstour durch das Tessin... wenn das Wetter hält
|
||||
heroAlt:
|
||||
seasons: 5-9
|
||||
---
|
||||
|
||||
<script>
|
||||
import HikeImage from '$lib/components/hikes/HikeImage.svelte';
|
||||
import JourneyPlanner from '$lib/components/hikes/JourneyPlanner.svelte';
|
||||
</script>
|
||||
|
||||
## Anfahrt
|
||||
|
||||
Anreise von Lugano via Bus nach Lavertezzo, Ai Poss.
|
||||
<JourneyPlanner from="<current location>" to="Lavertezzo, Ai Poss" toFixed time="09:00" target="arrival" />
|
||||
|
||||
<HikeImage idx={0} />
|
||||
<HikeImage idx={1} />
|
||||
<HikeImage idx={2} />
|
||||
<HikeImage idx={3} />
|
||||
<HikeImage idx={4} />
|
||||
<HikeImage idx={5} />
|
||||
<HikeImage idx={6} />
|
||||
<HikeImage idx={7} />
|
||||
<HikeImage idx={8} />
|
||||
<HikeImage idx={9} />
|
||||
<HikeImage idx={10} />
|
||||
<HikeImage idx={11} />
|
||||
<HikeImage idx={12} />
|
||||
<HikeImage idx={13} />
|
||||
<HikeImage idx={14} />
|
||||
<HikeImage idx={15} />
|
||||
<HikeImage idx={16} />
|
||||
<HikeImage idx={17} />
|
||||
<HikeImage idx={18} />
|
||||
<HikeImage idx={19} />
|
||||
<HikeImage idx={20} />
|
||||
<HikeImage idx={21} />
|
||||
<HikeImage idx={22} />
|
||||
<HikeImage idx={23} />
|
||||
<HikeImage idx={24} />
|
||||
<HikeImage idx={25} />
|
||||
<HikeImage idx={26} />
|
||||
<HikeImage idx={27} />
|
||||
<HikeImage idx={28} />
|
||||
<HikeImage idx={29} />
|
||||
<HikeImage idx={30} />
|
||||
<HikeImage idx={31} />
|
||||
<HikeImage idx={32} />
|
||||
<HikeImage idx={33} />
|
||||
<HikeImage idx={34} />
|
||||
<HikeImage idx={35} />
|
||||
<HikeImage idx={36} />
|
||||
<HikeImage idx={37} />
|
||||
<HikeImage idx={38} />
|
||||
<HikeImage idx={39} />
|
||||
<HikeImage idx={40} />
|
||||
<HikeImage idx={41} />
|
||||
<HikeImage idx={42} />
|
||||
<HikeImage idx={43} />
|
||||
<HikeImage idx={44} />
|
||||
<HikeImage idx={45} />
|
||||
<HikeImage idx={46} />
|
||||
<HikeImage idx={47} />
|
||||
|
||||
## Abreise
|
||||
|
||||
Via Bus zurück von Sonogno nach Lugano. Dort via Zug nach Hause.
|
||||
<JourneyPlanner from="Sonogno" to="<current location>" fromFixed time="12:00" target="departure" />
|
||||
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,110 @@
|
||||
---
|
||||
title: Walenseewanderung
|
||||
date: 2024-04-14
|
||||
author: Alexander
|
||||
difficulty: T1
|
||||
tags: [St. Gallen, Walensee, Waldwanderung]
|
||||
seasons: 5-8
|
||||
summary:
|
||||
heroAlt:
|
||||
---
|
||||
|
||||
<script>
|
||||
import HikeImage from '$lib/components/hikes/HikeImage.svelte';
|
||||
import JourneyPlanner from '$lib/components/hikes/JourneyPlanner.svelte';
|
||||
</script>
|
||||
|
||||
### Anreise
|
||||
|
||||
Anreise nach Amden, Dorf.
|
||||
|
||||
<JourneyPlanner from="<current location>" to="Amden, Dorf" toFixed time="08:30" target="arrival"/>
|
||||
|
||||
<HikeImage idx={0} />
|
||||
<HikeImage idx={1} />
|
||||
<HikeImage idx={2} />
|
||||
<HikeImage idx={3} />
|
||||
<HikeImage idx={4} />
|
||||
<HikeImage idx={5} />
|
||||
<HikeImage idx={6} />
|
||||
<HikeImage idx={7} />
|
||||
<HikeImage idx={8} />
|
||||
<HikeImage idx={9} />
|
||||
<HikeImage idx={10} />
|
||||
<HikeImage idx={11} />
|
||||
<HikeImage idx={12} />
|
||||
<HikeImage idx={13} />
|
||||
<HikeImage idx={14} />
|
||||
<HikeImage idx={15} />
|
||||
<HikeImage idx={16} />
|
||||
<HikeImage idx={17} />
|
||||
<HikeImage idx={18} />
|
||||
<HikeImage idx={19} />
|
||||
<HikeImage idx={20} />
|
||||
<HikeImage idx={21} />
|
||||
<HikeImage idx={22} />
|
||||
<HikeImage idx={23} />
|
||||
<HikeImage idx={24} />
|
||||
<HikeImage idx={25} />
|
||||
<HikeImage idx={26} />
|
||||
<HikeImage idx={27} />
|
||||
<HikeImage idx={28} />
|
||||
<HikeImage idx={29} />
|
||||
<HikeImage idx={30} />
|
||||
<HikeImage idx={31} />
|
||||
<HikeImage idx={32} />
|
||||
<HikeImage idx={33} />
|
||||
<HikeImage idx={34} />
|
||||
<HikeImage idx={35} />
|
||||
<HikeImage idx={36} />
|
||||
<HikeImage idx={37} />
|
||||
<HikeImage idx={38} />
|
||||
<HikeImage idx={39} />
|
||||
<HikeImage idx={40} />
|
||||
<HikeImage idx={41} />
|
||||
<HikeImage idx={42} />
|
||||
<HikeImage idx={43} />
|
||||
<HikeImage idx={44} />
|
||||
<HikeImage idx={45} />
|
||||
<HikeImage idx={46} />
|
||||
<HikeImage idx={47} />
|
||||
<HikeImage idx={48} />
|
||||
<HikeImage idx={49} />
|
||||
<HikeImage idx={50} />
|
||||
<HikeImage idx={51} />
|
||||
<HikeImage idx={52} />
|
||||
<HikeImage idx={53} />
|
||||
<HikeImage idx={54} />
|
||||
<HikeImage idx={55} />
|
||||
<HikeImage idx={56} />
|
||||
<HikeImage idx={57} />
|
||||
<HikeImage idx={58} />
|
||||
<HikeImage idx={59} />
|
||||
<HikeImage idx={60} />
|
||||
<HikeImage idx={61} />
|
||||
<HikeImage idx={62} />
|
||||
<HikeImage idx={63} />
|
||||
<HikeImage idx={64} />
|
||||
<HikeImage idx={65} />
|
||||
<HikeImage idx={66} />
|
||||
<HikeImage idx={67} />
|
||||
<HikeImage idx={68} />
|
||||
<HikeImage idx={69} />
|
||||
<HikeImage idx={70} />
|
||||
<HikeImage idx={71} />
|
||||
<HikeImage idx={72} />
|
||||
<HikeImage idx={73} />
|
||||
<HikeImage idx={74} />
|
||||
<HikeImage idx={75} />
|
||||
<HikeImage idx={76} />
|
||||
<HikeImage idx={77} />
|
||||
<HikeImage idx={78} />
|
||||
<HikeImage idx={79} />
|
||||
<HikeImage idx={80} />
|
||||
<HikeImage idx={81} />
|
||||
<HikeImage idx={82} />
|
||||
|
||||
## Heimreise
|
||||
|
||||
Via Schiff von Quinten nach Murg. Dort mit Zug nach Hause.
|
||||
<JourneyPlanner fromFixed from="Quinten" to="<current location>" time="14:30" target="departure"/>
|
||||
|
After Width: | Height: | Size: 37 KiB |
@@ -0,0 +1,73 @@
|
||||
---
|
||||
title: Klausenpasswanderung
|
||||
date: 2025-08-17
|
||||
author: Alexander
|
||||
difficulty: T2
|
||||
tags: [Zentralschweiz, Panorama, family, Spätsommer]
|
||||
summary: Eine schöne Aussichtstour durch die Zentralschweiz... wenn das Wetter hält
|
||||
heroAlt:
|
||||
seasons: 5-8
|
||||
---
|
||||
|
||||
<script>
|
||||
import HikeImage from '$lib/components/hikes/HikeImage.svelte';
|
||||
import JourneyPlanner from '$lib/components/hikes/JourneyPlanner.svelte';
|
||||
</script>
|
||||
|
||||
## Übersicht
|
||||
|
||||
Eine wunderschöne Panoramawanderung im Zentralschweizer Gebirge.
|
||||
Anspruch nicht sehr hoch, da grösstenteils flach oder gemütlich abwärts.
|
||||
Bei unserem Besuch hat das Wetter leider nicht ganz mitgespielt, weswegen grosse Teile voller Nebel waren.
|
||||
|
||||
## Anfahrt
|
||||
|
||||
Anreise via Altdorf, Uri und dann mit dem Bus auf die Klausen Passhöhe.
|
||||
<JourneyPlanner from="<current location>" to="Klausen Passhöhe" toFixed time="09:00" target="arrival" />
|
||||
|
||||
Die Wanderung hat wunderschön angefangen mit einer weiten Aussicht über das Tal.
|
||||
<HikeImage idx={0} />
|
||||
|
||||
Wir haben auch direkt einen Bergsalamander getroffen.
|
||||
<HikeImage idx={1} />
|
||||
<HikeImage idx={2} />
|
||||
<HikeImage idx={3} />
|
||||
<HikeImage idx={4} />
|
||||
<HikeImage idx={5} />
|
||||
<HikeImage idx={6} />
|
||||
|
||||
Kurz bevor der Nebel aufkam hatten wir noch einen wunderschönen Ausblick auf den Gross Windgällen (rechts) und Gross Ruchen (links)
|
||||
<HikeImage idx={7} />
|
||||
<HikeImage idx={8} />
|
||||
<HikeImage idx={9} />
|
||||
<HikeImage idx={10} />
|
||||
Leider verfolgte uns Nach 1.5 Stunden der Nebel, was die weitere Aussicht erschwerte.
|
||||
Nichtsdestotroz hat die Wanderun eine schönen Wanderweg bereitgestellt.
|
||||
<HikeImage idx={11} />
|
||||
<HikeImage idx={12} />
|
||||
<HikeImage idx={13} />
|
||||
<HikeImage idx={14} />
|
||||
<HikeImage idx={15} />
|
||||
<HikeImage idx={16} />
|
||||
|
||||
Bei Mättental gab es einige Möglichkeiten Bergkäse direkt ab Hof zu kaufen. Ein nettes Mitbringsel von der Wanderung.
|
||||
<HikeImage idx={17} />
|
||||
Nach ca. 4 Stunden war der Nebel grösstenteils vorbei und wir konnten die schöne Alpenlandschaft geniessen.
|
||||
<HikeImage idx={18} />
|
||||
<HikeImage idx={19} />
|
||||
<HikeImage idx={20} />
|
||||
<HikeImage idx={21} />
|
||||
<HikeImage idx={22} />
|
||||
<HikeImage idx={23} />
|
||||
<HikeImage idx={24} />
|
||||
<HikeImage idx={25} />
|
||||
<HikeImage idx={26} />
|
||||
<HikeImage idx={27} />
|
||||
<HikeImage idx={28} />
|
||||
Ankunft bei der Bergbahn Eggberge nach ca. 5 Stunden.
|
||||
<HikeImage idx={29} />
|
||||
|
||||
## Abreise
|
||||
|
||||
Via Bergbahn und Bus von Eggberge nach Altdorf. Dort via Zug nach Hause.
|
||||
<JourneyPlanner from="Bergbahn Eggberge" to="<current location>" fromFixed time="14:00" target="departure" />
|
||||
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,105 @@
|
||||
---
|
||||
title: Siebengipfelwanderung
|
||||
date: 2025-08-11
|
||||
author: Alexander
|
||||
difficulty: T2
|
||||
tags: [St. Gallen, Walensee, Almwanderung]
|
||||
seasons: 5-8
|
||||
summary:
|
||||
heroAlt:
|
||||
---
|
||||
|
||||
<script>
|
||||
import HikeImage from '$lib/components/hikes/HikeImage.svelte';
|
||||
import JourneyPlanner from '$lib/components/hikes/JourneyPlanner.svelte';
|
||||
</script>
|
||||
|
||||
### Anreise
|
||||
|
||||
Anreise an Bahnhof Unterterzen, dann mit Gondeln hoch nach Flumserberg und via Gondel von Flumserberg auf den Maschgenkamm.
|
||||
|
||||
<JourneyPlanner from="<current location>" to="Maschgenkamm" toFixed time="08:30" target="arrival"/>
|
||||
|
||||
<HikeImage idx={0} />
|
||||
<HikeImage idx={1} />
|
||||
<HikeImage idx={2} />
|
||||
|
||||
## Gipfel 1: Ziger
|
||||
|
||||
<HikeImage idx={3} />
|
||||
<HikeImage idx={4} />
|
||||
<HikeImage idx={5} />
|
||||
<HikeImage idx={6} />
|
||||
<HikeImage idx={7} />
|
||||
<HikeImage idx={8} />
|
||||
|
||||
## Gipfel 2: Leist
|
||||
|
||||
<HikeImage idx={9} />
|
||||
<HikeImage idx={10} />
|
||||
|
||||
## Gipfel 3: Rainissalts
|
||||
|
||||
<HikeImage idx={11} />
|
||||
<HikeImage idx={12} />
|
||||
<HikeImage idx={13} />
|
||||
<HikeImage idx={14} />
|
||||
<HikeImage idx={15} />
|
||||
<HikeImage idx={16} />
|
||||
|
||||
## Gipfel 4: Gulmen
|
||||
|
||||
<HikeImage idx={17} />
|
||||
<HikeImage idx={18} />
|
||||
<HikeImage idx={19} />
|
||||
<HikeImage idx={20} />
|
||||
<HikeImage idx={21} />
|
||||
<HikeImage idx={22} />
|
||||
<HikeImage idx={23} />
|
||||
<HikeImage idx={24} />
|
||||
<HikeImage idx={25} />
|
||||
<HikeImage idx={26} />
|
||||
<HikeImage idx={27} />
|
||||
<HikeImage idx={28} />
|
||||
<HikeImage idx={29} />
|
||||
<HikeImage idx={30} />
|
||||
<HikeImage idx={31} />
|
||||
<HikeImage idx={32} />
|
||||
<HikeImage idx={33} />
|
||||
<HikeImage idx={34} />
|
||||
<HikeImage idx={35} />
|
||||
<HikeImage idx={36} />
|
||||
<HikeImage idx={37} />
|
||||
|
||||
## Gipfel 5: Cuncels
|
||||
|
||||
<HikeImage idx={38} />
|
||||
<HikeImage idx={39} />
|
||||
<HikeImage idx={40} />
|
||||
<HikeImage idx={41} />
|
||||
|
||||
## Gipfel 6: Chli Güslen
|
||||
|
||||
<HikeImage idx={42} />
|
||||
<HikeImage idx={43} />
|
||||
<HikeImage idx={44} />
|
||||
|
||||
## Gipfel 7: Gross Güslen
|
||||
|
||||
<HikeImage idx={45} />
|
||||
<HikeImage idx={46} />
|
||||
<HikeImage idx={47} />
|
||||
<HikeImage idx={48} />
|
||||
<HikeImage idx={49} />
|
||||
<HikeImage idx={50} />
|
||||
<HikeImage idx={51} />
|
||||
<HikeImage idx={52} />
|
||||
<HikeImage idx={53} />
|
||||
<HikeImage idx={54} />
|
||||
<HikeImage src="PXL_20240414_135254651.jpg" />
|
||||
|
||||
|
||||
## Heimreise
|
||||
|
||||
Vom Flumserberg mit der Gondel wieder runter nach Unterterzen. Von Unterterzen via Zug nach Hause.
|
||||
<JourneyPlanner fromFixed from="Flumserberg Tannenboden" to="<current location>" time="14:00" target="departure"/>
|
||||
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,33 @@
|
||||
---
|
||||
title: Spaziergang Uetliberg
|
||||
date: 2025-08-07
|
||||
author: Alexander
|
||||
difficulty: T1
|
||||
tags: [Zürich, Spaziergang, Sommer, mittel]
|
||||
seasons: 4-9
|
||||
summary:
|
||||
heroAlt:
|
||||
---
|
||||
|
||||
<script>
|
||||
import HikeImage from '$lib/components/hikes/HikeImage.svelte';
|
||||
import JourneyPlanner from '$lib/components/hikes/JourneyPlanner.svelte';
|
||||
</script>
|
||||
|
||||
## Anreise
|
||||
|
||||
Start direkt von der Tramstation Triemli.
|
||||
<JourneyPlanner from="<current location>" to="Zürich, Triemli" toFixed time="10:00" target="arrival"/>
|
||||
|
||||
Der erste Anstieg ist recht intensiv.
|
||||
<HikeImage idx={0} />
|
||||
<HikeImage idx={1} />
|
||||
Danach geht es gemütlich dem Grat entlang. Ein entspannter Spaziergang bei schönem Wetter.
|
||||
<HikeImage idx={2} />
|
||||
<HikeImage idx={3} />
|
||||
|
||||
|
||||
## Heimreise
|
||||
|
||||
Via Bus von Albispasshöhe nach Thalwil, dann nach Hause via Zug.
|
||||
<JourneyPlanner fromFixed from="Langnau a.A., Albispasshöhe" to="<current location>" time="15:00" target="departure"/>
|
||||
|
After Width: | Height: | Size: 39 KiB |
@@ -0,0 +1,151 @@
|
||||
---
|
||||
title: Muttertagswanderung Herisau Teufen
|
||||
date: 2026-05-09
|
||||
author: Alexander
|
||||
difficulty: T2
|
||||
tags: [appenzell, leicht, hügellandschaft, weiden]
|
||||
seasons: 4-9
|
||||
summary: Eine angenehme Wanderung über die Hügellandschaft von Appenzell
|
||||
heroAlt:
|
||||
---
|
||||
|
||||
<script>
|
||||
import HikeImage from '$lib/components/hikes/HikeImage.svelte';
|
||||
import JourneyPlanner from '$lib/components/hikes/JourneyPlanner.svelte';
|
||||
</script>
|
||||
|
||||
|
||||
### Übersicht
|
||||
|
||||
Eine nette Wanderung durch die hübsche Hügellandschaft des Appenzells. Man wandert entlang Feldwegen, Schotterstrassen, Teerstrassen und hier und da kurze Abschnitte auf Waldwegen.
|
||||
Auf dem Weg durchquert man zwei kleine Schluchten mit Flüssen.
|
||||
Ein netter Spaziergang, der keine besonderen Konditionsanforderungen über ein generelles Fitnessniveau hinaus stellt.
|
||||
|
||||
### Anreise
|
||||
|
||||
Die Wanderung startet direkt am Bahnhof Herisau und ist somit leicht erreichbar.
|
||||
<JourneyPlanner from="<current location>" to="Herisau" toFixed time="10:00" target="arrival"/>
|
||||
|
||||
|
||||
### Start
|
||||
|
||||
Schon schnell wurde klar, dass es ein wunderschöner Tag wird die grünenden Weiden des Appenzells zu erkunden.
|
||||
|
||||
<HikeImage idx={0} />
|
||||
<HikeImage idx={1} />
|
||||
<HikeImage idx={2} />
|
||||
<HikeImage idx={3} />
|
||||
<HikeImage idx={4} />
|
||||
<HikeImage idx={5} />
|
||||
|
||||
### Schlucht Nummer Eins
|
||||
|
||||
Nach circa einer Stunde sieht man prominent den Kirchturm von Hundwil vor sich.
|
||||
|
||||
<HikeImage idx={6} />
|
||||
|
||||
Davor geht es noch via der Alten Tobelbrücke über die Urnäsch.
|
||||
Die überdachte Brücke trägt an ihren Dachbalken christliche Zitate.
|
||||
|
||||
<HikeImage idx={7} />
|
||||
<HikeImage idx={8} />
|
||||
<HikeImage idx={9} />
|
||||
|
||||
Die Urnäsch ist ein idealer Punkt für eine kurze erste Verpflegungspause.
|
||||
<HikeImage idx={10} />
|
||||
<HikeImage idx={11} />
|
||||
<HikeImage idx={12} />
|
||||
|
||||
### Hundwil
|
||||
|
||||
Nach Schlucht Nummer Eins ist man bereits recht schnell in Hundwil und kann die wunderschöne Bauernlandschaft geniessen.
|
||||
<HikeImage idx={13} />
|
||||
<HikeImage idx={14} />
|
||||
<HikeImage idx={15} />
|
||||
<HikeImage idx={16} />
|
||||
|
||||
Nach 2:30 Stunden geht der Wanderweg direkt geradeaus über eine Wiese, während der Feldweg rechts abbiegt. Hier aufpassen, der Karte zu folgen.
|
||||
|
||||
<HikeImage idx={17} />
|
||||
<HikeImage idx={18} />
|
||||
<HikeImage idx={19} />
|
||||
<HikeImage idx={20} />
|
||||
<HikeImage idx={21} />
|
||||
|
||||
### Appenzeller Volkskundemuseum und Appenzeller Schaukäserei
|
||||
|
||||
In Stein angekommen, lohnt sich ein kleiner Abstecher in das Appenzeller Volkskundemuseum. Von Appenzeller Traditionen bis hin zu modernerer Kunst gibt es eine grosse Bandbreite an Appenzeller Kunst und Kultur.
|
||||
Im Keller gibt es eine grosse Ausstellung zur traditionellen Stickkunst des Appenzells sowie deren Industrialisierung und Automatisierung.
|
||||
|
||||
#### Das Berg-Häämetli fressende Ungeheuer
|
||||
|
||||
<HikeImage idx={22} />
|
||||
|
||||
Von den modernen Bildern hat mir besonders obiges Gemälde des Künstlers Willy Künzler gefallen.
|
||||
Wem die Botschaft der Politikerspinne, welche Hof (die im Appenzell üblichen Höfe werden auch (Berg)-<q>Häämetli</q> genannt) und Tier frisst und Golfplätze baut, noch zu unklar ist, dem hat Künzler auf der Seite des Bildes noch eine Hilfseinschrift hinterlassen:
|
||||
|
||||
> Wir wollen Bergbauern bleiben.
|
||||
> Nicht Folklore- und Schau-Bauern sein.
|
||||
> Nicht Golf-Handlanger werden.
|
||||
|
||||
Ein kurzer 30- bis 45-minütiger Abstecher in das Museum ist zu empfehlen.
|
||||
Wem das zu langweilig ist, dem kann die Appenzeller Schaukäserei direkt nebenan eventuell gefallen oder als Ort der Stärkung dienen.
|
||||
|
||||
|
||||
<HikeImage idx={23} />
|
||||
Stein hat auch sonst überzeugt: Es zeigt sich, dass man auch neu bauen kann, ohne <q>markant eckig</q> sein zu müssen. Ein schönes Häämetli, das sehr gut ins Appenzell passt.
|
||||
<HikeImage idx={24} />
|
||||
<HikeImage idx={25} />
|
||||
|
||||
Auch alt kann überzeugen, mit schöner Obstwiese, verwittertem Holz und viel Detail im Holz.
|
||||
|
||||
### Schlucht Nummer Zwei
|
||||
|
||||
Danach geht es runter in Schlucht Nummer Zwei. Über Weide und Wald gelangt man dorthin, wo der Rotbach in die Sitter fliesst.
|
||||
<HikeImage idx={26} />
|
||||
<HikeImage idx={27} />
|
||||
|
||||
|
||||
<HikeImage idx={28} />
|
||||
<HikeImage idx={29} />
|
||||
<HikeImage idx={30} />
|
||||
<HikeImage idx={31} />
|
||||
<HikeImage idx={32} />
|
||||
|
||||
<HikeImage idx={33} />
|
||||
|
||||
### Ehemaliges Kloster Wonnenstein
|
||||
|
||||
Nach Schlucht Nummer Zwei und einem Gefängnis kommt man an einem ehemaligen Nonnenkloster vorbei.
|
||||
Das Kloster wurde erst 2021 wegen fehlendem Nachwuchs aufgelöst.
|
||||
|
||||
Man fragt sich vielleicht, wie ein katholisches Kloster ins protestantische Appenzell <em>Ausserrhoden</em> passt — die Halbkantone sind ja historisch aus konfessionellen Gründen gespalten.
|
||||
|
||||
Darüber gibt das Kloster via Infotafeln Auskunft:
|
||||
Zwar war lange Zeit der Status des Klosters unklar, man hat sich jedoch einige Jahrzehnte nach den ursprünglichen Protestantenaufständen dazu einigen können, dass alles innerhalb der Klostermauern offiziell zu Appenzell <em>Innerrhoden</em> gehört. Somit ist das Kloster eine winzige Enklave Appenzell Innerrhodens innerhalb Appenzell Ausserrhoden.
|
||||
|
||||
|
||||
<HikeImage idx={34} />
|
||||
|
||||
Die Klosterkirche selbst ist erst vor kurzem restauriert worden, mit einem neuen Altar für <i>ad populum</i> Messen.
|
||||
Die drei Altäre dahinter haben alle einen Fokus auf die heilige Familie, ist die Kirche doch der heiligen Maria geweiht.
|
||||
Die Marienfigur des linken Altars könnte Schweizer Katholiken bekannt vorkommen.
|
||||
Es handelt sich hierbei um eine Replika der schwarzen Madonna aus Einsiedeln.
|
||||
Dementsprechend ist die Replika auch nach der fehlgeschlagenen Restauration der Einsiedler Madonna angefertigt worden, ist diese doch ursprünglich farbig bemalt gewesen.
|
||||
|
||||
|
||||
Der letzte Abschnitt vor Teufen verläuft teils neben der Autobahn, was nicht optimal ist. Gott sei Dank ist das Ganze aber nach 15 Minuten vorbei.
|
||||
<HikeImage idx={35} />
|
||||
|
||||
## Ankunft in Teufen
|
||||
|
||||
Teufen erreicht man über den treffend genannten Ort <q>Einsamkeit</q>, wo ein einzelnes wunderschönes Bauernhaus einen begrüsst.
|
||||
<HikeImage idx={36} />
|
||||
<HikeImage idx={37} />
|
||||
<HikeImage idx={38} />
|
||||
|
||||
## Heimreise
|
||||
|
||||
Von Teufen fährt halbstündlich ein Zug nach St. Gallen. Von dort die jeweilige Verbindung nach Hause.
|
||||
|
||||
<JourneyPlanner from="Teufen AR" to="<current location>" fromFixed time="16:30" target="departure"/>
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
title: Einsiedeln – Spital – Unteriberg
|
||||
date: 2026-03-07
|
||||
author: Alexander
|
||||
difficulty: T2
|
||||
tags: [einsiedeln, schwyz, sommer, mittel]
|
||||
seasons: 4-9
|
||||
summary: Eine schnelle Gipfelwanderung in den Schwyzer Bergen
|
||||
heroAlt: Blick aus dem Sihltal Richtung Unteriberg
|
||||
---
|
||||
|
||||
<script>
|
||||
import HikeImage from '$lib/components/hikes/HikeImage.svelte';
|
||||
import JourneyPlanner from '$lib/components/hikes/JourneyPlanner.svelte';
|
||||
</script>
|
||||
|
||||
## Übersicht
|
||||
|
||||
Vom Einsiedler Sihlsee geht es über ein paar Almen auf den Spitalgipfel.
|
||||
Man hat eine schöne Aussicht auf das Schweizer Alpenmassiv, den grossen und kleinen Mythen Richtung Westen und natürich den Sihlsee hinter sich.
|
||||
Eine nette Wanderung welche man doch frühstens im Mai begehen sollte. März war definitiv zu früh und es lag noch in Teilen knietief Schnee, nicht nur an Nordhängen.
|
||||
|
||||
## Anreise
|
||||
|
||||
Anreise nach Einsiedeln. Dann 10 Minuten mit Buslinie 555 nach Gross, Ebenau.
|
||||
<JourneyPlanner from="<current location>" to="Gross, Ebenau" toFixed target="arrival" time="10:00" />
|
||||
|
||||
## Erster Anstieg
|
||||
|
||||
Von Gross ging es direkt los mit dem ersten richtigen Anstieg. Eine gute Aufwärmung für die restliche Wanderung.
|
||||
<HikeImage idx={0} />
|
||||
<HikeImage idx={1} />
|
||||
<HikeImage idx={2} />
|
||||
|
||||
Früh Im Jahr sieht man schöne Almen, wenn auch etwas zu viel Schnee für den guten Geschmack.
|
||||
<HikeImage idx={3} />
|
||||
<HikeImage idx={4} />
|
||||
|
||||
### Erster Hügel
|
||||
|
||||
Erster Hügel ist erklummen. Jetzt geht die Route für ein zwei Kilometer recht flach der Almen entlang.
|
||||
Derweil hat man einen wunderschöne Aussicht auf das schweizer Alpenmassiv und den Sihlsee.
|
||||
|
||||
|
||||
## Zweiter Hügel
|
||||
Jetzt geht es durch einen Wald zum zweiten Hügel, dem Spital. Die Bäume stehen hier und da etwas im Weg, es ist aber keine grössere Kraxelei um hoch zu kommen.
|
||||
<HikeImage idx={5} />
|
||||
<HikeImage idx={6} />
|
||||
<HikeImage idx={7} />
|
||||
<HikeImage idx={8} />
|
||||
<HikeImage idx={9} />
|
||||
<HikeImage idx={10} />
|
||||
<HikeImage idx={11} />
|
||||
<HikeImage idx={12} />
|
||||
<HikeImage idx={13} />
|
||||
<HikeImage idx={14} />
|
||||
<HikeImage idx={15} />
|
||||
<HikeImage idx={16} />
|
||||
|
||||
Jetzt zum schlimmsten Abschnitt, zumindest wenn man zu früh im Jahr unterwegs ist: der letzte Hügel auf den Spital hoch hat noch bis tief ins Jahr Schnee und wer nicht sehr früh am Morgen dort ist droht immer wieder einzubrechen.
|
||||
|
||||
<HikeImage idx={17} />
|
||||
<HikeImage idx={18} />
|
||||
|
||||
## Spital
|
||||
|
||||
Am Spital angekommen lohnt sich eine Pause und den Ausblick zu geniessen.
|
||||
<HikeImage idx={19} />
|
||||
<HikeImage idx={20} />
|
||||
<HikeImage idx={21} />
|
||||
<HikeImage idx={22} />
|
||||
|
||||
## Abstieg
|
||||
Der Abstieg nach Unteriberg ist recht schnell, jedoch auch nicht schneefrei bis in den Mai.
|
||||
|
||||
<HikeImage idx={23} />
|
||||
<HikeImage idx={24} />
|
||||
|
||||
## Heimreise
|
||||
|
||||
Angekommen in Unteriberg — Zeit für eine Pause vor der Rückfahrt.
|
||||
|
||||
<HikeImage idx={25} />
|
||||
|
||||
Von Unteriberg per Bus zurück nach Einsiedeln (Linie 555 oder 556) und weiter mit der
|
||||
S-Bahn ab Einsiedeln.
|
||||
<JourneyPlanner from="Unteriberg, Guggelstrasse" to="<current location>" fromFixed target=departure time="14:30" />
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
title: Wanderung auf den Morgartenberg
|
||||
date: 2026-04-19
|
||||
author: Alexander
|
||||
difficulty: T2
|
||||
tags: [Schwyz, Zentralschweiz, kurz, leicht]
|
||||
seasons: 4-9
|
||||
summary:
|
||||
heroAlt:
|
||||
---
|
||||
|
||||
<script>
|
||||
import HikeImage from '$lib/components/hikes/HikeImage.svelte';
|
||||
import JourneyPlanner from '$lib/components/hikes/JourneyPlanner.svelte';
|
||||
</script>
|
||||
|
||||
## Anreise
|
||||
|
||||
Start in Biberegg.
|
||||
<JourneyPlanner from="<current location>" to="Biberegg" toFixed time="10:00" target="arrival"/>
|
||||
|
||||
<HikeImage idx={0} />
|
||||
<HikeImage idx={1} />
|
||||
<HikeImage idx={2} />
|
||||
<HikeImage idx={3} />
|
||||
<HikeImage idx={4} />
|
||||
<HikeImage idx={5} />
|
||||
<HikeImage idx={6} />
|
||||
<HikeImage idx={7} />
|
||||
<HikeImage idx={8} />
|
||||
<HikeImage idx={9} />
|
||||
<HikeImage idx={10} />
|
||||
<HikeImage idx={11} />
|
||||
<HikeImage idx={12} />
|
||||
<HikeImage idx={13} />
|
||||
<HikeImage idx={14} />
|
||||
<HikeImage idx={15} />
|
||||
<HikeImage idx={16} />
|
||||
<HikeImage idx={17} />
|
||||
<HikeImage idx={18} />
|
||||
|
||||
<HikeImage src="PXL_20260419_090639481.jpg" private />
|
||||
<HikeImage src="PXL_20260419_090657549.jpg" private />
|
||||
|
||||
|
||||
## Heimreise
|
||||
|
||||
Rückreise ab Sattel, Schornen.
|
||||
<JourneyPlanner fromFixed from="Sattel, Schornen" to="<current location>" time="12:00" target="departure"/>
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
title: Wallfahrt Rheinau
|
||||
date: 2026-05-14
|
||||
author: Alexander
|
||||
difficulty: T1
|
||||
tags: [winterthur, rheinau, wallfahrt, pilgern]
|
||||
seasons: 4-9
|
||||
summary:
|
||||
heroAlt:
|
||||
---
|
||||
|
||||
<script>
|
||||
import HikeImage from '$lib/components/hikes/HikeImage.svelte';
|
||||
import JourneyPlanner from '$lib/components/hikes/JourneyPlanner.svelte';
|
||||
</script>
|
||||
|
||||
## Von Winterthur über Feld und Wald zur ehemaligen Klosterkirche Rheinau
|
||||
|
||||
An Christi Himmelfahrt (14. Mai 2026) ging es auf eine 8-stündige Pilgerung mit anschliessendem Gottesdienst und Apéro riche mit Pater Ramm, organisiert durch die Christkönigjugend [(ckj.ch)](https://ckj.ch).
|
||||
Mit einem dann doch nicht so langsamen Tempo wurden die fast 26 km bis 14:30 Uhr zurückgelegt.
|
||||
|
||||
### Anreise
|
||||
|
||||
Start war direkt beim Bahnhof Winterthur, was die Anreise erleichtert.
|
||||
<JourneyPlanner from="<current location>" to="Winterthur, Hauptbahnhof" toFixed time="08:00" target="arrival"/>
|
||||
|
||||
### Route
|
||||
Kurz nach 8 Uhr ging es in Winterthur los. Nach ca. 30 Minuten durch die Stadt ging es über Feldwege und geteerte Fusswege Richtung Norden.
|
||||
Das Wetter hielt leider nicht lange, starker Regen erschwerte das Lesen von Liedtexten für Leute ohne Regenschirm.
|
||||
<HikeImage idx={0} />
|
||||
<HikeImage idx={1} />
|
||||
|
||||
|
||||
Immer wieder gab es jedoch auch Pausen im Regen und man konnte das vibrante Frühlingsgrün von Feldern und Wald geniessen.
|
||||
Der starke Regen hat auch das Aufnehmen weiterer Fotos verhindert, weswegen erst am Ziel wieder viele Bilder zu sehen sind.
|
||||
|
||||
<HikeImage idx={2} />
|
||||
<HikeImage idx={3} />
|
||||
|
||||
|
||||
## Ziel
|
||||
|
||||
Nach 8 Stunden war die Klosterkirche Rheinau hinter den Baumzipfeln zu erkennen.
|
||||
Der Rhein macht hier eine starke Rechtskurve, wodurch die Klosterinsel genau am Bogen des Rheins Richtung Osten zeigt.
|
||||
<HikeImage idx={4} />
|
||||
<HikeImage idx={5} />
|
||||
<HikeImage idx={6} />
|
||||
|
||||
<HikeImage idx={7} />
|
||||
<HikeImage idx={8} />
|
||||
<HikeImage idx={9} />
|
||||
|
||||
## Heimreise
|
||||
|
||||
Von der Klosterinsel Rheinau fährt stündlich ein Bus zum Bahnhof Marthalen.
|
||||
Dann geht es via Zug mit eventuellem Umsteigen in Winterthur nach Hause.
|
||||
|
||||
<JourneyPlanner fromFixed from="Rheinau, Unterstadt" to="<current location>" time="16:00" target="departure"/>
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Handle, HandleServerError, ServerInit } from "@sveltejs/kit"
|
||||
import { redirect } from "@sveltejs/kit"
|
||||
import { sequence } from "@sveltejs/kit/hooks"
|
||||
import { building } from "$app/environment"
|
||||
import * as auth from "./auth"
|
||||
import { initializeScheduler } from "./lib/server/scheduler"
|
||||
import { dbConnect } from "./utils/db"
|
||||
@@ -25,6 +26,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
|
||||
* 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
|
||||
@@ -46,11 +70,34 @@ const NOINDEX_PATTERNS: RegExp[] = [
|
||||
async function noindex({ event, resolve }: Parameters<Handle>[0]) {
|
||||
const response = await resolve(event);
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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]) {
|
||||
const marks: Record<string, number> = {};
|
||||
event.locals.timing = {
|
||||
@@ -72,11 +119,19 @@ async function timing({ event, resolve }: Parameters<Handle>[0]) {
|
||||
const header = Object.entries(marks)
|
||||
.map(([k, v]) => `${k};dur=${v.toFixed(1)}`)
|
||||
.join(', ');
|
||||
response.headers.set('Server-Timing', header);
|
||||
return response;
|
||||
return applyHeaders(response, [['Server-Timing', header]]);
|
||||
}
|
||||
|
||||
export const init: ServerInit = async () => {
|
||||
// SvelteKit runs prerendering/analysis inside a worker_threads worker (see
|
||||
// @sveltejs/kit utils/fork.js) whose JS heap is capped well below the main
|
||||
// thread's. `init` fires there too, so warming the romcal cache during a
|
||||
// build exhausts that worker's heap → ERR_WORKER_OUT_OF_MEMORY and a failed
|
||||
// build. None of it is needed at build time: no prerendered route touches the
|
||||
// DB, and connecting to Mongo / starting the payment scheduler from a build
|
||||
// is undesirable regardless. Skip startup work while building.
|
||||
if (building) return;
|
||||
|
||||
console.log('🚀 Server starting - initializing database connection...');
|
||||
try {
|
||||
await dbConnect();
|
||||
@@ -172,8 +227,14 @@ async function authorization({ event, resolve }: Parameters<Handle>[0]) {
|
||||
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 }) => {
|
||||
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 isEnglish = event.url.pathname.startsWith('/faith/') || event.url.pathname.startsWith('/recipes/');
|
||||
@@ -189,6 +250,7 @@ export const handle: Handle = sequence(
|
||||
timing,
|
||||
htmlLang,
|
||||
noindex,
|
||||
securityHeaders,
|
||||
auth.handle,
|
||||
authorization
|
||||
);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Public responsive image assets
|
||||
|
||||
Drop public source images here, then render them with `$lib/components/Image.svelte`.
|
||||
|
||||
At build time `@sveltejs/enhanced-img` (vite-imagetools + sharp) processes every
|
||||
raster image in this folder into AVIF/WebP at multiple widths and strips EXIF.
|
||||
Output is a public, hashed, immutable build asset.
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import Image from '$lib/components/Image.svelte';
|
||||
</script>
|
||||
|
||||
<!-- lazy by default; `src` is relative to this folder -->
|
||||
<Image src="hero.jpg" alt="…" />
|
||||
|
||||
<!-- above-the-fold / LCP image: load eagerly -->
|
||||
<Image src="hero.jpg" alt="…" lazy={false} />
|
||||
|
||||
<!-- full-width image: pass `sizes` so smaller screens fetch smaller files -->
|
||||
<Image src="banner.jpg" alt="…" sizes="min(1280px, 100vw)" />
|
||||
|
||||
<!-- subfolders work too -->
|
||||
<Image src="blog/cover.png" alt="…" />
|
||||
```
|
||||
|
||||
For **private, auth-gated** images use `<Image src="…" private />` and put the
|
||||
source in `../private-images/` instead — see that folder's README.
|
||||
|
||||
Notes:
|
||||
|
||||
- Provide images at ~2× the displayed size so HiDPI screens stay sharp;
|
||||
processing only ever scales **down**.
|
||||
- SVGs are not processed here — import them directly instead.
|
||||
- First build is slow (encoding); results are cached in
|
||||
`node_modules/.cache/imagetools`.
|
||||
- These sources are committed (they're public site assets).
|
||||
@@ -0,0 +1,45 @@
|
||||
# Private (auth-gated) image sources
|
||||
|
||||
Drop **private** source images here, then render them with
|
||||
`<Image src="…" private />` from `$lib/components/Image.svelte`.
|
||||
|
||||
These can't use `@sveltejs/enhanced-img` — its output is a public asset. Instead
|
||||
`scripts/build-private-images.ts` (runs at `prebuild`) encodes each image into
|
||||
AVIF/WebP at multiple widths into `private-assets/` (gitignored, outside the
|
||||
client bundle) and writes `src/lib/data/privateImages.generated.ts`. The bytes
|
||||
are served only through the auth-gated endpoint
|
||||
`src/routes/private-images/[...file]/+server.ts`.
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import Image from '$lib/components/Image.svelte';
|
||||
</script>
|
||||
|
||||
<!-- `src` is relative to THIS folder; shows a lock badge -->
|
||||
<Image src="receipt.jpg" private alt="…" />
|
||||
|
||||
<!-- gate rendering behind your own auth check too -->
|
||||
{#if data.session}
|
||||
<Image src="family/2024.jpg" private alt="…" sizes="min(1000px, 100vw)" />
|
||||
{/if}
|
||||
```
|
||||
|
||||
Setup / notes:
|
||||
|
||||
- **Dev:** run `pnpm exec vite-node scripts/build-private-images.ts` once (and
|
||||
after adding/changing images) so the manifest + `private-assets/` exist. You
|
||||
must be logged in for the gated endpoint to serve the bytes.
|
||||
- **Prod (one-time):** add an nginx `internal` location so the bytes are only
|
||||
reachable via the endpoint's `X-Accel-Redirect`:
|
||||
|
||||
```nginx
|
||||
location /protected-images/ {
|
||||
internal;
|
||||
alias /var/www/static/private-images/;
|
||||
}
|
||||
```
|
||||
|
||||
`scripts/deploy.sh` rsyncs `private-assets/` → `/var/www/static/private-images/`.
|
||||
- These source images are **gitignored** (private + large). Back them up
|
||||
separately.
|
||||
- SVGs are not processed here.
|
||||
@@ -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>
|
||||
@@ -0,0 +1,192 @@
|
||||
<script lang="ts" module>
|
||||
import type { Picture } from '@sveltejs/enhanced-img';
|
||||
|
||||
// Build-time map of every PUBLIC raster image under src/lib/assets/images/.
|
||||
// `query: { enhanced: true }` routes each match through @sveltejs/enhanced-img
|
||||
// (vite-imagetools + sharp), which generates AVIF/WebP at multiple widths and
|
||||
// returns a Picture that <enhanced:img> renders as a <picture>. Eager so the
|
||||
// lookup below stays synchronous. SVGs are excluded — enhanced-img only
|
||||
// supports them statically, and they need no rasterising anyway.
|
||||
const sources = import.meta.glob(
|
||||
'/src/lib/assets/images/**/*.{avif,gif,heif,jpeg,jpg,png,tiff,webp}',
|
||||
{ eager: true, query: { enhanced: true } }
|
||||
) as Record<string, { default: Picture }>;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { dev } from '$app/environment';
|
||||
import Lock from '@lucide/svelte/icons/lock';
|
||||
// PRIVATE images can't use enhanced-img (its output is public). They go
|
||||
// through the parallel sharp pipeline (scripts/build-private-images.ts) and
|
||||
// are served by the auth-gated /private-images/ endpoint. The manifest is
|
||||
// generated at prebuild; run `vite-node scripts/build-private-images.ts` once
|
||||
// for dev.
|
||||
import { PRIVATE_IMAGES } from '$lib/data/privateImages.generated';
|
||||
|
||||
interface Props {
|
||||
/** Path to the source image. Public: relative to src/lib/assets/images/.
|
||||
* Private: relative to src/lib/assets/private-images/. e.g. "hero.jpg"
|
||||
* or "blog/cover.png". A leading slash is tolerated. */
|
||||
src: string;
|
||||
/** Alt text. Always provide one for non-decorative images. */
|
||||
alt?: string;
|
||||
/** Lazy-load below the fold (default). Set false for above-the-fold /
|
||||
* LCP images, which should load eagerly. */
|
||||
lazy?: boolean;
|
||||
/** Auth-gate this image: served only to logged-in users via the
|
||||
* /private-images/ endpoint, with a lock badge. The bytes are never a
|
||||
* public asset. Render these behind your own auth check too — anonymous
|
||||
* viewers get a "locked" placeholder instead of the image. */
|
||||
private?: boolean;
|
||||
/** Responsive `sizes`. When set, smaller screens fetch smaller files;
|
||||
* omit for a plain 1x/2x pair (public) or the full ladder (private). */
|
||||
sizes?: string;
|
||||
/** Extra class(es) forwarded to the underlying <img>. */
|
||||
class?: string;
|
||||
/** Any other <img> attribute (width, height, fetchpriority, style, …). */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
let {
|
||||
src,
|
||||
alt = '',
|
||||
lazy = true,
|
||||
private: isPrivate = false,
|
||||
sizes,
|
||||
class: className,
|
||||
...rest
|
||||
}: Props = $props();
|
||||
|
||||
const key = $derived(src.replace(/^\/+/, ''));
|
||||
|
||||
// Public: enhanced-img Picture, looked up by root-relative glob key.
|
||||
const picture = $derived(isPrivate ? undefined : sources[`/src/lib/assets/images/${key}`]?.default);
|
||||
// Private: responsive variant with auth-gated /private-images/ URLs.
|
||||
const variant = $derived(isPrivate ? PRIVATE_IMAGES[key] : undefined);
|
||||
|
||||
// Anonymous viewers get a 401 from /private-images/; swap the broken image
|
||||
// for a locked placeholder when that happens.
|
||||
let locked = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (!dev) return;
|
||||
if (isPrivate && !variant) {
|
||||
console.warn(
|
||||
`[Image] No private build-time asset for "${src}". Place it under ` +
|
||||
`src/lib/assets/private-images/ and re-run scripts/build-private-images.ts.`
|
||||
);
|
||||
} else if (!isPrivate && !picture) {
|
||||
console.warn(
|
||||
`[Image] No build-time asset for "${src}". ` +
|
||||
`Place it under src/lib/assets/images/ (path relative to that dir).`
|
||||
);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if isPrivate}
|
||||
{#if variant}
|
||||
<span class="g-private-image" class:locked>
|
||||
<picture>
|
||||
<source type="image/avif" srcset={variant.srcsetAvif} {sizes} />
|
||||
<source type="image/webp" srcset={variant.srcsetWebp} {sizes} />
|
||||
<img
|
||||
src={variant.src}
|
||||
{alt}
|
||||
width={variant.width}
|
||||
height={variant.height}
|
||||
class={className}
|
||||
loading={lazy ? 'lazy' : 'eager'}
|
||||
decoding="async"
|
||||
onerror={() => (locked = true)}
|
||||
{...rest}
|
||||
/>
|
||||
</picture>
|
||||
<span class="g-private-badge" title="Privates Bild — nur für eingeloggte Benutzer sichtbar">
|
||||
<Lock size={11} strokeWidth={2.25} aria-hidden="true" />
|
||||
privat
|
||||
</span>
|
||||
{#if locked}
|
||||
<span class="g-private-locked">
|
||||
<Lock size={20} strokeWidth={2} aria-hidden="true" />
|
||||
Anmeldung erforderlich
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
{:else if picture}
|
||||
<enhanced:img
|
||||
src={picture}
|
||||
{alt}
|
||||
{sizes}
|
||||
class={className}
|
||||
loading={lazy ? 'lazy' : 'eager'}
|
||||
decoding="async"
|
||||
{...rest}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* The colon in the tag name must be escaped in a selector. enhanced-img
|
||||
* rewrites this to target the generated <img>. */
|
||||
enhanced\:img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.g-private-image {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.g-private-image picture,
|
||||
.g-private-image img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Lock badge — mirrors HikeImage's `.private`. */
|
||||
.g-private-badge {
|
||||
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);
|
||||
}
|
||||
|
||||
/* Shown when the gated request 401s (anonymous viewer). */
|
||||
.g-private-image.locked img {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.g-private-locked {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import Lock from '@lucide/svelte/icons/lock';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
/** Show the small "privat" lock chip above the content (default true). */
|
||||
badge?: boolean;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { badge = true, children }: Props = $props();
|
||||
|
||||
// Visible only to logged-in viewers. Pages that use this should be rendered
|
||||
// per request (e.g. the hike detail page is `prerender = false`) so the
|
||||
// session is live and, for anonymous visitors, the content is omitted from
|
||||
// the SSR HTML.
|
||||
//
|
||||
// NOTE: this is *cosmetic* gating, not byte-gating like a private image.
|
||||
// The prose is compiled into the page's JS chunk, which ships to every
|
||||
// visitor — a determined anonymous user can read it in the bundle. Use it
|
||||
// for "members-only" notes, never for secrets.
|
||||
const canSee = $derived(!!page.data.session?.user);
|
||||
</script>
|
||||
|
||||
{#if canSee}
|
||||
<div class="private-prose">
|
||||
{#if badge}
|
||||
<span class="badge" title="Privat — nur für eingeloggte Benutzer sichtbar">
|
||||
<Lock size={11} strokeWidth={2.25} aria-hidden="true" />
|
||||
privat
|
||||
</span>
|
||||
{/if}
|
||||
{@render children()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.private-prose {
|
||||
position: relative;
|
||||
margin: 1.5rem 0;
|
||||
padding: 0.85rem 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-left: 3px solid var(--color-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
/* Trim the first/last rendered block's margins so the box hugs its content. */
|
||||
.private-prose :global(> :first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
.private-prose :global(> :last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
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: var(--color-primary);
|
||||
color: var(--color-text-on-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,326 @@
|
||||
<script lang="ts">
|
||||
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import Clock from '@lucide/svelte/icons/clock';
|
||||
import { m } from '$lib/js/commonI18n';
|
||||
import type { CommonLang } from '$lib/js/commonI18n';
|
||||
|
||||
/**
|
||||
* Pill-styled time picker, sibling to `DatePicker.svelte` (date) and
|
||||
* `DateTimePicker.svelte` (combined). Operates on a plain `"HH:MM"` string so
|
||||
* it drops straight into 24-hour API params and `<input type="time">`-shaped
|
||||
* stores.
|
||||
*
|
||||
* - Chevron arrows nudge by `step` minutes (wrapping across the hour).
|
||||
* - The display opens a two-column hour / minute dropdown.
|
||||
* - Optional `min` / `max` (also `"HH:MM"`) disable out-of-range cells.
|
||||
*/
|
||||
interface Props {
|
||||
value?: string;
|
||||
/** Minute granularity for the dropdown + chevron nudges. */
|
||||
step?: number;
|
||||
min?: string;
|
||||
max?: string;
|
||||
lang?: CommonLang;
|
||||
/** Optional extra CSS class on the outer wrapper. */
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable(''),
|
||||
step = 5,
|
||||
min = '',
|
||||
max = '',
|
||||
lang = 'de',
|
||||
class: extraClass = ''
|
||||
}: Props = $props();
|
||||
|
||||
const t = $derived(m[lang]);
|
||||
|
||||
let open = $state(false);
|
||||
let pickerRef = $state<HTMLDivElement | null>(null);
|
||||
let hourCol = $state<HTMLDivElement | null>(null);
|
||||
let minCol = $state<HTMLDivElement | null>(null);
|
||||
|
||||
function pad(n: number): string {
|
||||
return n.toString().padStart(2, '0');
|
||||
}
|
||||
function parse(v: string): { h: number; m: number } | null {
|
||||
const mt = /^(\d{1,2}):(\d{2})$/.exec(v ?? '');
|
||||
if (!mt) return null;
|
||||
const h = Number(mt[1]);
|
||||
const mm = Number(mt[2]);
|
||||
if (h < 0 || h > 23 || mm < 0 || mm > 59) return null;
|
||||
return { h, m: mm };
|
||||
}
|
||||
|
||||
const current = $derived(parse(value));
|
||||
const label = $derived(current ? `${pad(current.h)}:${pad(current.m)}` : t.select_time);
|
||||
|
||||
const hours = Array.from({ length: 24 }, (_, i) => i);
|
||||
const minutes = $derived(
|
||||
Array.from({ length: Math.ceil(60 / step) }, (_, i) => i * step).filter((mm) => mm < 60)
|
||||
);
|
||||
|
||||
function outOfRange(time: string): boolean {
|
||||
if (min && time < min) return true;
|
||||
if (max && time > max) return true;
|
||||
return false;
|
||||
}
|
||||
function hourDisabled(h: number): boolean {
|
||||
for (let mm = 0; mm < 60; mm += step) {
|
||||
if (!outOfRange(`${pad(h)}:${pad(mm)}`)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
function minuteDisabled(mm: number): boolean {
|
||||
const h = current?.h ?? -1;
|
||||
if (h < 0) return false;
|
||||
return outOfRange(`${pad(h)}:${pad(mm)}`);
|
||||
}
|
||||
|
||||
function commit(h: number, mm: number) {
|
||||
const next = `${pad(h)}:${pad(mm)}`;
|
||||
if (outOfRange(next)) return;
|
||||
value = next;
|
||||
}
|
||||
function selectHour(h: number) {
|
||||
commit(h, current?.m ?? 0);
|
||||
}
|
||||
function selectMinute(mm: number) {
|
||||
commit(current?.h ?? new Date().getHours(), mm);
|
||||
}
|
||||
function nudge(delta: number) {
|
||||
const base = current ?? { h: new Date().getHours(), m: 0 };
|
||||
let total = (base.h * 60 + base.m + delta) % (24 * 60);
|
||||
if (total < 0) total += 24 * 60;
|
||||
commit(Math.floor(total / 60), total % 60);
|
||||
}
|
||||
function setNow() {
|
||||
const d = new Date();
|
||||
let mm = Math.round(d.getMinutes() / step) * step;
|
||||
let h = d.getHours();
|
||||
if (mm >= 60) {
|
||||
mm = 0;
|
||||
h = (h + 1) % 24;
|
||||
}
|
||||
commit(h, mm);
|
||||
open = false;
|
||||
}
|
||||
|
||||
// Centre the selected cells when the dropdown opens.
|
||||
function centreCol(col: HTMLDivElement | null) {
|
||||
if (!col) return;
|
||||
const sel = col.querySelector<HTMLElement>('.tp-cell.selected');
|
||||
if (sel) col.scrollTop = sel.offsetTop - col.clientHeight / 2 + sel.clientHeight / 2;
|
||||
}
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
centreCol(hourCol);
|
||||
centreCol(minCol);
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="tp {extraClass}" bind:this={pickerRef}>
|
||||
<div class="tp-pill" class:empty={current == null}>
|
||||
<button type="button" class="tp-arrow" onclick={() => nudge(-step)} aria-label="−{step} min">
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<button type="button" class="tp-display" onclick={() => (open = !open)} aria-label={t.select_time}>
|
||||
<Clock size={14} aria-hidden="true" />
|
||||
<span class="tp-label">{label}</span>
|
||||
</button>
|
||||
<button type="button" class="tp-arrow" onclick={() => nudge(step)} aria-label="+{step} min">
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if open}
|
||||
<div class="tp-dropdown" role="dialog" aria-label={t.select_time}>
|
||||
<div class="tp-cols">
|
||||
<div class="tp-col" bind:this={hourCol} role="listbox" aria-label="Stunde">
|
||||
{#each hours as h (h)}
|
||||
<button
|
||||
type="button"
|
||||
class="tp-cell"
|
||||
class:selected={current?.h === h}
|
||||
disabled={hourDisabled(h)}
|
||||
onclick={() => selectHour(h)}
|
||||
>
|
||||
{pad(h)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="tp-col" bind:this={minCol} role="listbox" aria-label="Minute">
|
||||
{#each minutes as mm (mm)}
|
||||
<button
|
||||
type="button"
|
||||
class="tp-cell"
|
||||
class:selected={current?.m === mm}
|
||||
disabled={minuteDisabled(mm)}
|
||||
onclick={() => selectMinute(mm)}
|
||||
>
|
||||
{pad(mm)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="tp-now" onclick={setNow}>{t.now}</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tp {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tp-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;
|
||||
}
|
||||
|
||||
.tp-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);
|
||||
}
|
||||
.tp-arrow:hover {
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
.tp-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);
|
||||
}
|
||||
.tp-display:hover {
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
.tp-label {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.tp-pill.empty .tp-label {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
/* Dropdown — mirrors DatePicker / DateTimePicker chrome. */
|
||||
.tp-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.5rem;
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.tp-cols {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.tp-col {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-height: 11rem;
|
||||
overflow-y: auto;
|
||||
padding-right: 0.15rem;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.tp-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2.6rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.82rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-normal), color var(--transition-normal);
|
||||
}
|
||||
.tp-cell:hover:not(:disabled) {
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
.tp-cell:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.tp-cell.selected {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-on-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
.tp-cell.selected:hover {
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
.tp-now {
|
||||
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);
|
||||
}
|
||||
.tp-now:hover {
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
</style>
|
||||
@@ -30,7 +30,7 @@
|
||||
<i>✻</i>
|
||||
In meiner Todesstunde rufe mich,
|
||||
<i>✻</i>
|
||||
Und heisse zur Dir kommen mich,
|
||||
Und heisse zu Dir kommen mich,
|
||||
<i>✻</i>
|
||||
Damit ich möge loben Dich
|
||||
<i>✻</i>
|
||||
@@ -53,10 +53,10 @@
|
||||
{/if}
|
||||
<p>
|
||||
{#if showLatin}
|
||||
<v lang=la >En ego, o bone et dulcíssime Jesu, ante contspéctum tuum génibusme provólvo ac máximo ánimi ardóre te oro atque obtéstor, ut meum in in cor vívidos fídei, spei et caritátis sensus, atque veram peccatórum meórum pœniténtiam, éaque emendándi firmíssimam voluntátem velis imprímere; dum magno ánimi afféctu et dolóre tua quinque vúlnera mecum ipse consídero ac mente contémplor, illud præ óculis habens, quod jam in ore ponébat tuo David Prophéta de te, o bone Jesu: Fodérunt manus meas et pedes meos; dinumeravérunt ómnio ossa mea (Ps. 21, 17-18)</v>
|
||||
<v lang=la >En ego, o bone et dulcíssime Jesu, ante conspéctum tuum génibus me provólvo ac máximo ánimi ardóre te oro atque obtéstor, ut meum in cor vívidos fídei, spei et caritátis sensus, atque veram peccatórum meórum pœniténtiam, éaque emendándi firmíssimam voluntátem velis imprímere; dum magno ánimi afféctu et dolóre tua quinque vúlnera mecum ipse consídero ac mente contémplor, illud præ óculis habens, quod jam in ore ponébat tuo David Prophéta de te, o bone Jesu: Fodérunt manus meas et pedes meos; dinumeravérunt ómnia ossa mea (Ps. 21, 17-18)</v>
|
||||
{/if}
|
||||
<v lang=de>
|
||||
Siehe, o gütiger und milder Jesus, ich werfe mich vor Deinen Augen auf die Knie. Inbrünstig bitte und beschwöre ich Dich: Präge meinem Herzen lebendige Gefühle des Glaubens, der Hoffung und der Liebe ein sowie wahre Reue über meine Sünden und den ganz festen Willen, mich zu bessern. Voll Liebe und Schmerz schaue ich Deine fünf Wunden und betrachte sie in meinem Geiste. Dabei halte ich mir vor Augen, was im Hinblick auf Dich, o guter Jesus, schon der Prophet David Dir in den Mund legte: «Sie haben Meine Hände und Meine Füsse durchbohrt; alle meine Gebeine haben sie gezählt.» (Ps. 21, 17-18)
|
||||
Siehe, o gütiger und milder Jesus, ich werfe mich vor Deinen Augen auf die Knie. Inbrünstig bitte und beschwöre ich Dich: Präge meinem Herzen lebendige Gefühle des Glaubens, der Hoffnung und der Liebe ein sowie wahre Reue über meine Sünden und den ganz festen Willen, mich zu bessern. Voll Liebe und Schmerz schaue ich Deine fünf Wunden und betrachte sie in meinem Geiste. Dabei halte ich mir vor Augen, was im Hinblick auf Dich, o guter Jesus, schon der Prophet David Dir in den Mund legte: «Sie haben Meine Hände und Meine Füsse durchbohrt; alle meine Gebeine haben sie gezählt.» (Ps. 21, 17-18)
|
||||
</v>
|
||||
|
||||
</p>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<Prayer>
|
||||
{#snippet children(showLatin, urlLang)}
|
||||
<p>
|
||||
{#if showLatin}<v lang=la>Ánima Christi, santífica me.</v>{/if}
|
||||
{#if showLatin}<v lang=la>Ánima Christi, sanctífica me.</v>{/if}
|
||||
{#if urlLang=='de'}<v lang=de>Seele Christi, heilige mich.</v>{/if}
|
||||
{#if urlLang=='en'}<v lang=en>Soul of Christ, sanctify me.</v>{/if}
|
||||
{#if showLatin}<v lang=la>Corpus Christi, salva me.</v>{/if}
|
||||
@@ -22,7 +22,7 @@
|
||||
{#if urlLang=='en'}<v lang=en>Water from the side of Christ, wash me.</v>{/if}
|
||||
{#if showLatin}<v lang=la>Pássio Christi, confórta me.</v>{/if}
|
||||
{#if urlLang=='de'}<v lang=de>Leiden Christi, stärke mich.</v>{/if}
|
||||
{#if urlLang=='en'}<v lang=en>Passion of Christ, strenghten me.</v>{/if}
|
||||
{#if urlLang=='en'}<v lang=en>Passion of Christ, strengthen me.</v>{/if}
|
||||
{#if showLatin}<v lang=la>O bone Iesu, exáudi me.</v>{/if}
|
||||
{#if urlLang=='de'}<v lang=de>O gütiger Jesus, erhöre mich.</v>{/if}
|
||||
{#if urlLang=='en'}<v lang=en>O good Jesus, hear me.</v>{/if}
|
||||
@@ -34,12 +34,12 @@
|
||||
{#if urlLang=='en'}<v lang=en>Separated from Thee let me never be.</v>{/if}
|
||||
{#if showLatin}<v lang=la>Ab hoste malígno defénde me.</v>{/if}
|
||||
{#if urlLang=='de'}<v lang=de>Vor dem bösen Feind beschütze mich.</v>{/if}
|
||||
{#if urlLang=='en'}<v lang=en>From the malignant enemeny, defend me.</v>{/if}
|
||||
{#if urlLang=='en'}<v lang=en>From the malignant enemy, defend me.</v>{/if}
|
||||
{#if showLatin}<v lang=la>In hora mortis meæ voca me.</v>{/if}
|
||||
{#if urlLang=='de'}<v lang=de>In meiner Todesstunde rufe mich,</v>{/if}
|
||||
{#if urlLang=='en'}<v lang=en>At the hour of death, call me.</v>{/if}
|
||||
{#if showLatin}<v lang=la>Et iube me veníre ad te,</v>{/if}
|
||||
{#if urlLang=='de'}<v lang=de>Und heisse zur Dir kommen mich,</v>{/if}
|
||||
{#if urlLang=='de'}<v lang=de>Und heisse zu Dir kommen mich,</v>{/if}
|
||||
{#if urlLang=='en'}<v lang=en>And bid me come unto Thee</v>{/if}
|
||||
{#if showLatin}<v lang=la>Ut cum Sanctis tuis laudem te</v>{/if}
|
||||
{#if urlLang=='de'}<v lang=de>Damit ich möge loben Dich</v>{/if}
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
{#if urlLang === 'de'}<v lang="de">aufgefahren in den Himmel,</v>{/if}
|
||||
{#if urlLang === 'en'}<v lang="en">He ascended into heaven,</v>{/if}
|
||||
{#if showLatin}<v lang="la">sedet ad déxteram Dei Patris omnipoténtis,</v>{/if}
|
||||
{#if urlLang === 'de'}<v lang="de">er sitzet zur Rechten Gottes, des allmächtigen Vaters;</v>{/if}
|
||||
{#if urlLang === 'de'}<v lang="de">er sitzt zur Rechten Gottes, des allmächtigen Vaters;</v>{/if}
|
||||
{#if urlLang === 'en'}<v lang="en">and sits at the right hand of God the Father almighty.</v>{/if}
|
||||
{#if showLatin}<v lang="la">inde ventúrus est iudicáre vivos et mórtuos.</v>{/if}
|
||||
{#if urlLang === 'de'}<v lang="de">von dort wird er kommen, zu richten die Lebenden und die Toten.</v>{/if}
|
||||
|
||||
@@ -23,10 +23,10 @@
|
||||
{#if showLatin}<v lang="la">ómnibus Sanctis, et tibi pater:</v>{/if}
|
||||
{#if urlLang === 'de'}<v lang="de">allen Heiligen und dir, Vater,</v>{/if}
|
||||
{#if urlLang === 'en'}<v lang="en">to all the Saints, and to you, Father,</v>{/if}
|
||||
{#if showLatin}<v lang="la">quia paccávi nimis</v>{/if}
|
||||
{#if showLatin}<v lang="la">quia peccávi nimis</v>{/if}
|
||||
{#if urlLang === 'de'}<v lang="de">dass ich viel gesündigt habe</v>{/if}
|
||||
{#if urlLang === 'en'}<v lang="en">that I have sinned exceedingly</v>{/if}
|
||||
{#if showLatin}<v lang="la">cogitatióne, verbe et ópere:</v>{/if}
|
||||
{#if showLatin}<v lang="la">cogitatióne, verbo et ópere:</v>{/if}
|
||||
{#if urlLang === 'de'}<v lang="de">in Gedanken, Worten und Werken,</v>{/if}
|
||||
{#if urlLang === 'en'}<v lang="en">in thought, word, and deed:</v>{/if}
|
||||
{#if showLatin}<v lang="la">mea culpa, mea culpa, mea máxima culpa.</v>{/if}
|
||||
@@ -39,7 +39,7 @@
|
||||
{#if urlLang === 'de'}<v lang="de">den hl. Erzengel Michael,</v>{/if}
|
||||
{#if urlLang === 'en'}<v lang="en">blessed Michael the Archangel,</v>{/if}
|
||||
{#if showLatin}<v lang="la">beátum Ioánnem Baptístam,</v>{/if}
|
||||
{#if urlLang === 'de'}<v lang="de">dem hl. Johannes den Täufer,</v>{/if}
|
||||
{#if urlLang === 'de'}<v lang="de">den hl. Johannes den Täufer,</v>{/if}
|
||||
{#if urlLang === 'en'}<v lang="en">blessed John the Baptist,</v>{/if}
|
||||
{#if showLatin}<v lang="la">sanctos Apóstolos Petrum et Paulum,</v>{/if}
|
||||
{#if urlLang === 'de'}<v lang="de">die hll. Apostel Petrus und Paulus,</v>{/if}
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
{#if showLatin}<v lang="la">secúndum Scriptúras.</v>{/if}
|
||||
{#if urlLang === 'de'}<v lang="de">gemäss der Schrift;</v>{/if}
|
||||
{#if urlLang === 'en'}<v lang="en">in accordance with the Scriptures.</v>{/if}
|
||||
{#if showLatin}<v lang="la">Et ascéndit in cáelum:</v>{/if}
|
||||
{#if showLatin}<v lang="la">Et ascéndit in cælum:</v>{/if}
|
||||
{#if urlLang === 'de'}<v lang="de">Er ist aufgefahren in den Himmel</v>{/if}
|
||||
{#if urlLang === 'en'}<v lang="en">He ascended into heaven</v>{/if}
|
||||
{#if showLatin}<v lang="la">sedet ad déxteram Patris.</v>{/if}
|
||||
@@ -91,7 +91,7 @@
|
||||
{#if urlLang === 'de'}<v lang="de">Gericht zu halten über Lebende und Tote:</v>{/if}
|
||||
{#if urlLang === 'en'}<v lang="en">to judge the living and the dead</v>{/if}
|
||||
{#if showLatin}<v lang="la">cujus regni non erit finis.</v>{/if}
|
||||
{#if urlLang === 'de'}<v lang="de">und Seines Reiches wird kein Endes sein.</v>{/if}
|
||||
{#if urlLang === 'de'}<v lang="de">und Seines Reiches wird kein Ende sein.</v>{/if}
|
||||
{#if urlLang === 'en'}<v lang="en">and His kingdom will have no end.</v>{/if}
|
||||
</p>
|
||||
<p>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
{#if urlLang === 'de'}<v lang="de">Ehre sei <i><sup>⚬</sup></i> Gott in der Höhe.</v>{/if}
|
||||
{#if urlLang === 'en'}<v lang="en">Glory to <i><sup>⚬</sup></i> God in the highest.</v>{/if}
|
||||
{#if showLatin}<v lang="la">Et in terra pax homínibus</v>{/if}
|
||||
{#if urlLang === 'de'}<v lang="de">Und auf Erden Friede den Mesnchen,</v>{/if}
|
||||
{#if urlLang === 'de'}<v lang="de">Und auf Erden Friede den Menschen,</v>{/if}
|
||||
{#if urlLang === 'en'}<v lang="en">And on earth peace to men</v>{/if}
|
||||
{#if showLatin}<v lang="la">bonæ voluntátis.</v>{/if}
|
||||
{#if urlLang === 'de'}<v lang="de">die guten Willens sind.</v>{/if}
|
||||
@@ -67,9 +67,9 @@
|
||||
{#if urlLang === 'de'}<v lang="de">erbarme Dich unser.</v>{/if}
|
||||
{#if urlLang === 'en'}<v lang="en">have mercy on us.</v>{/if}
|
||||
{#if showLatin}<v lang="la">Qui tollis peccáta mundi,</v>{/if}
|
||||
{#if urlLang === 'de'}<v lang="de">Du nimmst hinwerg die Sünden der Welt.</v>{/if}
|
||||
{#if urlLang === 'de'}<v lang="de">Du nimmst hinweg die Sünden der Welt.</v>{/if}
|
||||
{#if urlLang === 'en'}<v lang="en">Thou who takest away the sins of the world,</v>{/if}
|
||||
{#if showLatin}<v lang="la"><i><sup>⚬</sup></i> súscipe depreciatiónem nostram.</v>{/if}
|
||||
{#if showLatin}<v lang="la"><i><sup>⚬</sup></i> súscipe deprecatiónem nostram.</v>{/if}
|
||||
{#if urlLang === 'de'}<v lang="de"><i><sup>⚬</sup></i> nimm unser Flehen gnädig auf.</v>{/if}
|
||||
{#if urlLang === 'en'}<v lang="en"><i><sup>⚬</sup></i> receive our prayer.</v>{/if}
|
||||
{#if showLatin}<v lang="la">Qui sedes ad déxteram Patris,</v>{/if}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
{#if showLatin}<v lang="la">defénde nos in proélio,</v>{/if}
|
||||
{#if urlLang === 'de'}<v lang="de">verteidige uns im Kampfe!</v>{/if}
|
||||
{#if urlLang === 'en'}<v lang="en">defend us in battle.</v>{/if}
|
||||
{#if showLatin}<v lang="la">cóntra nequítam et insídias</v>{/if}
|
||||
{#if showLatin}<v lang="la">cóntra nequítiam et insídias</v>{/if}
|
||||
{#if urlLang === 'de'}<v lang="de">Gegen die Bosheit und Nachstellungen</v>{/if}
|
||||
{#if urlLang === 'en'}<v lang="en">Be our protection against the wickedness</v>{/if}
|
||||
{#if showLatin}<v lang="la">diáboli ésto præsídium.</v>{/if}
|
||||
@@ -20,7 +20,7 @@
|
||||
{#if showLatin}<v lang="la">Ímperet ílli Déus, súpplices deprecámur:</v>{/if}
|
||||
{#if urlLang === 'de'}<v lang="de">»Gott gebiete ihm!«, so bitten wir flehentlich.</v>{/if}
|
||||
{#if urlLang === 'en'}<v lang="en">May God rebuke him, we humbly pray;</v>{/if}
|
||||
{#if showLatin}<v lang="la">tuque, Prínceps milítæ cæléstis,</v>{/if}
|
||||
{#if showLatin}<v lang="la">tuque, Prínceps milítiæ cæléstis,</v>{/if}
|
||||
{#if urlLang === 'de'}<v lang="de">Du aber, Fürst der himmlischen Heerscharen,</v>{/if}
|
||||
{#if urlLang === 'en'}<v lang="en">and do thou, O Prince of the heavenly host,</v>{/if}
|
||||
{#if showLatin}<v lang="la">Sátanam aliósque spíritus malígnos,</v>{/if}
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
{#if showLatin}<v lang=la>ac máximo ánimi ardóre te oro atque obtéstor, </v>{/if}
|
||||
{#if urlLang=='de'}<v lang=de>Inbrünstig bitte und beschwöre ich Dich:</v>{/if}
|
||||
{#if urlLang=='en'}<v lang=en>and with the most fervent desire of my soul I pray and beseech Thee</v>{/if}
|
||||
{#if showLatin}<v lang=la> ut meum in cor vívidos fídei, spei et caritátis sensus, atque veram peccatórum meórum pœniténtiam,</v>{/if}
|
||||
{#if urlLang=='de'}<v lang=de>Präge meinem Herzen lebendige Gefühle des Glaubens, der Hoffung und der Liebe ein</v>{/if}
|
||||
{#if showLatin}<v lang=la> ut in cor meum vívidos fídei, spei et caritátis sensus, atque veram peccatórum meórum pœniténtiam,</v>{/if}
|
||||
{#if urlLang=='de'}<v lang=de>Präge meinem Herzen lebendige Gefühle des Glaubens, der Hoffnung und der Liebe ein</v>{/if}
|
||||
{#if urlLang=='en'}<v lang=en>to impress upon my heart lively sentiments of faith, hope and charity,</v>{/if}
|
||||
{#if showLatin} <v lang=la>éaque emendándi firmíssimam voluntátem velis</v>{/if}
|
||||
{#if urlLang=='de'}<v lang=de>sowie wahre Reue über meine Sünden und den ganz festen Willen, mich zu bessern.</v>{/if}
|
||||
@@ -29,9 +29,9 @@
|
||||
{#if showLatin}<v lang=la>illud præ óculis habens, quod jam in ore ponébat tuo David Prophéta de te, o bone Jesu:</v>{/if}
|
||||
{#if urlLang=='de'}<v lang=de>Dabei halte ich mir vor Augen, was im Hinblick auf Dich, o guter Jesus, schon der Prophet David Dir in den Mund legte:</v>{/if}
|
||||
{#if urlLang=='en'}<v lang=en>having before mine eyes that which David, the prophet, long ago spoke in Thine Own person concerning Thee, my Jesus:</v>{/if}
|
||||
{#if showLatin}<v lang=la> «Fodérunt manus meas et pedes meos; dinumeravérunt ómnio ossa mea.» (Ps. 21, 17-18)</v>{/if}
|
||||
{#if showLatin}<v lang=la> «Fodérunt manus meas et pedes meos; dinumeravérunt ómnia ossa mea.» (Ps. 21, 17-18)</v>{/if}
|
||||
{#if urlLang=='de'}<v lang=de>«Sie haben Meine Hände und Meine Füsse durchbohrt; alle meine Gebeine haben sie gezählt.» (Ps. 21, 17-18)</v>{/if}
|
||||
{#if urlLang=='en'}<v lang=en>"They have pierced My hands and My feet, they have numbered all My bones." (Ps. 21:17-18</v>{/if}
|
||||
{#if urlLang=='en'}<v lang=en>"They have pierced My hands and My feet, they have numbered all My bones." (Ps. 21:17-18)</v>{/if}
|
||||
<v lang=und>Amen.</v>
|
||||
</p>
|
||||
{/snippet}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
{#if urlLang === 'de'}<v lang="de">verleihe uns, wir bitten dich,</v>{/if}
|
||||
{#if urlLang === 'en'}<v lang="en">grant, we beseech Thee,</v>{/if}
|
||||
{#if showLatin}<v lang="la">út, hæc mystéria sanctíssimo beátæ Maríæ Vírginis Rosário recoléntes;</v>{/if}
|
||||
{#if urlLang === 'de'}<v lang="de">dass wir, indem wir die Geheimisse des heiligen Rosenkranzes der allerseligsten Jungfrau ehren,</v>{/if}
|
||||
{#if urlLang === 'de'}<v lang="de">dass wir, indem wir die Geheimnisse des heiligen Rosenkranzes der allerseligsten Jungfrau ehren,</v>{/if}
|
||||
{#if urlLang === 'en'}<v lang="en">that by meditating on these mysteries of the most holy Rosary of the Blessed Virgin Mary,</v>{/if}
|
||||
{#if showLatin}<v lang="la">ét imitémur quód cóntinent,</v>{/if}
|
||||
{#if urlLang === 'de'}<v lang="de">was sie enthalten nachahmen</v>{/if}
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-card);
|
||||
view-transition-name: workout-focus-card;
|
||||
}
|
||||
|
||||
/* Eyebrow row: step counter + bodypart + equipment, controls on the right */
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
<script lang="ts">
|
||||
// Typeahead chip selector — a text field that opens a dropdown of matching
|
||||
// options, with the picked ones shown below as removable chips. Generic over
|
||||
// the value: used for free-text tags (with a leading "#") and for cantons
|
||||
// (with the coat-of-arms emblem rendered before the name). Themed with the
|
||||
// semantic variables so it fits the filter panel in both colour schemes.
|
||||
import type { SvelteSet } from 'svelte/reactivity';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
|
||||
interface Props {
|
||||
/** All selectable values, in display order. */
|
||||
options: string[];
|
||||
/** Currently-selected values. Mutated via {@link onToggle}. */
|
||||
selected: SvelteSet<string>;
|
||||
onToggle: (value: string) => void;
|
||||
placeholder?: string;
|
||||
/** Prefix each value with a "#" (tag style). */
|
||||
hash?: boolean;
|
||||
/** Optional icon URL rendered before each value (e.g. canton emblem). */
|
||||
iconFor?: (value: string) => string | undefined;
|
||||
/** Display label for a value (defaults to the value itself). */
|
||||
labelFor?: (value: string) => string;
|
||||
}
|
||||
|
||||
const {
|
||||
options,
|
||||
selected,
|
||||
onToggle,
|
||||
placeholder = 'Eingeben oder auswählen…',
|
||||
hash = false,
|
||||
iconFor,
|
||||
labelFor = (v) => v
|
||||
}: Props = $props();
|
||||
|
||||
// Unique per instance — two of these live in the panel at once (tags +
|
||||
// cantons), so a shared id would be a duplicate.
|
||||
const dropdownId = `tt-dd-${Math.random().toString(36).slice(2, 9)}`;
|
||||
|
||||
let inputValue = $state('');
|
||||
let open = $state(false);
|
||||
let wrapper = $state<HTMLElement>();
|
||||
let inputEl = $state<HTMLInputElement>();
|
||||
|
||||
const unselected = $derived(options.filter((v) => !selected.has(v)));
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
const q = inputValue.trim().toLowerCase();
|
||||
if (q === '') return unselected;
|
||||
return unselected.filter(
|
||||
(v) => labelFor(v).toLowerCase().includes(q) || v.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
|
||||
// Selected values kept in the canonical display order rather than click order.
|
||||
const selectedList = $derived(options.filter((v) => selected.has(v)));
|
||||
|
||||
function pick(value: string) {
|
||||
onToggle(value);
|
||||
inputValue = '';
|
||||
// Keep the field focused so several can be added in a row.
|
||||
inputEl?.focus();
|
||||
open = true;
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const q = inputValue.trim().toLowerCase();
|
||||
const match =
|
||||
filtered.find((v) => labelFor(v).toLowerCase() === q || v.toLowerCase() === q) ??
|
||||
filtered[0];
|
||||
if (match) pick(match);
|
||||
} else if (e.key === 'Escape') {
|
||||
if (inputValue) {
|
||||
inputValue = '';
|
||||
} else {
|
||||
open = false;
|
||||
inputEl?.blur();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close when focus leaves the whole widget (click-away / tab-out), but stay
|
||||
// open while moving between the input and its dropdown chips.
|
||||
function onFocusOut(e: FocusEvent) {
|
||||
const next = e.relatedTarget as Node | null;
|
||||
if (!next || !wrapper?.contains(next)) open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="tt" bind:this={wrapper} onfocusout={onFocusOut}>
|
||||
<div class="tt-field">
|
||||
<input
|
||||
class="tt-input"
|
||||
type="text"
|
||||
bind:this={inputEl}
|
||||
bind:value={inputValue}
|
||||
onfocus={() => (open = true)}
|
||||
onkeydown={onKey}
|
||||
{placeholder}
|
||||
autocomplete="off"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-controls={dropdownId}
|
||||
/>
|
||||
|
||||
{#if open && filtered.length > 0}
|
||||
<div class="tt-dropdown" id={dropdownId}>
|
||||
{#each filtered as value (value)}
|
||||
{@const icon = iconFor?.(value)}
|
||||
<button type="button" class="tt-option" onclick={() => pick(value)}>
|
||||
{#if icon}<img class="tt-emblem" src={icon} alt="" aria-hidden="true" />{/if}
|
||||
{#if hash}<span class="tt-hash" aria-hidden="true">#</span>{/if}
|
||||
{labelFor(value)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if selectedList.length > 0}
|
||||
<div class="tt-selected">
|
||||
{#each selectedList as value (value)}
|
||||
{@const icon = iconFor?.(value)}
|
||||
<button
|
||||
type="button"
|
||||
class="tt-chip"
|
||||
onclick={() => onToggle(value)}
|
||||
aria-label={`${labelFor(value)} entfernen`}
|
||||
>
|
||||
{#if icon}<img class="tt-emblem" src={icon} alt="" aria-hidden="true" />{/if}
|
||||
{#if hash}<span class="tt-hash" aria-hidden="true">#</span>{/if}
|
||||
{labelFor(value)}
|
||||
<X size={13} strokeWidth={2} aria-hidden="true" />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tt-field {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tt-input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.5rem 0.7rem;
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.tt-input::placeholder {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.tt-input:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.tt-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.3rem);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.tt-option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
appearance: none;
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.25rem 0.7rem;
|
||||
border-radius: var(--radius-pill);
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
transition: scale var(--transition-fast), background-color var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
|
||||
.tt-option:hover {
|
||||
scale: 1.05;
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.tt-selected {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.tt-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
appearance: none;
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.25rem 0.5rem 0.25rem 0.7rem;
|
||||
border-radius: var(--radius-pill);
|
||||
cursor: pointer;
|
||||
color: var(--color-text-on-primary);
|
||||
background: var(--color-primary);
|
||||
border: 1px solid var(--color-primary);
|
||||
transition: scale var(--transition-fast), background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.tt-chip:hover {
|
||||
scale: 1.05;
|
||||
background: var(--color-primary-hover);
|
||||
border-color: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
.tt-chip :global(svg) {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Canton coat-of-arms — tall shield, kept proportional in a fixed slot. */
|
||||
.tt-emblem {
|
||||
width: 13px;
|
||||
height: 16px;
|
||||
object-fit: contain;
|
||||
flex: 0 0 auto;
|
||||
filter: drop-shadow(0 1px 1px rgb(0 0 0 / 0.18));
|
||||
}
|
||||
|
||||
.tt-hash {
|
||||
opacity: 0.6;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tt-chip .tt-hash {
|
||||
opacity: 0.85;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,587 @@
|
||||
<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[];
|
||||
/** Restrict the x-axis to a stage's index range (multi-day hikes). The
|
||||
* dataset stays the full track so hover indices remain global. */
|
||||
viewRange?: { startIdx: number; endIdx: number } | null;
|
||||
}
|
||||
|
||||
const { track, viewRange = null }: Props = $props();
|
||||
|
||||
// x-axis window in km for the current view (whole track, or a stage slice).
|
||||
function xBounds(): { min: number; max: number } {
|
||||
const last = cumKm[cumKm.length - 1] ?? 0;
|
||||
if (!viewRange) return { min: 0, max: last };
|
||||
const lo = Math.max(0, Math.min(viewRange.startIdx, cumKm.length - 1));
|
||||
const hi = Math.max(0, Math.min(viewRange.endIdx, cumKm.length - 1));
|
||||
return { min: cumKm[lo] ?? 0, max: cumKm[hi] ?? last };
|
||||
}
|
||||
|
||||
let canvas = $state<HTMLCanvasElement | undefined>(undefined);
|
||||
let chart: ChartType | null = null;
|
||||
let ChartCtor: typeof import('chart.js').Chart | null = null;
|
||||
// Goes true once Chart.js has painted at least one frame. Drives the
|
||||
// cross-fade from the SSR-rendered static SVG to the interactive canvas.
|
||||
// Stays sticky-true on theme re-creation so the SVG doesn't flash back.
|
||||
let chartReady = $state(false);
|
||||
|
||||
// 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;
|
||||
});
|
||||
|
||||
// SSR-rendered fallback: a static SVG profile of the whole track so no-JS
|
||||
// (and pre-hydration) users see the elevation graph immediately. Path
|
||||
// coordinates live in an 800×200 viewBox; `preserveAspectRatio="none"`
|
||||
// stretches the path to fill the box on any aspect ratio. Strokes use
|
||||
// `vector-effect: non-scaling-stroke` so the line weight stays at a
|
||||
// constant pixel weight regardless of the stretch. Once Chart.js mounts
|
||||
// and paints, the SVG fades out and the interactive canvas takes over.
|
||||
const FALLBACK_VB_W = 800;
|
||||
const FALLBACK_VB_H = 200;
|
||||
const elevFallback = $derived.by(() => {
|
||||
if (track.length < 2) return { fill: '', line: '' };
|
||||
// Per-track sample cap so a ~5 000-point GPX doesn't produce a 60 KB
|
||||
// SVG path in the HTML. ~600 samples is enough for a smooth profile
|
||||
// at typical display widths and keeps the inline SVG around ~6 KB.
|
||||
const target = 600;
|
||||
const step = Math.max(1, Math.floor(track.length / target));
|
||||
|
||||
let altLo = Infinity;
|
||||
let altHi = -Infinity;
|
||||
for (let i = 0; i < track.length; i++) {
|
||||
const a = track[i][2];
|
||||
if (typeof a === 'number') {
|
||||
if (a < altLo) altLo = a;
|
||||
if (a > altHi) altHi = a;
|
||||
}
|
||||
}
|
||||
if (!Number.isFinite(altLo)) return { fill: '', line: '' };
|
||||
|
||||
const maxKm = cumKm[cumKm.length - 1];
|
||||
if (!maxKm) return { fill: '', line: '' };
|
||||
|
||||
// No vertical pad: the path needs to touch the top/bottom of the
|
||||
// plot exactly, otherwise the HTML axis labels (min/max altitude)
|
||||
// drawn next to the SVG won't line up with the actual peak/trough.
|
||||
const yMin = altLo;
|
||||
const ySpread = altHi - altLo || 1;
|
||||
|
||||
let line = '';
|
||||
let firstX: number | null = null;
|
||||
let lastX: number | null = null;
|
||||
const append = (i: number) => {
|
||||
const a = track[i][2];
|
||||
if (typeof a !== 'number') return;
|
||||
const x = (cumKm[i] / maxKm) * FALLBACK_VB_W;
|
||||
const y = (1 - (a - yMin) / ySpread) * FALLBACK_VB_H;
|
||||
if (firstX === null) {
|
||||
firstX = x;
|
||||
line = `M${x.toFixed(1)} ${y.toFixed(1)}`;
|
||||
} else {
|
||||
line += `L${x.toFixed(1)} ${y.toFixed(1)}`;
|
||||
}
|
||||
lastX = x;
|
||||
};
|
||||
for (let i = 0; i < track.length; i += step) append(i);
|
||||
// Always include the last sample so the trace runs to maxKm.
|
||||
if ((track.length - 1) % step !== 0) append(track.length - 1);
|
||||
if (firstX === null || lastX === null) return { fill: '', line: '' };
|
||||
const fill = `${line}L${lastX.toFixed(1)} ${FALLBACK_VB_H}L${firstX.toFixed(1)} ${FALLBACK_VB_H}Z`;
|
||||
return { fill, line };
|
||||
});
|
||||
|
||||
// Axis ticks for the SSR fallback: five values per axis (start, three
|
||||
// intermediates, end) so each axis reads as a properly-scaled chart,
|
||||
// not just labelled at the bookends. y-ticks are emitted top-to-bottom
|
||||
// so the first label = max altitude, matching the SVG's y=0-at-top
|
||||
// coordinate system. The three intermediate y-tick fractions (0.75,
|
||||
// 0.5, 0.25) double as the soft helpline positions inside the plot,
|
||||
// expressed as `viewBox` y-offsets below.
|
||||
const elevFallbackKm = $derived(cumKm[cumKm.length - 1] ?? 0);
|
||||
|
||||
const elevFallbackYTicks = $derived.by(() => {
|
||||
let lo = Infinity;
|
||||
let hi = -Infinity;
|
||||
for (let i = 0; i < track.length; i++) {
|
||||
const a = track[i][2];
|
||||
if (typeof a === 'number') {
|
||||
if (a < lo) lo = a;
|
||||
if (a > hi) hi = a;
|
||||
}
|
||||
}
|
||||
if (!Number.isFinite(lo)) return null;
|
||||
const min = Math.round(lo);
|
||||
const max = Math.round(hi);
|
||||
const span = max - min;
|
||||
return [
|
||||
max,
|
||||
Math.round(min + span * 0.75),
|
||||
Math.round(min + span * 0.5),
|
||||
Math.round(min + span * 0.25),
|
||||
min
|
||||
];
|
||||
});
|
||||
|
||||
const elevFallbackXTicks = $derived.by(() => {
|
||||
const max = elevFallbackKm;
|
||||
if (!max) return [];
|
||||
return [0, max * 0.25, max * 0.5, max * 0.75, max];
|
||||
});
|
||||
|
||||
// Horizontal helpline positions inside the SVG (viewBox y-coords).
|
||||
// Only the three intermediates — the top and bottom of the plot are
|
||||
// already framed by the filled area's edge, so adding gridlines there
|
||||
// would just be visual noise.
|
||||
const ELEV_FALLBACK_GRID_Y = [
|
||||
FALLBACK_VB_H * 0.25,
|
||||
FALLBACK_VB_H * 0.5,
|
||||
FALLBACK_VB_H * 0.75
|
||||
];
|
||||
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
// Flip the SSR-fallback flag synchronously with chart creation so the
|
||||
// next paint already shows the canvas underneath the fading SVG.
|
||||
// Set inside `createChart` (not only in `onMount`) so theme rebuilds
|
||||
// don't briefly flash the SVG back; the flag is one-way (never reset).
|
||||
chartReady = true;
|
||||
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. When a stage is
|
||||
// selected, the window narrows to that stage.
|
||||
min: xBounds().min,
|
||||
max: xBounds().max,
|
||||
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');
|
||||
});
|
||||
|
||||
// Re-window the x-axis when the active stage changes (reads `viewRange`).
|
||||
$effect(() => {
|
||||
const b = (() => {
|
||||
void viewRange;
|
||||
return xBounds();
|
||||
})();
|
||||
if (!chart) return;
|
||||
const xScale = chart.options.scales?.x;
|
||||
if (!xScale) return;
|
||||
xScale.min = b.min;
|
||||
xScale.max = b.max;
|
||||
chart.update('none');
|
||||
});
|
||||
|
||||
// Rebuild the dataset when the track data itself changes — e.g. the route
|
||||
// builder edits the route live. On the static detail page `track` is stable
|
||||
// after its one-time fetch, so this runs once (no-op) and never again.
|
||||
$effect(() => {
|
||||
const pts = track;
|
||||
const ck = cumKm;
|
||||
if (!chart) return;
|
||||
chart.data.datasets[0].data = pts.map((p, i) => ({
|
||||
x: ck[i],
|
||||
y: typeof p[2] === 'number' ? p[2] : null
|
||||
}));
|
||||
const b = xBounds();
|
||||
const xScale = chart.options.scales?.x;
|
||||
if (xScale) {
|
||||
xScale.min = b.min;
|
||||
xScale.max = b.max;
|
||||
}
|
||||
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">
|
||||
<!-- Static SVG profile rendered server-side so no-JS readers (and JS
|
||||
users pre-hydration) see the elevation graph without waiting on
|
||||
Chart.js. The grid lays out the axis gutters (y-title + y-ticks
|
||||
on the left, x-ticks + x-title under) so the SVG plot occupies
|
||||
the same content region Chart.js will use for its chart area.
|
||||
Once the canvas chart paints, this layer fades out and
|
||||
`pointer-events: none` cedes hover to the interactive chart. -->
|
||||
<div class="elev-fallback" class:hidden={chartReady} aria-hidden="true">
|
||||
<div class="y-title">Höhe (m)</div>
|
||||
<ol class="y-ticks">
|
||||
{#if elevFallbackYTicks}
|
||||
{#each elevFallbackYTicks as v (v)}
|
||||
<li>{v}</li>
|
||||
{/each}
|
||||
{/if}
|
||||
</ol>
|
||||
<svg
|
||||
class="elev-fallback-svg"
|
||||
viewBox="0 0 {FALLBACK_VB_W} {FALLBACK_VB_H}"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<!-- Soft helplines, one per intermediate y-tick. `non-scaling-
|
||||
stroke` keeps them at 1 px even when the SVG is stretched
|
||||
horizontally by `preserveAspectRatio="none"`. -->
|
||||
<g class="elev-fallback-grid">
|
||||
{#each ELEV_FALLBACK_GRID_Y as gy (gy)}
|
||||
<line
|
||||
x1="0"
|
||||
y1={gy}
|
||||
x2={FALLBACK_VB_W}
|
||||
y2={gy}
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
{/each}
|
||||
</g>
|
||||
{#if elevFallback.fill}
|
||||
<path d={elevFallback.fill} class="elev-fallback-fill" />
|
||||
<path
|
||||
d={elevFallback.line}
|
||||
class="elev-fallback-line"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
<ol class="x-ticks">
|
||||
{#each elevFallbackXTicks as v (v)}
|
||||
<li>{v.toFixed(1)}</li>
|
||||
{/each}
|
||||
</ol>
|
||||
<div class="x-title">Distanz (km)</div>
|
||||
</div>
|
||||
<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;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* SSR fallback grid: y-title (rotated) + y-ticks form the left gutter,
|
||||
* x-ticks + x-title form the bottom gutter, the SVG plot fills the
|
||||
* remaining cell. Sized with `calc(100% - 2*padding)` rather than the
|
||||
* top/right/bottom/left-inset shortcut, because `<svg>` is a replaced
|
||||
* element with an intrinsic aspect ratio from `viewBox` that some
|
||||
* browsers let win over a `bottom` constraint — the full-width 220 px
|
||||
* chart was spilling past its rounded box at the bottom.
|
||||
*
|
||||
* The canvas sits on top (z-index 2) so once Chart.js paints, its
|
||||
* scene fully covers this fallback; we still fade the fallback out so
|
||||
* any anti-aliased edge gaps don't leak through. */
|
||||
.elev-fallback {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
left: 1rem;
|
||||
width: calc(100% - 2rem);
|
||||
height: calc(100% - 1.5rem);
|
||||
z-index: 1;
|
||||
opacity: 1;
|
||||
transition: opacity 250ms ease;
|
||||
pointer-events: none;
|
||||
display: grid;
|
||||
grid-template-columns: 0.85rem 2rem 1fr;
|
||||
grid-template-rows: 1fr 0.9rem 0.9rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.elev-fallback.hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.y-title {
|
||||
grid-row: 1;
|
||||
grid-column: 1;
|
||||
writing-mode: vertical-rl;
|
||||
transform: rotate(180deg);
|
||||
text-align: center;
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
color: var(--color-text-secondary);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
/* Y-ticks reset list defaults so they read as plain labels. `space-
|
||||
* between` aligns the first/last items with the plot's top/bottom
|
||||
* edges — same Y-range the SVG path uses (no altitude padding), so
|
||||
* the topmost label sits on the highest peak and the bottom one at
|
||||
* the lowest trough. */
|
||||
.y-ticks {
|
||||
grid-row: 1;
|
||||
grid-column: 2;
|
||||
margin: 0;
|
||||
padding: 0 0.3rem 0 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.y-ticks li::after {
|
||||
content: ' m';
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.elev-fallback-svg {
|
||||
grid-row: 1;
|
||||
grid-column: 3;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* X-ticks: 0 / mid / max, evenly distributed along the bottom of the
|
||||
* plot so they line up with the SVG's left edge, midpoint, and right
|
||||
* edge respectively. */
|
||||
.x-ticks {
|
||||
grid-row: 2;
|
||||
grid-column: 3;
|
||||
margin: 0;
|
||||
padding: 0.15rem 0 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.x-title {
|
||||
grid-row: 3;
|
||||
grid-column: 3;
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
letter-spacing: 0.01em;
|
||||
line-height: 1;
|
||||
padding-top: 0.05rem;
|
||||
}
|
||||
|
||||
.elev-fallback-grid line {
|
||||
stroke: currentColor;
|
||||
stroke-width: 1;
|
||||
opacity: 0.15;
|
||||
}
|
||||
|
||||
.elev-fallback-fill {
|
||||
fill: var(--color-primary);
|
||||
fill-opacity: 0.18;
|
||||
}
|
||||
|
||||
.elev-fallback-line {
|
||||
fill: none;
|
||||
stroke: var(--color-primary);
|
||||
stroke-width: 2;
|
||||
stroke-linejoin: round;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,365 @@
|
||||
<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 { resolveCanton } from '$lib/data/cantons';
|
||||
import type { HikeManifestEntry } from '$types/hikes';
|
||||
|
||||
interface Props {
|
||||
hike: HikeManifestEntry;
|
||||
}
|
||||
|
||||
const { hike }: Props = $props();
|
||||
|
||||
const durationLabel = $derived(
|
||||
hike.durationMin !== null && hike.durationMin > 0
|
||||
? `${Math.floor(hike.durationMin / 60)}h ${hike.durationMin % 60}m`
|
||||
: '—'
|
||||
);
|
||||
|
||||
// SAC trail-sign colour scheme (matches the detail page):
|
||||
// T1 yellow Wegweiser, T2/T3 white-red-white Bergwanderweg,
|
||||
// T4–T6 white-blue-white Alpinwanderweg.
|
||||
const sacBand = $derived.by<'yellow' | 'red' | 'blue'>(() => {
|
||||
if (hike.difficulty === 'T1') return 'yellow';
|
||||
if (hike.difficulty === 'T2' || hike.difficulty === 'T3') return 'red';
|
||||
return 'blue';
|
||||
});
|
||||
|
||||
const MONTHS_DE_SHORT = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'];
|
||||
const seasonLabel = $derived.by(() => {
|
||||
const a = hike.seasonStart;
|
||||
const b = hike.seasonEnd;
|
||||
if (a == null || b == null) return null;
|
||||
if (a < 1 || a > 12 || b < 1 || b > 12) return null;
|
||||
return `${MONTHS_DE_SHORT[a - 1]}–${MONTHS_DE_SHORT[b - 1]}`;
|
||||
});
|
||||
|
||||
// "Neu" badge for hikes published within the last 30 days. Uses the
|
||||
// frontmatter date (`YYYY-MM-DD`) compared against the build clock —
|
||||
// good enough for a prerendered listing page that rebuilds on every
|
||||
// content change.
|
||||
const isRecent = $derived.by(() => {
|
||||
const t = Date.parse(hike.date);
|
||||
if (!Number.isFinite(t)) return false;
|
||||
const days = (Date.now() - t) / 86_400_000;
|
||||
return days >= 0 && days <= 30;
|
||||
});
|
||||
|
||||
const canton = $derived(resolveCanton(hike.canton));
|
||||
</script>
|
||||
|
||||
<a class="card" href={resolve('/hikes/[slug]', { slug: hike.slug })} style="view-transition-name: hike-{hike.slug}; view-transition-class: hike-fly-in">
|
||||
<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">
|
||||
{#if canton}
|
||||
<img
|
||||
class="canton-emblem"
|
||||
src={canton.emblemUrl}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
{/if}<span class="region-text"
|
||||
>{hike.region}{hike.canton && hike.canton !== hike.region
|
||||
? `, ${hike.canton}`
|
||||
: ''}</span
|
||||
>
|
||||
</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: 28px;
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.canton-emblem {
|
||||
flex: 0 0 auto;
|
||||
width: 18px;
|
||||
height: 22px;
|
||||
object-fit: contain;
|
||||
/* Most cantonal arms are tall-rectangle shields, a couple (Schwyz,
|
||||
* Solothurn) are squarer — `contain` keeps the proportions correct
|
||||
* inside the fixed slot so a row of cards stays visually aligned. */
|
||||
filter: drop-shadow(0 1px 1px rgb(0 0 0 / 0.18));
|
||||
}
|
||||
|
||||
.region-text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem 0.85rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.metrics span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.metrics :global(svg) {
|
||||
color: var(--color-primary);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.foot {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.2rem 0.55rem;
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.72rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.chip :global(svg) {
|
||||
color: var(--color-text-secondary);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,264 @@
|
||||
<script lang="ts">
|
||||
import { getHikeContext } from './hikeContext.svelte';
|
||||
import { focused } from './focusedImageStore.svelte';
|
||||
import { addScrollAnchor } from './scrollAnchors';
|
||||
import { dev } from '$app/environment';
|
||||
import Lock from '@lucide/svelte/icons/lock';
|
||||
import Clock from '@lucide/svelte/icons/clock';
|
||||
|
||||
interface Props {
|
||||
/** Position in the hike's full chronological image list (0-indexed,
|
||||
* stable across viewers because it refers to the unfiltered list).
|
||||
* Use this for route photos — it carries the map sync + elapsed time. */
|
||||
idx?: number;
|
||||
/** Source filename of an image in the hike's `images/` dir, for an
|
||||
* inline prose photo that isn't a route waypoint. Mutually exclusive
|
||||
* with `idx`. A path is accepted; only the basename is used. */
|
||||
src?: string;
|
||||
/** Alt text override for `src` mode. Falls back to the build-time alt. */
|
||||
alt?: string;
|
||||
/** Marks a `src`-mode prose image as private (auth-gated + lock badge).
|
||||
* Read at BUILD time from the prose by build-hikes — it encodes the image
|
||||
* into the gated `private/` segment. At runtime the component takes the
|
||||
* visibility from the manifest, so this prop is declarative only. */
|
||||
private?: boolean;
|
||||
/** Optional caption shown under the image — narrative blurb, not a
|
||||
* machine-derived label. Elapsed time is shown automatically (idx mode). */
|
||||
caption?: string;
|
||||
}
|
||||
|
||||
const { idx, src, alt, caption }: Props = $props();
|
||||
const ctx = getHikeContext();
|
||||
|
||||
// Prose mode: resolve the named image, hiding private ones from viewers who
|
||||
// may not see them (the gated endpoint would 401 anyway).
|
||||
const named = $derived.by(() => {
|
||||
if (!src) return undefined;
|
||||
const name = src.split('/').pop() ?? src;
|
||||
const n = ctx().imagesByName[name];
|
||||
if (!n) return undefined;
|
||||
if (n.visibility === 'private' && !ctx().showPrivate) return undefined;
|
||||
return n;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (dev && src && !ctx().imagesByName[src.split('/').pop() ?? src]) {
|
||||
console.warn(
|
||||
`[HikeImage] No image named "${src}" in this hike. Put it in the hike's ` +
|
||||
`images/ folder, reference it in the prose, and re-run build-hikes.`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const ip = $derived(idx === undefined ? undefined : ctx().images[idx]);
|
||||
const visible = $derived(ip ? ctx().visibleImages.includes(ip) : false);
|
||||
const visibleIdx = $derived(visible && ip ? 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;
|
||||
});
|
||||
|
||||
// Elapsed time since the hike start (first timestamped track point) — same
|
||||
// "nach X" the photo strip shows, not the absolute wall-clock time.
|
||||
const elapsedLabel = $derived.by(() => {
|
||||
const t = ip?.timestamp;
|
||||
const track = ctx().track;
|
||||
if (typeof t !== 'number' || !track) return null;
|
||||
let start: number | null = null;
|
||||
for (const p of track) {
|
||||
if (typeof p[3] === 'number') {
|
||||
start = p[3];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (start === null) return null;
|
||||
const ms = t - start;
|
||||
if (!Number.isFinite(ms) || ms < 0) return null;
|
||||
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`;
|
||||
});
|
||||
|
||||
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 src}
|
||||
{#if named}
|
||||
<figure class="hike-image">
|
||||
<picture>
|
||||
<source type="image/avif" srcset={named.srcsetAvif} sizes="(max-width: 680px) 100vw, 680px" />
|
||||
<source type="image/webp" srcset={named.srcsetWebp} sizes="(max-width: 680px) 100vw, 680px" />
|
||||
<img
|
||||
src={named.src}
|
||||
alt={alt ?? named.alt}
|
||||
width={named.width}
|
||||
height={named.height}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</picture>
|
||||
{#if named.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}
|
||||
{:else 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 elapsedLabel}
|
||||
<span class="shot-time" title="Zeit seit Start">
|
||||
<Clock size={11} strokeWidth={2.25} aria-hidden="true" />
|
||||
nach {elapsedLabel}
|
||||
</span>
|
||||
{/if}
|
||||
{#if caption}
|
||||
<figcaption>{caption}</figcaption>
|
||||
{/if}
|
||||
</figure>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.hike-image {
|
||||
position: relative;
|
||||
/* Cap the width so that in the single-column (mobile/tablet) layout the
|
||||
* photo doesn't blow up to the full content width on wider screens.
|
||||
* On the desktop two-column layout the prose column is already narrower
|
||||
* than this, so it stays full-bleed-in-column there. Centered via
|
||||
* auto inline margins. */
|
||||
max-width: 680px;
|
||||
margin: 2rem auto;
|
||||
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 picture {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
/* Capture time, bottom-right so it never collides with the private badge. */
|
||||
.shot-time {
|
||||
position: absolute;
|
||||
bottom: 0.6rem;
|
||||
right: 0.6rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.28rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: var(--radius-pill);
|
||||
background: rgb(0 0 0 / 0.55);
|
||||
color: #fff;
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* Sits within the rounded image; if a caption follows, the figure grows so
|
||||
* the badge stays over the photo (absolute to the figure, image is the top
|
||||
* block). */
|
||||
.hike-image:has(figcaption) .shot-time {
|
||||
bottom: auto;
|
||||
top: 0.6rem;
|
||||
right: 0.6rem;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hike-image {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,969 @@
|
||||
<script lang="ts">
|
||||
import type { Attachment } from 'svelte/attachments';
|
||||
import type { HikeTrackPoint, ImagePoint, HikeStage } from '$types/hikes';
|
||||
import { hover, setHover, clearHover } from './hoverStore.svelte';
|
||||
import { stage } from './stageStore.svelte';
|
||||
import { TILE_URL, TILE_ATTRIBUTION } from '$lib/data/mapTiles';
|
||||
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;
|
||||
/** Initial map centre `[lat, lng]`. When provided alongside
|
||||
* `initialZoom`, the map opens with `setView(center, zoom)` instead
|
||||
* of `fitBounds(track)` — used by the detail page to align Leaflet's
|
||||
* first paint with the SSR-rendered static hero map. */
|
||||
initialCenter?: [number, number];
|
||||
initialZoom?: number;
|
||||
/** Fires once the schematic tile layer's first batch of tiles has
|
||||
* finished loading — i.e. the map is visually complete. The detail
|
||||
* page uses this to fade out the SSR-rendered static hero. */
|
||||
onReady?: () => void;
|
||||
/** Polyline colour. Defaults to Nord red. Callers set this to the
|
||||
* SAC-tier colour so the live trail matches the colour of the same
|
||||
* route on the /hikes overview map (orange for T1, red for T2/T3,
|
||||
* blue for T4–T6). */
|
||||
trackColor?: string;
|
||||
/** Stage ranges for a multi-day hike. When a stage is active (shared
|
||||
* stageStore) the map highlights it, dims the rest, zooms to it, and
|
||||
* scopes photo markers to that stage. */
|
||||
stages?: HikeStage[] | null;
|
||||
/** Whether the hike lies in a swisstopo-covered region (CH/LI). Drives
|
||||
* the schematic max zoom (19 in-region vs 17 for OpenTopoMap abroad)
|
||||
* and whether the CH/LI-only Dufour layer is offered. */
|
||||
swissRegion?: boolean;
|
||||
}
|
||||
|
||||
const {
|
||||
track,
|
||||
imagePoints = [],
|
||||
showPrivate = false,
|
||||
initialCenter,
|
||||
initialZoom,
|
||||
onReady,
|
||||
trackColor,
|
||||
stages = null,
|
||||
swissRegion = true
|
||||
}: 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;
|
||||
}
|
||||
|
||||
|
||||
type BaseLayer = 'schematic' | 'aerial' | 'dufour';
|
||||
type LayerDef = { label: string; icon: typeof Map; maxZoom: number };
|
||||
// Schematic max zoom is region-aware: swisstopo reaches z19 over CH/LI,
|
||||
// but the global fallback (OpenTopoMap) only serves to z17.
|
||||
const LAYER_DEFS: Record<BaseLayer, LayerDef> = $derived({
|
||||
schematic: { label: 'Karte', icon: Map, maxZoom: swissRegion ? 19 : 17 },
|
||||
aerial: { label: 'Luftbild', icon: Satellite, maxZoom: 19 },
|
||||
// Dufour Map (1845–1864): swisstopo's historical layer, only goes up
|
||||
// to roughly z16. We cap the map's maxZoom when this layer is active.
|
||||
dufour: { label: 'Dufour (1864)', icon: Landmark, maxZoom: 16 }
|
||||
});
|
||||
|
||||
// The Dufour historical layer exists only for CH/LI — hide it abroad.
|
||||
const layerOptions = $derived(
|
||||
Object.entries(LAYER_DEFS).filter(([key]) => swissRegion || key !== 'dufour') as [
|
||||
BaseLayer,
|
||||
LayerDef
|
||||
][]
|
||||
);
|
||||
|
||||
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, {
|
||||
// On-map attribution control removed for a cleaner frame; the
|
||||
// required swisstopo credit is repeated in the page's meta footer
|
||||
// ("Kartendaten © swisstopo").
|
||||
attributionControl: false,
|
||||
zoomControl: true,
|
||||
preferCanvas: true
|
||||
});
|
||||
|
||||
const tileLayers: Record<BaseLayer, ReturnType<typeof L.tileLayer>> = {
|
||||
schematic: L.tileLayer(TILE_URL.karte, {
|
||||
maxZoom: LAYER_DEFS.schematic.maxZoom,
|
||||
minZoom: 7,
|
||||
attribution: TILE_ATTRIBUTION,
|
||||
updateWhenZooming: false
|
||||
}),
|
||||
aerial: L.tileLayer(TILE_URL.luftbild, {
|
||||
maxZoom: LAYER_DEFS.aerial.maxZoom,
|
||||
minZoom: 7,
|
||||
attribution: TILE_ATTRIBUTION,
|
||||
updateWhenZooming: false
|
||||
}),
|
||||
dufour: L.tileLayer(TILE_URL.dufour, {
|
||||
maxZoom: LAYER_DEFS.dufour.maxZoom,
|
||||
minZoom: 7,
|
||||
attribution: TILE_ATTRIBUTION,
|
||||
updateWhenZooming: false
|
||||
})
|
||||
};
|
||||
tileLayers.schematic.addTo(map);
|
||||
let currentBase: BaseLayer = 'schematic';
|
||||
|
||||
// First-paint handover: when the schematic tile layer finishes
|
||||
// loading its initial batch, fire `onReady` so the static hero
|
||||
// can fade out. The map already opened at the static pose via
|
||||
// `setView(initialCenter, initialZoom)` below, so the live
|
||||
// tiles paint over the static at the same framing — no second
|
||||
// animation is needed (and a `flyToBounds` here would actually
|
||||
// cause a visible wobble on hikes whose bbox sits right at an
|
||||
// integer-zoom boundary, where the static's fit and Leaflet's
|
||||
// runtime fit disagree by one zoom step at the user's actual
|
||||
// container size).
|
||||
tileLayers.schematic.once('load', () => {
|
||||
onReady?.();
|
||||
});
|
||||
|
||||
// Canvas-rendered polylines can't resolve CSS custom properties,
|
||||
// so the caller hands us a literal colour. Falls back to Nord red
|
||||
// for any caller that hasn't been updated yet.
|
||||
const trailColor =
|
||||
trackColor ??
|
||||
(getComputedStyle(document.documentElement).getPropertyValue('--red').trim() ||
|
||||
'#bf616a');
|
||||
|
||||
// Non-interactive: hover is driven by the whole-map `mousemove`
|
||||
// handler below (snap-to-nearest), so the line itself needn't grab
|
||||
// the pointer cursor or events.
|
||||
const polyline = L.polyline(latLngs, {
|
||||
color: trailColor,
|
||||
weight: 4,
|
||||
opacity: 0.95,
|
||||
interactive: false
|
||||
}).addTo(map);
|
||||
|
||||
// Brighter overlay drawn over the active stage (multi-day hikes); the
|
||||
// base line is dimmed underneath it. Empty until a stage is selected.
|
||||
const stageOverlay = L.polyline([] as [number, number][], {
|
||||
color: trailColor,
|
||||
weight: 6,
|
||||
opacity: 1,
|
||||
interactive: false
|
||||
});
|
||||
|
||||
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();
|
||||
// When the caller supplies a specific center+zoom (e.g. the detail
|
||||
// page handing over from a pre-rendered static hero), open with
|
||||
// `setView` so Leaflet lands on the exact same pose the static
|
||||
// image was rendered at. Otherwise fall back to fitBounds.
|
||||
if (initialCenter && typeof initialZoom === 'number') {
|
||||
map.setView(initialCenter, initialZoom, { animate: false });
|
||||
} else {
|
||||
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>';
|
||||
|
||||
// Nearest track sample (by time) to a photo — used to test which
|
||||
// stage a photo belongs to when scoping markers to the active stage.
|
||||
function nearestTrackIdx(ts: number): number {
|
||||
let best = -1;
|
||||
let bestD = Infinity;
|
||||
for (let i = 0; i < track.length; i++) {
|
||||
const t = track[i][3];
|
||||
if (typeof t !== 'number') continue;
|
||||
const d = Math.abs(t - ts);
|
||||
if (d < bestD) {
|
||||
bestD = d;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
function photoInActiveStage(ip: ImagePoint): boolean {
|
||||
const active = stage.active;
|
||||
if (active === null || !stages || !stages[active]) return true;
|
||||
if (typeof ip.timestamp !== 'number') return false;
|
||||
const s = stages[active];
|
||||
const idx = nearestTrackIdx(ip.timestamp);
|
||||
return idx >= s.startIdx && idx <= s.endIdx;
|
||||
}
|
||||
|
||||
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);
|
||||
// Keep `visiblePoints` aligned with the strip's index space, but
|
||||
// only draw a marker when the photo is in the active stage.
|
||||
if (!photoInActiveStage(ip)) continue;
|
||||
const altSafe = ip.alt.replace(/"/g, '"');
|
||||
const isPrivate = ip.visibility === 'private';
|
||||
const icon = L.divIcon({
|
||||
className: `hike-photo-marker${isPrivate ? ' is-private' : ''}`,
|
||||
html: `<span class="badge" title="${altSafe}">${cameraSvg}</span>`,
|
||||
iconSize: [28, 28],
|
||||
iconAnchor: [14, 14]
|
||||
});
|
||||
const marker = L.marker([ip.lat, ip.lng], { icon });
|
||||
marker.on('mouseover', () => {
|
||||
// Hover preview → stage. Distinct source so the strip
|
||||
// skips scroll (jerky across dense clusters) and the
|
||||
// map skips its own flyTo + focus ring.
|
||||
setFocused(visibleIdx, 'map-hover');
|
||||
// Also drive the chart cursor via the nearest track sample.
|
||||
if (typeof ip.timestamp !== 'number') return;
|
||||
let bestIdx = 0;
|
||||
let bestDelta = Infinity;
|
||||
for (let i = 0; i < track.length; i++) {
|
||||
const t = track[i][3];
|
||||
if (typeof t !== 'number') continue;
|
||||
const d = Math.abs(t - ip.timestamp);
|
||||
if (d < bestDelta) {
|
||||
bestDelta = d;
|
||||
bestIdx = i;
|
||||
}
|
||||
}
|
||||
setHover(bestIdx, 'image');
|
||||
});
|
||||
// Clear chart hover but keep `focused` sticky so the stage
|
||||
// keeps showing the last hovered image.
|
||||
marker.on('mouseout', () => clearHover());
|
||||
// Click (touch fallback): same semantics as a strip click —
|
||||
// scroll the strip card into view and centre the map.
|
||||
marker.on('click', () => setFocused(visibleIdx, 'map'));
|
||||
marker.addTo(photoLayer);
|
||||
visibleMarkers.push(marker);
|
||||
}
|
||||
}
|
||||
renderPhotos();
|
||||
|
||||
// Focus ring: a non-vector divIcon (so we can CSS-animate it under
|
||||
// Leaflet's canvas renderer). Created lazily on first focus.
|
||||
const focusIcon = L.divIcon({
|
||||
className: 'hike-photo-focus-ring',
|
||||
html: '<div class="ring"></div><div class="ring delay"></div>',
|
||||
iconSize: [80, 80],
|
||||
iconAnchor: [40, 40]
|
||||
});
|
||||
let focusMarker: ReturnType<typeof L.marker> | null = null;
|
||||
|
||||
// React to the shared hover store: drive the polyline cursor marker.
|
||||
// When the hover comes from the chart and the point is outside the
|
||||
// currently-visible map area, pan the map so the pin stays in view —
|
||||
// using `pad(-0.12)` shrinks the trigger bounds so we pan a touch
|
||||
// before the pin actually hits the edge.
|
||||
const stopHoverEffect = $effect.root(() => {
|
||||
$effect(() => {
|
||||
if (hover.index === null || hover.index < 0 || hover.index >= latLngs.length) {
|
||||
hoverMarker.remove();
|
||||
return;
|
||||
}
|
||||
const ll = latLngs[hover.index];
|
||||
hoverMarker.setLatLng(ll);
|
||||
hoverMarker.addTo(map);
|
||||
// Only auto-pan for cursors driven from elsewhere (chart /
|
||||
// scroll tracker). A map-sourced hover means the user is
|
||||
// already pointing here, so panning would fight them.
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Elevation tracking: rather than requiring the pointer to be exactly
|
||||
// on the thin trail, snap the chart cursor to the nearest track point
|
||||
// whenever the mouse is anywhere within HOVER_SNAP_PX of the route.
|
||||
// The track is cached in layer-point (pixel) space so each pointer
|
||||
// move is just cheap distance maths; the cache is rebuilt on zoom/
|
||||
// move (layer points are pan-invariant, but rebuilding on moveend
|
||||
// keeps it correct regardless of how the view changed).
|
||||
const HOVER_SNAP_PX = 70;
|
||||
let projected: { x: number; y: number }[] = [];
|
||||
function reproject() {
|
||||
projected = latLngs.map((ll) => map.latLngToLayerPoint(ll));
|
||||
}
|
||||
reproject();
|
||||
map.on('zoomend moveend', reproject);
|
||||
|
||||
map.on('mousemove', (e: { layerPoint: { x: number; y: number } }) => {
|
||||
if (projected.length === 0) return;
|
||||
const { x, y } = e.layerPoint;
|
||||
let bestIdx = 0;
|
||||
let bestSq = Infinity;
|
||||
for (let i = 0; i < projected.length; i++) {
|
||||
const dx = projected[i].x - x;
|
||||
const dy = projected[i].y - y;
|
||||
const sq = dx * dx + dy * dy;
|
||||
if (sq < bestSq) {
|
||||
bestSq = sq;
|
||||
bestIdx = i;
|
||||
}
|
||||
}
|
||||
if (bestSq <= HOVER_SNAP_PX * HOVER_SNAP_PX) {
|
||||
setHover(bestIdx, 'map');
|
||||
} else if (hover.source === 'map') {
|
||||
clearHover();
|
||||
}
|
||||
});
|
||||
map.on('mouseout', () => {
|
||||
if (hover.source === 'map') 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.
|
||||
let stageInitialized = false;
|
||||
const stopReactRoot = $effect.root(() => {
|
||||
$effect(() => {
|
||||
renderPhotos();
|
||||
});
|
||||
// Active-stage highlight + zoom. The first run (active === null on
|
||||
// mount) only normalises the base style — it must NOT fly, or it
|
||||
// would clobber the static-hero → live handover above.
|
||||
$effect(() => {
|
||||
const active = stage.active;
|
||||
if (active !== null && stages && stages[active]) {
|
||||
const s = stages[active];
|
||||
stageOverlay.setLatLngs(latLngs.slice(s.startIdx, s.endIdx + 1));
|
||||
if (!map.hasLayer(stageOverlay)) stageOverlay.addTo(map);
|
||||
polyline.setStyle({ opacity: 0.28 });
|
||||
const b = stageOverlay.getBounds();
|
||||
if (b.isValid()) {
|
||||
map.flyToBounds(b, { padding: [40, 40], duration: 0.6, easeLinearity: 0.25 });
|
||||
}
|
||||
stageInitialized = true;
|
||||
} else {
|
||||
if (map.hasLayer(stageOverlay)) stageOverlay.remove();
|
||||
polyline.setStyle({ opacity: 0.95 });
|
||||
if (stageInitialized) {
|
||||
map.flyToBounds(initialBounds, { padding: [24, 24], duration: 0.6, easeLinearity: 0.25 });
|
||||
}
|
||||
}
|
||||
});
|
||||
$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 layerOptions 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;
|
||||
/* Nord red — deliberately off the primary palette so the cursor pin
|
||||
* reads as a distinct "you are here" marker against the blue-ish
|
||||
* trail / UI accents. `currentColor` drives the SVG fill. */
|
||||
color: var(--red);
|
||||
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,764 @@
|
||||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition';
|
||||
import type { HikeTrackPoint, ImagePoint, HikeStage } from '$types/hikes';
|
||||
import { focused, setFocused } from './focusedImageStore.svelte';
|
||||
import { stage } from './stageStore.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';
|
||||
import Expand from '@lucide/svelte/icons/expand';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
|
||||
interface Props {
|
||||
images: ImagePoint[];
|
||||
track: HikeTrackPoint[];
|
||||
/** Stage ranges (multi-day hikes). When a stage is active, the strip
|
||||
* shows only that stage's photos. Indices stay aligned with the full
|
||||
* list so the shared focus store keeps matching the map. */
|
||||
stages?: HikeStage[] | null;
|
||||
}
|
||||
|
||||
const { images, track, stages = null }: Props = $props();
|
||||
|
||||
// Nearest track index (by time) per image — for testing stage membership.
|
||||
const imageTrackIdx = $derived(
|
||||
images.map((ip) => {
|
||||
if (typeof ip.timestamp !== 'number') return -1;
|
||||
let best = -1;
|
||||
let bestD = 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 < bestD) {
|
||||
bestD = d;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
})
|
||||
);
|
||||
|
||||
const activeStageRange = $derived.by(() => {
|
||||
if (stage.active === null || !stages || !stages[stage.active]) return null;
|
||||
const s = stages[stage.active];
|
||||
return { startIdx: s.startIdx, endIdx: s.endIdx };
|
||||
});
|
||||
|
||||
function inActiveStage(i: number): boolean {
|
||||
const r = activeStageRange;
|
||||
if (!r) return true;
|
||||
const idx = imageTrackIdx[i];
|
||||
return idx >= r.startIdx && idx <= r.endIdx;
|
||||
}
|
||||
|
||||
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<HTMLElement | null> = $state([]);
|
||||
let scrollEl = $state<HTMLDivElement | undefined>(undefined);
|
||||
|
||||
// Fullscreen lightbox. Independent of `focused` (which drives the map),
|
||||
// but opening / navigating also syncs `focused` so the map + strip follow
|
||||
// whatever is being viewed full-screen.
|
||||
let lightboxIndex = $state<number | null>(null);
|
||||
const lightboxOpen = $derived(lightboxIndex !== null);
|
||||
let closeBtn = $state<HTMLButtonElement | undefined>(undefined);
|
||||
|
||||
function openLightbox(i: number): void {
|
||||
lightboxIndex = i;
|
||||
setFocused(i, 'strip');
|
||||
}
|
||||
|
||||
function closeLightbox(): void {
|
||||
lightboxIndex = null;
|
||||
}
|
||||
|
||||
function lightboxStep(dir: -1 | 1): void {
|
||||
if (lightboxIndex === null) return;
|
||||
const n = lightboxIndex + dir;
|
||||
if (n < 0 || n >= images.length) return;
|
||||
lightboxIndex = n;
|
||||
setFocused(n, 'strip');
|
||||
}
|
||||
|
||||
// While open: Esc closes, arrows navigate, body scroll is locked, and focus
|
||||
// moves into the dialog. Keyed on `lightboxOpen` (not the index) so stepping
|
||||
// between images doesn't re-run the setup or steal focus back to close.
|
||||
$effect(() => {
|
||||
if (!lightboxOpen) return;
|
||||
closeBtn?.focus();
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') closeLightbox();
|
||||
else if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
lightboxStep(-1);
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
lightboxStep(1);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
};
|
||||
});
|
||||
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
|
||||
// Step to the next/previous photo that's in the active stage (skips photos
|
||||
// hidden by stage scoping).
|
||||
function advance(direction: -1 | 1): void {
|
||||
if (images.length === 0) return;
|
||||
let i = focused.index === null ? (direction === 1 ? -1 : images.length) : focused.index;
|
||||
i += direction;
|
||||
while (i >= 0 && i < images.length) {
|
||||
if (inActiveStage(i)) {
|
||||
setFocused(i, 'strip');
|
||||
return;
|
||||
}
|
||||
i += direction;
|
||||
}
|
||||
}
|
||||
|
||||
const canPrev = $derived.by(() => {
|
||||
if (focused.index === null) return false;
|
||||
for (let i = focused.index - 1; i >= 0; i--) if (inActiveStage(i)) return true;
|
||||
return false;
|
||||
});
|
||||
const canNext = $derived.by(() => {
|
||||
const start = focused.index === null ? -1 : focused.index;
|
||||
for (let i = start + 1; i < images.length; i++) if (inActiveStage(i)) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
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}
|
||||
{#if inActiveStage(i)}
|
||||
<div class="card-wrap" class:active bind:this={cardEls[i]}>
|
||||
<button
|
||||
type="button"
|
||||
class="card"
|
||||
class:private={ip.visibility === 'private'}
|
||||
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>
|
||||
<button
|
||||
type="button"
|
||||
class="expand"
|
||||
aria-label={`Foto ${i + 1} im Vollbild öffnen`}
|
||||
title="Vollbild"
|
||||
onclick={() => openLightbox(i)}
|
||||
>
|
||||
<Expand size={15} strokeWidth={2} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/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 lightboxIndex !== null}
|
||||
{@const ip = images[lightboxIndex]}
|
||||
{@const elapsed =
|
||||
ip.timestamp != null && startTimestamp != null
|
||||
? formatElapsed(ip.timestamp - startTimestamp)
|
||||
: null}
|
||||
<div
|
||||
class="lightbox"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`Foto ${lightboxIndex + 1} von ${images.length}`}
|
||||
transition:fade={{ duration: 150 }}
|
||||
>
|
||||
<button class="lb-backdrop" aria-label="Schließen" onclick={closeLightbox}></button>
|
||||
|
||||
<button
|
||||
class="lb-btn lb-close"
|
||||
aria-label="Schließen"
|
||||
bind:this={closeBtn}
|
||||
onclick={closeLightbox}
|
||||
>
|
||||
<X size={22} strokeWidth={2} aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
{#if lightboxIndex > 0}
|
||||
<button class="lb-btn lb-prev" aria-label="Vorheriges Bild" onclick={() => lightboxStep(-1)}>
|
||||
<ChevronLeft size={26} strokeWidth={2.25} aria-hidden="true" />
|
||||
</button>
|
||||
{/if}
|
||||
{#if lightboxIndex < images.length - 1}
|
||||
<button class="lb-btn lb-next" aria-label="Nächstes Bild" onclick={() => lightboxStep(1)}>
|
||||
<ChevronRight size={26} strokeWidth={2.25} aria-hidden="true" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<figure class="lb-figure">
|
||||
<img src={ip.src} alt={ip.alt} />
|
||||
<figcaption class="lb-caption">
|
||||
<span class="lb-count">{lightboxIndex + 1} / {images.length}</span>
|
||||
{#if elapsed}<span class="lb-elapsed">nach {elapsed}</span>{/if}
|
||||
{#if ip.alt}<span class="lb-alt">{ip.alt}</span>{/if}
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
{/if}
|
||||
{/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);
|
||||
}
|
||||
|
||||
/* The wrapper is the flex item: it carries the size, scroll-snap and the
|
||||
* lift/scale transform. The card button and the expand button live inside
|
||||
* it as siblings (a button can't be nested in a button). */
|
||||
.card-wrap {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
width: 232px;
|
||||
scroll-snap-align: center;
|
||||
border-radius: var(--radius-lg);
|
||||
transform: translateY(0) scale(1);
|
||||
transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.card-wrap:hover,
|
||||
.card-wrap:focus-within {
|
||||
transform: translateY(-2px) scale(1.02);
|
||||
}
|
||||
|
||||
/* Active card stands out via a much heavier, tinted drop shadow rather
|
||||
* than dimming everything else — keeps every photo legible. */
|
||||
.card-wrap.active {
|
||||
transform: translateY(-6px) scale(1.05);
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: box-shadow 220ms ease;
|
||||
}
|
||||
|
||||
.card-wrap:hover .card,
|
||||
.card-wrap:focus-within .card {
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.card:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.card-wrap.active .card {
|
||||
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);
|
||||
}
|
||||
|
||||
/* Fullscreen trigger — a circular badge in the top-right of each card.
|
||||
* Hidden until the card is hovered/focused/active (always shown on touch
|
||||
* devices, which have no hover). */
|
||||
.expand {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
z-index: 3;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: rgb(0 0 0 / 0.5);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transform: scale(0.85);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
transition:
|
||||
opacity var(--transition-fast),
|
||||
transform var(--transition-fast),
|
||||
background var(--transition-fast);
|
||||
}
|
||||
|
||||
.card-wrap:hover .expand,
|
||||
.card-wrap:focus-within .expand,
|
||||
.card-wrap.active .expand {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.expand:hover {
|
||||
background: rgb(0 0 0 / 0.72);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.expand:focus-visible {
|
||||
outline: 2px solid #fff;
|
||||
outline-offset: 2px;
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.expand {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.card img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
/* 3:2 — a touch shorter than the old 4:3 so the strip sits compactly
|
||||
* above the stats row without dominating the page. */
|
||||
aspect-ratio: 3 / 2;
|
||||
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-wrap {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.chev {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.chev-left {
|
||||
left: -4px;
|
||||
}
|
||||
|
||||
.chev-right {
|
||||
right: -4px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.card-wrap,
|
||||
.card,
|
||||
.strip-scroll,
|
||||
.chev,
|
||||
.expand {
|
||||
transition: none;
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Fullscreen lightbox ─────────────────────────────────────────────── */
|
||||
.lightbox {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9000;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1.5rem;
|
||||
background: rgb(0 0 0 / 0.92);
|
||||
}
|
||||
|
||||
.lb-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
cursor: zoom-out;
|
||||
}
|
||||
|
||||
.lb-figure {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
max-width: 92vw;
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
.lb-figure img {
|
||||
max-width: 92vw;
|
||||
max-height: 82vh;
|
||||
object-fit: contain;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 12px 48px rgb(0 0 0 / 0.55);
|
||||
}
|
||||
|
||||
.lb-caption {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem 0.85rem;
|
||||
max-width: 92vw;
|
||||
color: rgb(255 255 255 / 0.88);
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.lb-count {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.lb-elapsed {
|
||||
color: rgb(255 255 255 / 0.7);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.lb-alt {
|
||||
color: rgb(255 255 255 / 0.6);
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.lb-btn {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: rgb(255 255 255 / 0.12);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
transition:
|
||||
background var(--transition-fast),
|
||||
transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.lb-btn:hover {
|
||||
background: rgb(255 255 255 / 0.24);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.lb-btn:focus-visible {
|
||||
outline: 2px solid #fff;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.lb-close {
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
}
|
||||
|
||||
.lb-prev {
|
||||
left: 1rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.lb-next {
|
||||
right: 1rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.lb-prev:hover,
|
||||
.lb-next:hover {
|
||||
transform: translateY(-50%) scale(1.08);
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.lb-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.lb-close {
|
||||
top: 0.6rem;
|
||||
right: 0.6rem;
|
||||
}
|
||||
|
||||
.lb-prev {
|
||||
left: 0.5rem;
|
||||
}
|
||||
|
||||
.lb-next {
|
||||
right: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,191 @@
|
||||
<script lang="ts">
|
||||
// Stage switcher styled as a hut-to-hut itinerary line: a leading "Alle"
|
||||
// pill, then numbered nodes joined by thin connectors. The active stage's
|
||||
// node glows in the accent and its name/distance shows alongside. Light and
|
||||
// in-flow (no boxed/blurred bar) — writes the shared stageStore.
|
||||
import type { HikeStage } from '$types/hikes';
|
||||
import { stage, setActiveStage } from './stageStore.svelte';
|
||||
|
||||
interface Props {
|
||||
stages: HikeStage[];
|
||||
}
|
||||
|
||||
const { stages }: Props = $props();
|
||||
|
||||
const active = $derived(stage.active);
|
||||
const totalKm = $derived(stages.reduce((a, s) => a + s.distanceKm, 0));
|
||||
</script>
|
||||
|
||||
<nav class="stepper" aria-label="Etappen">
|
||||
<button
|
||||
type="button"
|
||||
class="all"
|
||||
class:active={active === null}
|
||||
aria-pressed={active === null}
|
||||
onclick={() => setActiveStage(null)}
|
||||
>
|
||||
Alle
|
||||
</button>
|
||||
|
||||
<ol class="line">
|
||||
{#each stages as s, i (i)}
|
||||
{#if i > 0}
|
||||
<li class="connector" class:lit={active === null} aria-hidden="true"></li>
|
||||
{/if}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="node"
|
||||
class:active={active === i}
|
||||
class:lit={active === null}
|
||||
aria-pressed={active === i}
|
||||
aria-label={`Etappe ${i + 1}: ${s.name}`}
|
||||
title={s.name}
|
||||
onclick={() => setActiveStage(i)}
|
||||
>
|
||||
{i + 1}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
|
||||
<p class="label" aria-live="polite">
|
||||
{#if active === null}
|
||||
<span class="title">Alle Etappen</span>
|
||||
<span class="dist">{totalKm.toFixed(1)} km</span>
|
||||
{:else}
|
||||
<span class="kicker">Etappe {active + 1}</span>
|
||||
<span class="title">{stages[active].name}</span>
|
||||
<span class="dist">{stages[active].distanceKm.toFixed(1)} km</span>
|
||||
{/if}
|
||||
</p>
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 1rem;
|
||||
/* (a) breathing room below the full-bleed hero map; horizontal inset
|
||||
* matches the other detail sections. */
|
||||
margin-top: 1.75rem;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.all {
|
||||
flex: 0 0 auto;
|
||||
appearance: none;
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-pill);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast), background-color var(--transition-fast),
|
||||
border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.all:hover {
|
||||
color: var(--color-text-primary);
|
||||
border-color: var(--color-border-hover);
|
||||
}
|
||||
|
||||
.all.active {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-on-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.connector {
|
||||
width: 1.75rem;
|
||||
height: 2px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 2px;
|
||||
background: var(--color-border);
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.connector.lit {
|
||||
background: color-mix(in srgb, var(--color-primary) 45%, var(--color-border));
|
||||
}
|
||||
|
||||
.node {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
appearance: none;
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: 50%;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast), background-color var(--transition-fast),
|
||||
border-color var(--transition-fast), scale var(--transition-fast),
|
||||
box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.node:hover {
|
||||
scale: 1.1;
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* "Alle" selected: whole line subtly lit so it reads as the full route. */
|
||||
.node.lit {
|
||||
border-color: color-mix(in srgb, var(--color-primary) 55%, var(--color-border));
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.node.active {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-on-primary);
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-primary) 22%, transparent);
|
||||
}
|
||||
|
||||
.label {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.1rem 0.5rem;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.kicker {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.dist {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,776 @@
|
||||
<script lang="ts">
|
||||
import { slide } from 'svelte/transition';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
|
||||
import ChevronDown from '@lucide/svelte/icons/chevron-down';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import RangeSlider from './RangeSlider.svelte';
|
||||
import ChipTypeahead from './ChipTypeahead.svelte';
|
||||
import Toggle from '$lib/components/Toggle.svelte';
|
||||
import { resolveHikeArea, type HikeArea } from '$lib/hikes/hikeArea';
|
||||
import {
|
||||
hikeFilterBounds,
|
||||
DISTANCE_STEP,
|
||||
DURATION_STEP,
|
||||
ELEVATION_STEP
|
||||
} from '$lib/hikes/filterBounds';
|
||||
import type { Difficulty, HikeManifestEntry } from '$types/hikes';
|
||||
|
||||
export type HikesFilter = {
|
||||
minDistanceKm: number;
|
||||
maxDistanceKm: number;
|
||||
minDurationMin: number;
|
||||
maxDurationMin: number;
|
||||
minGainM: number;
|
||||
maxGainM: number;
|
||||
minLossM: number;
|
||||
maxLossM: number;
|
||||
difficulties: SvelteSet<Difficulty>;
|
||||
regions: SvelteSet<string>;
|
||||
/** Namespaced area values — canton (CH) or country (abroad). See
|
||||
* {@link resolveHikeArea}. */
|
||||
areas: SvelteSet<string>;
|
||||
tags: SvelteSet<string>;
|
||||
/** Show only hikes whose recommended season covers the current month. */
|
||||
inSeasonOnly: boolean;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
hikes: HikeManifestEntry[];
|
||||
filter: HikesFilter;
|
||||
/** Hikes passing the current filter — shown in the bar summary. */
|
||||
resultCount: number;
|
||||
/** Total hikes before filtering. */
|
||||
totalCount: number;
|
||||
/** Summed distance / ascent over the filtered subset (already rounded). */
|
||||
totalKm: number;
|
||||
totalGain: number;
|
||||
}
|
||||
|
||||
const DIFFICULTIES: Difficulty[] = ['T1', 'T2', 'T3', 'T4', 'T5', 'T6'];
|
||||
|
||||
const { hikes, filter, resultCount, totalCount, totalKm, totalGain }: Props = $props();
|
||||
|
||||
// Collapsed-by-default: the bar is just a summary + active-filter chips +
|
||||
// a trigger until the user opens the control panel. Keeps the listing's
|
||||
// vertical rhythm clean — the filters only take space when wanted.
|
||||
let open = $state(false);
|
||||
let root = $state<HTMLElement>();
|
||||
|
||||
// Range-slider track extents, derived from the data (see filterBounds.ts —
|
||||
// the same helper seeds the page's default filter state).
|
||||
const bounds = $derived(hikeFilterBounds(hikes));
|
||||
|
||||
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));
|
||||
});
|
||||
|
||||
// Geographic areas present in the data: a Swiss hike contributes its canton,
|
||||
// a hike abroad its country. Deduped by namespaced value; cantons listed
|
||||
// first (alphabetical), then countries (alphabetical).
|
||||
const areaList = $derived.by(() => {
|
||||
const map = new Map<string, HikeArea>();
|
||||
for (const h of hikes) {
|
||||
const a = resolveHikeArea(h.canton, h.country);
|
||||
if (a && !map.has(a.value)) map.set(a.value, a);
|
||||
}
|
||||
return [...map.values()].sort((a, b) =>
|
||||
a.kind === b.kind ? a.label.localeCompare(b.label) : a.kind === 'canton' ? -1 : 1
|
||||
);
|
||||
});
|
||||
const areaValues = $derived(areaList.map((a) => a.value));
|
||||
const areaByValue = $derived(new Map(areaList.map((a) => [a.value, a])));
|
||||
|
||||
// Tags sorted by usage frequency (most-used first), alphabetical for
|
||||
// ties. Frequency ordering surfaces broadly-applicable filters like
|
||||
// "winter" or "easy" at the head of the list, where they're most
|
||||
// useful for narrowing the listing.
|
||||
const tags = $derived.by(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const h of hikes) {
|
||||
for (const t of h.tags ?? []) {
|
||||
counts.set(t, (counts.get(t) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
return [...counts.entries()]
|
||||
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
||||
.map(([t]) => t);
|
||||
});
|
||||
|
||||
function fmtDuration(min: number) {
|
||||
return `${Math.floor(min / 60)}h ${min % 60}m`;
|
||||
}
|
||||
|
||||
// Compact chip label for a narrowed range: "≤ hi" / "≥ lo" / "lo–hi",
|
||||
// suppressing whichever end still sits at its data bound.
|
||||
function rangeLabel(
|
||||
lo: number,
|
||||
hi: number,
|
||||
b: { min: number; max: number },
|
||||
fmt: (v: number) => string,
|
||||
unit: string
|
||||
) {
|
||||
const u = unit ? ` ${unit}` : '';
|
||||
const loNarrowed = lo > b.min;
|
||||
const hiNarrowed = hi < b.max;
|
||||
if (loNarrowed && hiNarrowed) return `${fmt(lo)}–${fmt(hi)}${u}`;
|
||||
if (hiNarrowed) return `≤ ${fmt(hi)}${u}`;
|
||||
return `≥ ${fmt(lo)}${u}`;
|
||||
}
|
||||
|
||||
// SAC trail-sign colour band — matches the card badges (T1 yellow
|
||||
// Wegweiser, T2/T3 red-white Bergweg, T4–T6 blue-white Alpinweg). Used
|
||||
// for the small colour dot on each difficulty toggle.
|
||||
function sacBand(d: Difficulty): 'yellow' | 'red' | 'blue' {
|
||||
if (d === 'T1') return 'yellow';
|
||||
if (d === 'T2' || d === 'T3') return 'red';
|
||||
return 'blue';
|
||||
}
|
||||
|
||||
// Active filters, flattened into removable chips for the collapsed bar.
|
||||
// A range counts as "active" only when narrowed below its data ceiling.
|
||||
type Chip = { key: string; label: string; icon?: string; clear: () => void };
|
||||
const chips = $derived.by<Chip[]>(() => {
|
||||
const out: Chip[] = [];
|
||||
const { distance, duration, gain, loss } = bounds;
|
||||
if (filter.inSeasonOnly)
|
||||
out.push({ key: 'season', label: 'In Saison', clear: () => (filter.inSeasonOnly = false) });
|
||||
if (filter.minDistanceKm > distance.min || filter.maxDistanceKm < distance.max)
|
||||
out.push({
|
||||
key: 'dist',
|
||||
label: rangeLabel(filter.minDistanceKm, filter.maxDistanceKm, distance, (v) => `${v}`, 'km'),
|
||||
clear: () => {
|
||||
filter.minDistanceKm = distance.min;
|
||||
filter.maxDistanceKm = distance.max;
|
||||
}
|
||||
});
|
||||
if (filter.minDurationMin > duration.min || filter.maxDurationMin < duration.max)
|
||||
out.push({
|
||||
key: 'dur',
|
||||
label: rangeLabel(filter.minDurationMin, filter.maxDurationMin, duration, fmtDuration, ''),
|
||||
clear: () => {
|
||||
filter.minDurationMin = duration.min;
|
||||
filter.maxDurationMin = duration.max;
|
||||
}
|
||||
});
|
||||
if (filter.minGainM > gain.min || filter.maxGainM < gain.max)
|
||||
out.push({
|
||||
key: 'gain',
|
||||
label: `↑ ${rangeLabel(filter.minGainM, filter.maxGainM, gain, (v) => `${v}`, 'm')}`,
|
||||
clear: () => {
|
||||
filter.minGainM = gain.min;
|
||||
filter.maxGainM = gain.max;
|
||||
}
|
||||
});
|
||||
if (filter.minLossM > loss.min || filter.maxLossM < loss.max)
|
||||
out.push({
|
||||
key: 'loss',
|
||||
label: `↓ ${rangeLabel(filter.minLossM, filter.maxLossM, loss, (v) => `${v}`, 'm')}`,
|
||||
clear: () => {
|
||||
filter.minLossM = loss.min;
|
||||
filter.maxLossM = loss.max;
|
||||
}
|
||||
});
|
||||
for (const d of DIFFICULTIES)
|
||||
if (filter.difficulties.has(d))
|
||||
out.push({ key: `d-${d}`, label: d, clear: () => filter.difficulties.delete(d) });
|
||||
for (const r of filter.regions)
|
||||
out.push({ key: `r-${r}`, label: r, clear: () => filter.regions.delete(r) });
|
||||
for (const value of filter.areas) {
|
||||
const a = areaByValue.get(value);
|
||||
out.push({
|
||||
key: `a-${value}`,
|
||||
label: a?.label ?? value,
|
||||
icon: a?.iconUrl,
|
||||
clear: () => filter.areas.delete(value)
|
||||
});
|
||||
}
|
||||
for (const t of filter.tags)
|
||||
out.push({ key: `t-${t}`, label: `#${t}`, clear: () => filter.tags.delete(t) });
|
||||
return out;
|
||||
});
|
||||
|
||||
const activeCount = $derived(chips.length);
|
||||
|
||||
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 toggleArea(value: string) {
|
||||
if (filter.areas.has(value)) filter.areas.delete(value);
|
||||
else filter.areas.add(value);
|
||||
}
|
||||
|
||||
function toggleTag(t: string) {
|
||||
if (filter.tags.has(t)) filter.tags.delete(t);
|
||||
else filter.tags.add(t);
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filter.minDistanceKm = bounds.distance.min;
|
||||
filter.maxDistanceKm = bounds.distance.max;
|
||||
filter.minDurationMin = bounds.duration.min;
|
||||
filter.maxDurationMin = bounds.duration.max;
|
||||
filter.minGainM = bounds.gain.min;
|
||||
filter.maxGainM = bounds.gain.max;
|
||||
filter.minLossM = bounds.loss.min;
|
||||
filter.maxLossM = bounds.loss.max;
|
||||
filter.difficulties.clear();
|
||||
filter.regions.clear();
|
||||
filter.areas.clear();
|
||||
filter.tags.clear();
|
||||
filter.inSeasonOnly = false;
|
||||
}
|
||||
|
||||
// Light-dismiss: close the panel on outside click or Escape. Only wired
|
||||
// up while open so the listeners aren't carried for the whole session.
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
const onPointer = (e: PointerEvent) => {
|
||||
if (root && !root.contains(e.target as Node)) open = false;
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') open = false;
|
||||
};
|
||||
document.addEventListener('pointerdown', onPointer);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onPointer);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="filter-bar" bind:this={root}>
|
||||
<p class="summary">
|
||||
<span class="count"><strong>{resultCount}</strong> von {totalCount} Touren</span>
|
||||
{#if resultCount > 0}
|
||||
<span class="dot" aria-hidden="true">·</span>
|
||||
<span class="stat">{totalKm.toLocaleString('de-CH')} km</span>
|
||||
<span class="dot" aria-hidden="true">·</span>
|
||||
<span class="stat">{totalGain.toLocaleString('de-CH')} hm</span>
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
{#if activeCount > 0}
|
||||
<div class="active-chips" aria-label="Aktive Filter">
|
||||
{#each chips as chip (chip.key)}
|
||||
<button type="button" class="chip" onclick={chip.clear}>
|
||||
{#if chip.icon}<img class="chip-emblem" src={chip.icon} alt="" aria-hidden="true" />{/if}<span
|
||||
class="chip-label">{chip.label}</span>
|
||||
<X size={13} strokeWidth={2} aria-label="entfernen" />
|
||||
</button>
|
||||
{/each}
|
||||
<button type="button" class="clear-all" onclick={resetFilters}>Alle löschen</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="filter-toggle"
|
||||
class:open
|
||||
aria-expanded={open}
|
||||
aria-controls="filter-panel"
|
||||
onclick={() => (open = !open)}
|
||||
>
|
||||
<SlidersHorizontal size={16} strokeWidth={1.75} aria-hidden="true" />
|
||||
<span>Filter</span>
|
||||
{#if activeCount > 0}<span class="badge">{activeCount}</span>{/if}
|
||||
<ChevronDown class="chev" size={16} strokeWidth={1.75} aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div id="filter-panel" class="panel" transition:slide={{ duration: 200 }}>
|
||||
<div class="season-row">
|
||||
<Toggle bind:checked={filter.inSeasonOnly} label="Nur Touren in der aktuellen Saison" />
|
||||
</div>
|
||||
|
||||
<hr class="divider" />
|
||||
|
||||
<div class="ranges">
|
||||
<RangeSlider
|
||||
label="Distanz"
|
||||
min={bounds.distance.min}
|
||||
max={bounds.distance.max}
|
||||
step={DISTANCE_STEP}
|
||||
bind:low={filter.minDistanceKm}
|
||||
bind:high={filter.maxDistanceKm}
|
||||
format={(v) => `${v} km`}
|
||||
/>
|
||||
<RangeSlider
|
||||
label="Dauer"
|
||||
min={bounds.duration.min}
|
||||
max={bounds.duration.max}
|
||||
step={DURATION_STEP}
|
||||
bind:low={filter.minDurationMin}
|
||||
bind:high={filter.maxDurationMin}
|
||||
format={fmtDuration}
|
||||
/>
|
||||
<RangeSlider
|
||||
label="Aufstieg"
|
||||
min={bounds.gain.min}
|
||||
max={bounds.gain.max}
|
||||
step={ELEVATION_STEP}
|
||||
bind:low={filter.minGainM}
|
||||
bind:high={filter.maxGainM}
|
||||
format={(v) => `${v} m`}
|
||||
/>
|
||||
<RangeSlider
|
||||
label="Abstieg"
|
||||
min={bounds.loss.min}
|
||||
max={bounds.loss.max}
|
||||
step={ELEVATION_STEP}
|
||||
bind:low={filter.minLossM}
|
||||
bind:high={filter.maxLossM}
|
||||
format={(v) => `${v} m`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<hr class="divider" />
|
||||
|
||||
<fieldset>
|
||||
<legend>Schwierigkeit (SAC)</legend>
|
||||
<div class="sac-grid">
|
||||
{#each DIFFICULTIES as d (d)}
|
||||
<button
|
||||
type="button"
|
||||
class="sac-toggle"
|
||||
class:active={filter.difficulties.has(d)}
|
||||
aria-pressed={filter.difficulties.has(d)}
|
||||
aria-label="SAC-Schwierigkeit {d}"
|
||||
onclick={() => toggleDifficulty(d)}
|
||||
>
|
||||
<span class="sac-marker sac-marker-{sacBand(d)}">{d}</span>
|
||||
</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}
|
||||
|
||||
{#if areaList.length > 0}
|
||||
<fieldset>
|
||||
<legend>Kanton / Land</legend>
|
||||
<ChipTypeahead
|
||||
options={areaValues}
|
||||
selected={filter.areas}
|
||||
onToggle={toggleArea}
|
||||
placeholder="Kanton oder Land eingeben oder auswählen…"
|
||||
iconFor={(value) => areaByValue.get(value)?.iconUrl}
|
||||
labelFor={(value) => areaByValue.get(value)?.label ?? value}
|
||||
/>
|
||||
</fieldset>
|
||||
{/if}
|
||||
|
||||
{#if tags.length > 0}
|
||||
<fieldset>
|
||||
<legend>Schlagwörter</legend>
|
||||
<ChipTypeahead
|
||||
options={tags}
|
||||
selected={filter.tags}
|
||||
onToggle={toggleTag}
|
||||
hash
|
||||
placeholder="Schlagwort eingeben oder auswählen…"
|
||||
/>
|
||||
</fieldset>
|
||||
{/if}
|
||||
|
||||
<div class="panel-foot">
|
||||
<button type="button" class="reset" onclick={resetFilters} disabled={activeCount === 0}>
|
||||
Zurücksetzen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.filter-bar {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 0.75rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 0.5rem 0.6rem 0.5rem 1rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
margin: 0;
|
||||
flex: 0 1 auto;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.count strong {
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stat {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.dot {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Active filters surfaced inline so the user always sees what's narrowing
|
||||
* the listing without opening the panel; each chip removes its own facet. */
|
||||
.active-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
appearance: none;
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
padding: 0.18rem 0.5rem 0.18rem 0.65rem;
|
||||
border-radius: var(--radius-pill);
|
||||
cursor: pointer;
|
||||
color: var(--color-text-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, var(--color-surface));
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 32%, var(--color-border));
|
||||
transition: background-color var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.chip:hover {
|
||||
background: color-mix(in srgb, var(--color-primary) 22%, var(--color-surface));
|
||||
}
|
||||
|
||||
.chip :global(svg) {
|
||||
opacity: 0.6;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.chip:hover :global(svg) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.chip-label {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Canton coat-of-arms inside an active-filter chip. */
|
||||
.chip-emblem {
|
||||
width: 12px;
|
||||
height: 15px;
|
||||
object-fit: contain;
|
||||
flex: 0 0 auto;
|
||||
filter: drop-shadow(0 1px 1px rgb(0 0 0 / 0.18));
|
||||
}
|
||||
|
||||
.clear-all {
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-tertiary);
|
||||
cursor: pointer;
|
||||
padding: 0.18rem 0.3rem;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.clear-all:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.filter-toggle {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
appearance: none;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
padding: 0.45rem 0.8rem;
|
||||
border-radius: var(--radius-pill);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.filter-toggle:hover {
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
.filter-toggle.open {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
.filter-toggle :global(.chev) {
|
||||
transition: rotate var(--transition-normal);
|
||||
}
|
||||
|
||||
.filter-toggle.open :global(.chev) {
|
||||
rotate: 180deg;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
padding: 0 0.35rem;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-on-primary);
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Expands in-flow on its own full-width row inside the bar, so opening it
|
||||
* pushes the card grid down (accordion) rather than overlaying it. The
|
||||
* top border separates it from the summary row; both it and the vertical
|
||||
* padding are animated by the `slide` transition. */
|
||||
.panel {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.1rem;
|
||||
margin-top: 0.6rem;
|
||||
padding-top: 1.1rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.ranges {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1.1rem 1.75rem;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
fieldset {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
legend {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
/* Difficulty toggles render the actual SAC trail-sign markers (same shapes
|
||||
* as the hike cards): T1 yellow Wegweiser arrow, T2/T3 white-red-white
|
||||
* Bergweg, T4–T6 white-blue-white Alpinweg. No container chrome — boxing
|
||||
* the irregular arrow looked off. Selection is the sign itself "lighting
|
||||
* up": unselected signs are dimmed + desaturated, the selected ones snap
|
||||
* to full colour, scale up and lift with a shadow. */
|
||||
.sac-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.sac-toggle {
|
||||
appearance: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
background: none;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
opacity: 0.45;
|
||||
filter: grayscale(0.6);
|
||||
transition: scale var(--transition-fast), opacity var(--transition-fast),
|
||||
filter var(--transition-fast);
|
||||
}
|
||||
|
||||
.sac-toggle:hover {
|
||||
opacity: 0.85;
|
||||
filter: grayscale(0.1);
|
||||
scale: 1.08;
|
||||
}
|
||||
|
||||
.sac-toggle.active {
|
||||
opacity: 1;
|
||||
filter: none;
|
||||
scale: 1.08;
|
||||
}
|
||||
|
||||
.sac-toggle:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Lift only the selected signs so they read as raised above the dimmed
|
||||
* ones. (Applied to the marker, not the toggle, so it survives the
|
||||
* toggle's `filter: none`.) */
|
||||
.sac-toggle.active .sac-marker {
|
||||
filter: drop-shadow(0 2px 5px rgb(0 0 0 / 0.35));
|
||||
}
|
||||
|
||||
.sac-marker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 26px;
|
||||
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.sac-marker-yellow {
|
||||
width: 44px;
|
||||
color: #1a1a1a;
|
||||
background: #f5a623;
|
||||
clip-path: polygon(0 0, 75% 0, 100% 50%, 75% 100%, 0 100%);
|
||||
/* Text sits in the rectangular left portion (arrow tip is the right 25%). */
|
||||
justify-content: flex-start;
|
||||
padding-left: 0.55rem;
|
||||
}
|
||||
|
||||
.sac-marker-red,
|
||||
.sac-marker-blue {
|
||||
width: 32px;
|
||||
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%);
|
||||
}
|
||||
|
||||
.panel-foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.reset {
|
||||
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;
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.reset:hover:not(:disabled) {
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
.reset:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.ranges {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* Give the trigger its own line so the summary + chips aren't squeezed. */
|
||||
.summary {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,599 @@
|
||||
<script lang="ts">
|
||||
import type { Attachment } from 'svelte/attachments';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { sacTrailColor } from '$lib/data/sacColors';
|
||||
import { TILE_URL, TILE_ATTRIBUTION } from '$lib/data/mapTiles';
|
||||
import { isSwissRegion } from '$lib/hikes/hikeArea';
|
||||
import type { HikeManifestEntry } 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[];
|
||||
/** Initial map centre `[lat, lng]`. When provided alongside
|
||||
* `initialZoom`, the map opens with `setView(center, zoom)` instead
|
||||
* of `fitBounds(union)` — used by the index page to align Leaflet's
|
||||
* first paint with the SSR-rendered static overview hero. */
|
||||
initialCenter?: [number, number];
|
||||
initialZoom?: number;
|
||||
/** Fires once the schematic tile layer's first batch of tiles has
|
||||
* finished loading — i.e. the map is visually complete. The page
|
||||
* uses this to fade out the SSR-rendered static hero. */
|
||||
onReady?: () => void;
|
||||
}
|
||||
|
||||
const { hikes, initialCenter, initialZoom, onReady }: Props = $props();
|
||||
|
||||
// When every displayed hike is in a swisstopo region (CH/LI), the schematic
|
||||
// can use swisstopo's z19; with a hike abroad the global fallback is shallower
|
||||
// so we cap a touch lower (still generous — the overview is a finder).
|
||||
const allSwiss = $derived(hikes.every((h) => isSwissRegion(h.canton, h.country)));
|
||||
|
||||
type BaseLayer = 'schematic' | 'aerial' | 'dufour';
|
||||
const LAYER_DEFS: Record<BaseLayer, { label: string; icon: typeof Map; maxZoom: number }> = $derived({
|
||||
schematic: { label: 'Karte', icon: Map, maxZoom: allSwiss ? 19 : 18 },
|
||||
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;
|
||||
|
||||
// `tolerance` widens the canvas renderer's hit-test radius around
|
||||
// every polyline (hit = weight/2 + tolerance), so a route can be
|
||||
// hovered/clicked from a comfortable margin instead of demanding a
|
||||
// pixel-perfect click on the 4 px line.
|
||||
const map = L.map(node, {
|
||||
// On-map attribution control removed for a cleaner frame.
|
||||
// NOTE: swisstopo's tile licence requires their credit to appear;
|
||||
// the /hikes page currently shows no other swisstopo attribution.
|
||||
attributionControl: false,
|
||||
zoomControl: true,
|
||||
preferCanvas: true,
|
||||
renderer: L.canvas({ tolerance: 12 })
|
||||
});
|
||||
// Sensible default centre (mid-Switzerland) while the polyline
|
||||
// layer is built up; `fitBounds` below overrides it once the
|
||||
// union bounds are known. If the caller passed a pre-rendered
|
||||
// hero pose, use that instead so Leaflet lands aligned with the
|
||||
// static image on first paint.
|
||||
if (initialCenter && typeof initialZoom === 'number') {
|
||||
map.setView(initialCenter, initialZoom, { animate: false });
|
||||
} else {
|
||||
map.setView([46.8, 8.3], 8);
|
||||
}
|
||||
|
||||
const tileLayers: Record<BaseLayer, ReturnType<typeof L.tileLayer>> = {
|
||||
schematic: L.tileLayer(TILE_URL.karte, {
|
||||
maxZoom: LAYER_DEFS.schematic.maxZoom,
|
||||
minZoom: 7,
|
||||
attribution: TILE_ATTRIBUTION,
|
||||
updateWhenZooming: false
|
||||
}),
|
||||
aerial: L.tileLayer(TILE_URL.luftbild, {
|
||||
maxZoom: LAYER_DEFS.aerial.maxZoom,
|
||||
minZoom: 7,
|
||||
attribution: TILE_ATTRIBUTION,
|
||||
updateWhenZooming: false
|
||||
}),
|
||||
dufour: L.tileLayer(TILE_URL.dufour, {
|
||||
maxZoom: LAYER_DEFS.dufour.maxZoom,
|
||||
minZoom: 7,
|
||||
attribution: TILE_ATTRIBUTION,
|
||||
updateWhenZooming: false
|
||||
})
|
||||
};
|
||||
tileLayers.schematic.addTo(map);
|
||||
let currentBase: BaseLayer = 'schematic';
|
||||
|
||||
// Forward-declared so the tile-load handover handler below can
|
||||
// close over it; populated once the polyline loop has built the
|
||||
// union bounds.
|
||||
let initialBounds: ReturnType<typeof L.latLngBounds> | null = null;
|
||||
|
||||
// First-paint handover: fire `onReady` once the schematic tile
|
||||
// layer's initial batch loads so the static hero can fade out.
|
||||
// The map already opened at the static pose via setView (see
|
||||
// the initialCenter branch below), so no extra animation is
|
||||
// needed — and `flyToBounds(union)` here used to cause a
|
||||
// visible wobble on hikes whose union bbox sits at an integer-
|
||||
// zoom boundary, where the static's fit and Leaflet's runtime
|
||||
// fit disagree by one zoom step. Mirrors the same fix in
|
||||
// `HikeMap.svelte`.
|
||||
tileLayers.schematic.once('load', () => {
|
||||
onReady?.();
|
||||
});
|
||||
|
||||
// One polyline per hike, sourced from the manifest's already-
|
||||
// simplified previewPolyline (≤150 points each). The layer is
|
||||
// re-populated on every `hikes` prop change (see the $effect
|
||||
// below) so toggling filters updates the visible routes — and
|
||||
// re-fits the camera to the new union bounds.
|
||||
const layer = L.layerGroup().addTo(map);
|
||||
|
||||
function renderPolylines(): boolean {
|
||||
layer.clearLayers();
|
||||
const b = 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 = sacTrailColor(hike.difficulty);
|
||||
// Multi-day hikes with a big inter-stage gap ship `previewBreaks`
|
||||
// (indices where a new run starts); split there so Leaflet draws
|
||||
// disconnected segments instead of a line across the transfer.
|
||||
const breaks = hike.previewBreaks;
|
||||
let coords: [number, number][] | [number, number][][] = latLngs;
|
||||
if (breaks && breaks.length > 0) {
|
||||
const segs: [number, number][][] = [];
|
||||
let start = 0;
|
||||
for (const brk of breaks) {
|
||||
if (brk > start) segs.push(latLngs.slice(start, brk));
|
||||
start = brk;
|
||||
}
|
||||
segs.push(latLngs.slice(start));
|
||||
coords = segs.filter((s) => s.length >= 2);
|
||||
}
|
||||
const poly = L.polyline(coords, {
|
||||
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) {
|
||||
b.extend([lat, lng]);
|
||||
}
|
||||
}
|
||||
if (b.isValid()) {
|
||||
initialBounds = b;
|
||||
recenterMap = () => {
|
||||
if (!initialBounds) return;
|
||||
map.flyToBounds(initialBounds, {
|
||||
padding: [32, 32],
|
||||
maxZoom: 13,
|
||||
duration: 0.6,
|
||||
easeLinearity: 0.25
|
||||
});
|
||||
};
|
||||
return true;
|
||||
}
|
||||
initialBounds = null;
|
||||
recenterMap = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Initial paint — no animated fit when the caller handed us a
|
||||
// pre-rendered hero pose (the tile-load handover handles the
|
||||
// fly-to), otherwise fit straight to the union bounds.
|
||||
if (renderPolylines() && (!initialCenter || typeof initialZoom !== 'number') && initialBounds) {
|
||||
map.fitBounds(initialBounds, { padding: [32, 32], maxZoom: 13 });
|
||||
}
|
||||
|
||||
// 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(() => {
|
||||
// Re-render polylines whenever the `hikes` prop changes
|
||||
// (filter bar toggles, tag deep-link). The first $effect
|
||||
// run fires immediately and would re-do the initial paint
|
||||
// for no UX gain — skip it via a tick counter.
|
||||
let rerunTick = 0;
|
||||
$effect(() => {
|
||||
void hikes;
|
||||
if (rerunTick++ === 0) return;
|
||||
if (renderPolylines() && initialBounds) {
|
||||
// Smooth re-fit so the user sees the camera glide
|
||||
// toward whichever subset is now on display.
|
||||
map.flyToBounds(initialBounds, {
|
||||
padding: [32, 32],
|
||||
maxZoom: 13,
|
||||
duration: 0.6,
|
||||
easeLinearity: 0.25
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$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://maps.bocken.org" 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,270 @@
|
||||
<script lang="ts">
|
||||
// Dual-thumb range slider: one track, two handles (lower + upper bound).
|
||||
// Custom pointer/keyboard implementation rather than two overlaid
|
||||
// <input type=range> elements — the latter lock up when both thumbs
|
||||
// coincide at an edge. Here a drag that crosses the other thumb hands off
|
||||
// to it, so the range is always adjustable.
|
||||
interface Props {
|
||||
label: string;
|
||||
/** Track extent (data floor / ceiling). */
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
/** Current lower bound. Bindable. */
|
||||
low: number;
|
||||
/** Current upper bound. Bindable. */
|
||||
high: number;
|
||||
/** Renders a value for the readout + aria-valuetext. */
|
||||
format?: (v: number) => string;
|
||||
}
|
||||
|
||||
let {
|
||||
label,
|
||||
min,
|
||||
max,
|
||||
step = 1,
|
||||
low = $bindable(),
|
||||
high = $bindable(),
|
||||
format = (v) => String(v)
|
||||
}: Props = $props();
|
||||
|
||||
let trackEl = $state<HTMLElement>();
|
||||
let lowThumb = $state<HTMLElement>();
|
||||
let highThumb = $state<HTMLElement>();
|
||||
let dragging = $state<null | 'low' | 'high'>(null);
|
||||
|
||||
const span = $derived(Math.max(1, max - min));
|
||||
// Clamp for display so an out-of-range initial value (e.g. ±Infinity
|
||||
// before the data defaults land) still paints a sane thumb position.
|
||||
const lowPct = $derived(((Math.min(Math.max(low, min), max) - min) / span) * 100);
|
||||
const highPct = $derived(((Math.min(Math.max(high, min), max) - min) / span) * 100);
|
||||
|
||||
function snap(v: number) {
|
||||
return Math.round(v / step) * step;
|
||||
}
|
||||
|
||||
function setLow(v: number) {
|
||||
low = Math.min(Math.max(snap(v), min), high);
|
||||
}
|
||||
|
||||
function setHigh(v: number) {
|
||||
high = Math.max(Math.min(snap(v), max), low);
|
||||
}
|
||||
|
||||
function valueFromClientX(clientX: number) {
|
||||
if (!trackEl) return min;
|
||||
const r = trackEl.getBoundingClientRect();
|
||||
const ratio = r.width > 0 ? (clientX - r.left) / r.width : 0;
|
||||
return min + Math.min(Math.max(ratio, 0), 1) * (max - min);
|
||||
}
|
||||
|
||||
// Move the active thumb; if it crosses the other one, hand the drag over so
|
||||
// dragging stays continuous instead of stalling at the collision point.
|
||||
function update(which: 'low' | 'high', raw: number) {
|
||||
const v = Math.min(Math.max(snap(raw), min), max);
|
||||
if (which === 'low') {
|
||||
if (v > high) {
|
||||
dragging = 'high';
|
||||
highThumb?.focus();
|
||||
setHigh(v);
|
||||
} else setLow(v);
|
||||
} else {
|
||||
if (v < low) {
|
||||
dragging = 'low';
|
||||
lowThumb?.focus();
|
||||
setLow(v);
|
||||
} else setHigh(v);
|
||||
}
|
||||
}
|
||||
|
||||
function onTrackPointerDown(e: PointerEvent) {
|
||||
e.preventDefault();
|
||||
const v = valueFromClientX(e.clientX);
|
||||
const which: 'low' | 'high' = Math.abs(v - low) <= Math.abs(v - high) ? 'low' : 'high';
|
||||
dragging = which;
|
||||
(which === 'low' ? lowThumb : highThumb)?.focus();
|
||||
trackEl?.setPointerCapture(e.pointerId);
|
||||
update(which, v);
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!dragging) return;
|
||||
update(dragging, valueFromClientX(e.clientX));
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
dragging = null;
|
||||
}
|
||||
|
||||
function onThumbKey(e: KeyboardEvent, which: 'low' | 'high') {
|
||||
const big = step * 10;
|
||||
let delta = 0;
|
||||
switch (e.key) {
|
||||
case 'ArrowRight':
|
||||
case 'ArrowUp':
|
||||
delta = step;
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowDown':
|
||||
delta = -step;
|
||||
break;
|
||||
case 'PageUp':
|
||||
delta = big;
|
||||
break;
|
||||
case 'PageDown':
|
||||
delta = -big;
|
||||
break;
|
||||
case 'Home':
|
||||
e.preventDefault();
|
||||
if (which === 'low') setLow(min);
|
||||
else setHigh(low);
|
||||
return;
|
||||
case 'End':
|
||||
e.preventDefault();
|
||||
if (which === 'low') setLow(high);
|
||||
else setHigh(max);
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
if (which === 'low') setLow(low + delta);
|
||||
else setHigh(high + delta);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rs">
|
||||
<div class="rs-head">
|
||||
<span class="rs-label">{label}</span>
|
||||
<span class="rs-value">{format(low)} – {format(high)}</span>
|
||||
</div>
|
||||
<div
|
||||
class="rs-track"
|
||||
role="group"
|
||||
aria-label={label}
|
||||
bind:this={trackEl}
|
||||
onpointerdown={onTrackPointerDown}
|
||||
onpointermove={onPointerMove}
|
||||
onpointerup={onPointerUp}
|
||||
onpointercancel={onPointerUp}
|
||||
>
|
||||
<div class="rs-rail"></div>
|
||||
<div class="rs-fill" style="left: {lowPct}%; right: {100 - highPct}%;"></div>
|
||||
<button
|
||||
type="button"
|
||||
class="rs-thumb"
|
||||
class:active={dragging === 'low'}
|
||||
bind:this={lowThumb}
|
||||
style="left: {lowPct}%"
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
aria-label="{label} Minimum"
|
||||
aria-valuemin={min}
|
||||
aria-valuemax={high}
|
||||
aria-valuenow={low}
|
||||
aria-valuetext={format(low)}
|
||||
onkeydown={(e) => onThumbKey(e, 'low')}
|
||||
></button>
|
||||
<button
|
||||
type="button"
|
||||
class="rs-thumb"
|
||||
class:active={dragging === 'high'}
|
||||
bind:this={highThumb}
|
||||
style="left: {highPct}%"
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
aria-label="{label} Maximum"
|
||||
aria-valuemin={low}
|
||||
aria-valuemax={max}
|
||||
aria-valuenow={high}
|
||||
aria-valuetext={format(high)}
|
||||
onkeydown={(e) => onThumbKey(e, 'high')}
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.rs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.rs-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.rs-label {
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.rs-value {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rs-track {
|
||||
position: relative;
|
||||
height: 1.25rem;
|
||||
touch-action: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rs-rail,
|
||||
.rs-fill {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
height: 0.3rem;
|
||||
transform: translateY(-50%);
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.rs-rail {
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
.rs-fill {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
.rs-thumb {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
background: var(--color-surface);
|
||||
border: 2px solid var(--color-primary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
cursor: grab;
|
||||
appearance: none;
|
||||
transition: scale var(--transition-fast);
|
||||
}
|
||||
|
||||
.rs-thumb:hover {
|
||||
scale: 1.1;
|
||||
}
|
||||
|
||||
.rs-thumb:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.rs-thumb.active {
|
||||
cursor: grabbing;
|
||||
scale: 1.15;
|
||||
}
|
||||
</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,49 @@
|
||||
/**
|
||||
* 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, NamedHikeImage } 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;
|
||||
/** Images addressable by source filename for `<HikeImage src="…">`,
|
||||
* keyed by source basename. */
|
||||
readonly imagesByName: Record<string, NamedHikeImage>;
|
||||
/** Whether the current viewer may see private images. Path-mode
|
||||
* `<HikeImage src>` hides private images when this is false. */
|
||||
readonly showPrivate: boolean;
|
||||
}
|
||||
|
||||
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,628 @@
|
||||
<script lang="ts">
|
||||
import type { Attachment } from 'svelte/attachments';
|
||||
import {
|
||||
builder,
|
||||
mapView,
|
||||
nextWaypointId,
|
||||
scheduleSave
|
||||
} from './builderStore.svelte';
|
||||
import { SAC_TRAIL_COLOR } from '$lib/data/sacColors';
|
||||
import { TILE_URL, TILE_ATTRIBUTION } from '$lib/data/mapTiles';
|
||||
import MapIcon from '@lucide/svelte/icons/map';
|
||||
import Satellite from '@lucide/svelte/icons/satellite';
|
||||
import Layers from '@lucide/svelte/icons/layers';
|
||||
// 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
|
||||
);
|
||||
|
||||
// Schematic ↔ satellite base layer (satellite helps placing waypoints on
|
||||
// trails/landmarks, esp. off the marked path). Same bottom-right layer
|
||||
// popover as the detail / overview maps.
|
||||
type BaseLayer = 'schematic' | 'aerial';
|
||||
const LAYER_DEFS: Record<BaseLayer, { label: string; icon: typeof MapIcon }> = {
|
||||
schematic: { label: 'Karte', icon: MapIcon },
|
||||
aerial: { label: 'Luftbild', icon: Satellite }
|
||||
};
|
||||
let baseLayer = $state<BaseLayer>('schematic');
|
||||
let layerMenuOpen = $state(false);
|
||||
|
||||
// Close the layer popover on outside click (the opening click stops
|
||||
// propagation so this never sees 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);
|
||||
});
|
||||
|
||||
|
||||
// Default view: Switzerland-wide.
|
||||
const DEFAULT_CENTER: [number, number] = [46.8, 8.3];
|
||||
const DEFAULT_ZOOM = 8;
|
||||
|
||||
const TRACK_COLOR = SAC_TRAIL_COLOR.T2;
|
||||
const ACCENT_COLOR = '#2965c8'; // SAC T4 blue — used for the focused-marker accent ring
|
||||
|
||||
function escapeAttr(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
||||
}
|
||||
|
||||
// Pin geometry:
|
||||
// - Solo pin: 28 wide × 36 tall, head r=10 at (14,14), tip at (14,36).
|
||||
// - Image pin: 44 wide × 52 tall, head r=15 (clip) inside r=18 (frame)
|
||||
// at (22,22), tip at (22,52).
|
||||
// Both anchor at the tip so `iconAnchor = [width/2, height]`.
|
||||
function makePinIcon(num: number, opts: { active: boolean }) {
|
||||
const ring = opts.active ? ACCENT_COLOR : 'white';
|
||||
const ringWidth = opts.active ? 3 : 2;
|
||||
const html = `
|
||||
<svg viewBox="0 0 28 36" width="28" height="36" class="rb-pin solo${opts.active ? ' is-active' : ''}" aria-hidden="true">
|
||||
<path d="M14 36 L5.1 18.5 A10 10 0 1 1 22.9 18.5 Z"
|
||||
fill="${TRACK_COLOR}" stroke="${ring}" stroke-width="${ringWidth}" stroke-linejoin="round" />
|
||||
<text x="14" y="17.6" text-anchor="middle" font-size="11" font-weight="700"
|
||||
fill="white" font-family="ui-sans-serif,system-ui,Helvetica,Arial,sans-serif">${num}</text>
|
||||
</svg>`;
|
||||
return { html, size: [28, 36] as [number, number], anchor: [14, 36] as [number, number] };
|
||||
}
|
||||
|
||||
function makeImagePinIcon(num: number, thumb: string, opts: { active: boolean }) {
|
||||
const safeThumb = escapeAttr(thumb);
|
||||
const ring = opts.active ? ACCENT_COLOR : TRACK_COLOR;
|
||||
const ringWidth = opts.active ? 3 : 2.5;
|
||||
const clipId = `rb-pin-head-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const html = `
|
||||
<svg viewBox="0 0 44 52" width="44" height="52" class="rb-pin image${opts.active ? ' is-active' : ''}" aria-hidden="true">
|
||||
<defs>
|
||||
<clipPath id="${clipId}"><circle cx="22" cy="22" r="15" /></clipPath>
|
||||
</defs>
|
||||
<path d="M22 52 L7.6 32.8 A18 18 0 1 1 36.4 32.8 Z"
|
||||
fill="white" stroke="${ring}" stroke-width="${ringWidth}" stroke-linejoin="round" />
|
||||
<image href="${safeThumb}" x="7" y="7" width="30" height="30"
|
||||
clip-path="url(#${clipId})" preserveAspectRatio="xMidYMid slice" />
|
||||
<g transform="translate(34 9)">
|
||||
<circle r="7.5" fill="${ring}" stroke="white" stroke-width="1.5" />
|
||||
<text y="3" text-anchor="middle" font-size="9" font-weight="700" fill="white"
|
||||
font-family="ui-sans-serif,system-ui,Helvetica,Arial,sans-serif">${num}</text>
|
||||
</g>
|
||||
</svg>`;
|
||||
return { html, size: [44, 52] as [number, number], anchor: [22, 52] as [number, number] };
|
||||
}
|
||||
|
||||
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, {
|
||||
// On-map attribution removed for a cleaner frame; the required
|
||||
// swisstopo credit is shown in the page footer instead.
|
||||
attributionControl: false,
|
||||
zoomControl: true,
|
||||
preferCanvas: false
|
||||
}).setView(DEFAULT_CENTER, DEFAULT_ZOOM);
|
||||
|
||||
const tileLayers = {
|
||||
schematic: L.tileLayer(TILE_URL.karte, {
|
||||
maxZoom: 19,
|
||||
minZoom: 7,
|
||||
attribution: TILE_ATTRIBUTION,
|
||||
updateWhenZooming: false
|
||||
}),
|
||||
aerial: L.tileLayer(TILE_URL.luftbild, {
|
||||
maxZoom: 19,
|
||||
minZoom: 7,
|
||||
attribution: TILE_ATTRIBUTION,
|
||||
updateWhenZooming: false
|
||||
})
|
||||
};
|
||||
tileLayers.schematic.addTo(map);
|
||||
let currentBase: 'schematic' | 'aerial' = 'schematic';
|
||||
|
||||
const markerLayer = L.layerGroup().addTo(map);
|
||||
const lineLayer = L.layerGroup().addTo(map);
|
||||
|
||||
// Route polylines render on a canvas with a hit-test `tolerance`, so a
|
||||
// click *near* the (thin) line still inserts a waypoint into the route
|
||||
// — same trick as the /hikes overview map's broadened hover.
|
||||
const routeRenderer = L.canvas({ tolerance: 12 });
|
||||
|
||||
// Map of waypointId → marker, kept in sync by render(). Used by the
|
||||
// focus effect so it can pan/zoom + style the marker for `mapView.focusId`
|
||||
// without forcing a full re-render of every marker.
|
||||
const markerByWp = new Map<string, ReturnType<typeof L.marker>>();
|
||||
|
||||
function buildIcon(num: number, wp: { thumbnail?: string }, active: boolean) {
|
||||
const spec = wp.thumbnail
|
||||
? makeImagePinIcon(num, wp.thumbnail, { active })
|
||||
: makePinIcon(num, { active });
|
||||
return L.divIcon({
|
||||
className: 'rb-waypoint',
|
||||
html: spec.html,
|
||||
iconSize: spec.size,
|
||||
iconAnchor: spec.anchor
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
markerByWp.clear();
|
||||
|
||||
// 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);
|
||||
});
|
||||
const focusId = mapView.focusId;
|
||||
placedIndices.forEach((idx, displayPos) => {
|
||||
const w = builder.waypoints[idx];
|
||||
const seqNum = displayPos + 1;
|
||||
const marker = L.marker([w.lat, w.lng], {
|
||||
icon: buildIcon(seqNum, w, w.id === focusId),
|
||||
draggable: true,
|
||||
// Lift the focused marker above its neighbours so its accent
|
||||
// ring isn't covered by an adjacent unfocused pin.
|
||||
zIndexOffset: w.id === focusId ? 1000 : 0
|
||||
}).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();
|
||||
});
|
||||
marker.on('click', () => {
|
||||
mapView.focusId = w.id;
|
||||
mapView.focusTick++;
|
||||
});
|
||||
markerByWp.set(w.id, marker);
|
||||
});
|
||||
|
||||
// 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.
|
||||
// SAC white-red-white red — matches /hikes overview + detail-page
|
||||
// trail colour so the live preview reads as the final published track.
|
||||
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: TRACK_COLOR,
|
||||
weight: 4,
|
||||
opacity: 0.9,
|
||||
renderer: routeRenderer
|
||||
}).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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function fitToTrack() {
|
||||
const points: [number, number][] = [];
|
||||
for (const w of builder.waypoints) {
|
||||
if (w.unplaced) continue;
|
||||
points.push([w.lat, w.lng]);
|
||||
}
|
||||
for (const seg of builder.routedSegments) {
|
||||
for (const p of seg) points.push([p[1], p[0]]);
|
||||
}
|
||||
if (points.length === 0) return;
|
||||
if (points.length === 1) {
|
||||
map.setView(points[0], 13);
|
||||
return;
|
||||
}
|
||||
map.fitBounds(L.latLngBounds(points), { padding: [40, 40] });
|
||||
}
|
||||
|
||||
function focusOnWaypoint(id: string | null) {
|
||||
if (!id) return;
|
||||
const wp = builder.waypoints.find((w) => w.id === id);
|
||||
if (!wp || wp.unplaced) return;
|
||||
// Zoom in but don't over-zoom — 16 reads as "this trail junction"
|
||||
// without losing surrounding context. flyTo gives smooth motion.
|
||||
const targetZoom = Math.max(map.getZoom(), 16);
|
||||
map.flyTo([wp.lat, wp.lng], targetZoom, { duration: 0.6 });
|
||||
}
|
||||
|
||||
// React to store changes.
|
||||
const stopRoot = $effect.root(() => {
|
||||
// Base-layer switch (schematic ↔ satellite).
|
||||
$effect(() => {
|
||||
if (baseLayer === currentBase) return;
|
||||
tileLayers[currentBase].remove();
|
||||
tileLayers[baseLayer].addTo(map);
|
||||
currentBase = baseLayer;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
// Touch each reactive field so we re-render on any mutation,
|
||||
// including focus changes (so the active marker re-styles).
|
||||
builder.waypoints.length;
|
||||
for (const w of builder.waypoints) {
|
||||
w.lat; w.lng; w.thumbnail;
|
||||
}
|
||||
builder.routedSegments.length;
|
||||
mapView.focusId;
|
||||
render();
|
||||
});
|
||||
|
||||
// External fit-bounds requests (image drops, GPX imports).
|
||||
// The map's own init-time auto-fit covers first-load; this
|
||||
// effect handles every subsequent batch insertion.
|
||||
let lastFitTick = mapView.fitTick;
|
||||
$effect(() => {
|
||||
const tick = mapView.fitTick;
|
||||
if (tick === lastFitTick) return;
|
||||
lastFitTick = tick;
|
||||
fitToTrack();
|
||||
});
|
||||
|
||||
// Focus requests (table row "fokussieren", prev/next nav bar).
|
||||
// Tick is bumped on every request even if the id stays the same
|
||||
// so repeated clicks re-center even if the user panned away.
|
||||
let lastFocusTick = mapView.focusTick;
|
||||
$effect(() => {
|
||||
const tick = mapView.focusTick;
|
||||
if (tick === lastFocusTick) return;
|
||||
lastFocusTick = tick;
|
||||
focusOnWaypoint(mapView.focusId);
|
||||
});
|
||||
});
|
||||
|
||||
// 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>
|
||||
|
||||
<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>
|
||||
</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: 640px;
|
||||
border-radius: var(--radius-card);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-md);
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.edit-map {
|
||||
height: 520px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Default cursor is a pointing hand — a click adds a waypoint (on blank map
|
||||
* or, with the canvas tolerance, near a route). Leaflet turns the
|
||||
* `.edit-map` div itself into the container, so the grab cursor lives on
|
||||
* this element (not a descendant) — target it directly. */
|
||||
.edit-map:global(.leaflet-container) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Waypoint pins are the only draggable thing — show the drag hand on them. */
|
||||
.edit-map :global(.rb-waypoint) {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.edit-map :global(.rb-waypoint:active) {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Placement mode (dropping an unplaced image) keeps the crosshair. */
|
||||
.edit-map-wrap.placement-mode :global(.leaflet-container) {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
/* Bottom-right round controls + layer popover — same language as the
|
||||
* detail / overview maps. */
|
||||
.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);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Leaflet wraps each marker in `.leaflet-marker-icon` with its own
|
||||
* absolute positioning. We just neutralise its default frame/background
|
||||
* so the SVG pin shows through cleanly. */
|
||||
:global(.rb-waypoint) {
|
||||
background: transparent !important;
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
:global(.rb-pin) {
|
||||
display: block;
|
||||
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.35));
|
||||
transition: filter 200ms ease, transform 200ms ease;
|
||||
transform-origin: 50% 100%;
|
||||
}
|
||||
|
||||
:global(.rb-waypoint:hover .rb-pin) {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
:global(.rb-pin.is-active) {
|
||||
filter: drop-shadow(0 0 6px color-mix(in oklab, #2965c8 70%, transparent))
|
||||
drop-shadow(0 2px 3px rgba(0, 0, 0, 0.4));
|
||||
animation: rb-pin-bounce 0.55s ease-out;
|
||||
}
|
||||
|
||||
@keyframes rb-pin-bounce {
|
||||
0% { transform: scale(0.85) translateY(-4px); }
|
||||
60% { transform: scale(1.12); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:global(.rb-pin.is-active) {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,538 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
builder,
|
||||
insertWaypointChronologically,
|
||||
nextWaypointId,
|
||||
requestFitBounds,
|
||||
scheduleSave,
|
||||
type Waypoint
|
||||
} from './builderStore.svelte';
|
||||
// `untrack` keeps the in-loop `builder.waypoints.find(...)` from
|
||||
// registering as a dep on a non-reactive call site, avoiding effect
|
||||
// loops when we patch the matched waypoint's `thumbnail`.
|
||||
import { untrack } from 'svelte';
|
||||
import { generateImageHashClient } from '$lib/imageHashClient';
|
||||
import { readThumbnail } from './imageThumbnail';
|
||||
import { setFullImage } from './fullImageCache.svelte';
|
||||
import ImagePlus from '@lucide/svelte/icons/image-plus';
|
||||
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
|
||||
import AlertTriangle from '@lucide/svelte/icons/triangle-alert';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import '$lib/css/action_button.css';
|
||||
|
||||
type Status = 'pending' | 'placed' | 'unplaced' | 'matched' | 'error';
|
||||
|
||||
type Entry = {
|
||||
id: string;
|
||||
name: string;
|
||||
status: Status;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
/** Called when the user picks/drops a `.gpx` file via the FAB.
|
||||
* Owning page handles the import + draft-replacement confirm.
|
||||
* When absent, GPX files are silently ignored. */
|
||||
onGpxImport?: (file: File) => void;
|
||||
}
|
||||
|
||||
const { onGpxImport }: Props = $props();
|
||||
|
||||
function isGpxFile(file: File): boolean {
|
||||
if (file.name.toLowerCase().endsWith('.gpx')) return true;
|
||||
return (
|
||||
file.type === 'application/gpx+xml' ||
|
||||
file.type === 'application/xml' ||
|
||||
file.type === 'text/xml'
|
||||
);
|
||||
}
|
||||
|
||||
let entries = $state<Entry[]>([]);
|
||||
let isDragging = $state(false);
|
||||
let showFailDetails = $state(false);
|
||||
|
||||
const orphanImageCount = $derived(
|
||||
builder.waypoints.filter((w) => w.imageHash && !w.thumbnail).length
|
||||
);
|
||||
const pendingCount = $derived(entries.filter((e) => e.status === 'pending').length);
|
||||
const failedEntries = $derived(
|
||||
entries.filter((e) => e.status === 'error' || e.status === 'unplaced')
|
||||
);
|
||||
const failCount = $derived(failedEntries.length);
|
||||
|
||||
// Numeric badge on the FAB. Pending wins (in-flight work), then
|
||||
// failures (need attention), then orphan hash-only waypoints from a
|
||||
// GPX import (waiting for their source images).
|
||||
const badge = $derived(
|
||||
pendingCount > 0
|
||||
? pendingCount
|
||||
: failCount > 0
|
||||
? failCount
|
||||
: orphanImageCount > 0
|
||||
? orphanImageCount
|
||||
: 0
|
||||
);
|
||||
const badgeTone = $derived<'pending' | 'fail' | 'info'>(
|
||||
pendingCount > 0 ? 'pending' : failCount > 0 ? 'fail' : 'info'
|
||||
);
|
||||
|
||||
type Prepared =
|
||||
| { ok: true; kind: 'new'; wp: Waypoint; hasGps: boolean; id: string; file: File }
|
||||
| { ok: true; kind: 'matched'; id: string; file: File }
|
||||
| { ok: false };
|
||||
|
||||
// Auto-clear successful entries after 4s so the badge counter doesn't
|
||||
// pile up. Failures stay until the user dismisses them.
|
||||
function scheduleAutoDismiss(id: string, ms = 4000) {
|
||||
setTimeout(() => {
|
||||
const e = entries.find((x) => x.id === id);
|
||||
if (!e) return;
|
||||
if (e.status === 'placed' || e.status === 'matched') dismiss(id);
|
||||
}, ms);
|
||||
}
|
||||
|
||||
async function handleFiles(files: File[]) {
|
||||
const exifr = (await import('exifr')).default;
|
||||
|
||||
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);
|
||||
|
||||
// Match path: re-attach to an existing waypoint with the
|
||||
// same content hash (covers the GPX-roundtrip flow).
|
||||
const existing = untrack(() =>
|
||||
builder.waypoints.find((w) => w.imageHash === imageHash)
|
||||
);
|
||||
if (existing) {
|
||||
if (thumbnail && !existing.thumbnail) existing.thumbnail = thumbnail;
|
||||
if (!existing.imageVisibility) existing.imageVisibility = 'public';
|
||||
scheduleSave();
|
||||
entries[entryIdx].status = 'matched';
|
||||
entries[entryIdx].message = existing.unplaced
|
||||
? 'noch nicht auf der Karte platziert'
|
||||
: undefined;
|
||||
scheduleAutoDismiss(entries[entryIdx].id);
|
||||
return { ok: true, kind: 'matched', id: existing.id, file };
|
||||
}
|
||||
|
||||
const timestamp =
|
||||
exif?.DateTimeOriginal instanceof Date ? exif.DateTimeOriginal.getTime() : null;
|
||||
const hasGps =
|
||||
exif &&
|
||||
typeof exif.latitude === 'number' &&
|
||||
typeof exif.longitude === 'number';
|
||||
|
||||
// EXIF GPSAltitude is intentionally ignored (too noisy);
|
||||
// terrain-model altitude from Swisstopo is backfilled later.
|
||||
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';
|
||||
if (hasGps) scheduleAutoDismiss(entries[entryIdx].id);
|
||||
return { ok: true, kind: 'new', wp, hasGps, id, file };
|
||||
} catch (err) {
|
||||
entries[entryIdx].status = 'error';
|
||||
entries[entryIdx].message = (err as Error).message;
|
||||
return { ok: false };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
let placedAny = false;
|
||||
for (const p of prepared) {
|
||||
if (!p.ok) continue;
|
||||
if (p.kind === 'new') {
|
||||
insertWaypointChronologically(p.wp);
|
||||
if (p.hasGps) placedAny = true;
|
||||
}
|
||||
setFullImage(p.id, p.file);
|
||||
}
|
||||
if (placedAny) requestFitBounds();
|
||||
}
|
||||
|
||||
function routeFiles(files: File[]) {
|
||||
const gpx = files.find(isGpxFile);
|
||||
if (gpx && onGpxImport) {
|
||||
// GPX import REPLACES the draft, so we hand off the first one
|
||||
// and ignore everything else in the batch — combining a GPX
|
||||
// import with an image batch would race the snap-to-route
|
||||
// reactor against a draft reset.
|
||||
onGpxImport(gpx);
|
||||
return;
|
||||
}
|
||||
const images = files.filter((f) => f.type.startsWith('image/'));
|
||||
if (images.length > 0) handleFiles(images);
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
isDragging = false;
|
||||
const files = [...(e.dataTransfer?.files ?? [])];
|
||||
if (files.length > 0) routeFiles(files);
|
||||
}
|
||||
|
||||
function onFileInput(e: Event) {
|
||||
const input = e.currentTarget as HTMLInputElement;
|
||||
const files = [...(input.files ?? [])];
|
||||
if (files.length > 0) routeFiles(files);
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
function dismiss(entryId: string) {
|
||||
const idx = entries.findIndex((e) => e.id === entryId);
|
||||
if (idx >= 0) entries.splice(idx, 1);
|
||||
}
|
||||
|
||||
function clearFailed() {
|
||||
entries = entries.filter((e) => e.status !== 'error' && e.status !== 'unplaced');
|
||||
showFailDetails = false;
|
||||
}
|
||||
|
||||
let fileInput: HTMLInputElement | undefined = $state();
|
||||
function openPicker() {
|
||||
fileInput?.click();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="bulk-fab-wrap"
|
||||
class:dragging={isDragging}
|
||||
role="region"
|
||||
aria-label="Bilder-Upload"
|
||||
ondragenter={(e) => {
|
||||
const types = e.dataTransfer?.types;
|
||||
if (types && Array.from(types).includes('Files')) {
|
||||
e.preventDefault();
|
||||
isDragging = true;
|
||||
}
|
||||
}}
|
||||
ondragover={(e) => {
|
||||
const types = e.dataTransfer?.types;
|
||||
if (types && Array.from(types).includes('Files')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
ondragleave={(e) => {
|
||||
if (e.currentTarget === e.target) isDragging = false;
|
||||
}}
|
||||
ondrop={onDrop}
|
||||
>
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
accept="image/*,.gpx,application/gpx+xml,application/xml,text/xml"
|
||||
multiple
|
||||
onchange={onFileInput}
|
||||
hidden
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="bulk-fab action_button"
|
||||
aria-label="Bilder oder GPX hinzufügen"
|
||||
title="Bilder oder GPX hinzufügen"
|
||||
onclick={openPicker}
|
||||
>
|
||||
{#if pendingCount > 0}
|
||||
<LoaderCircle size={30} strokeWidth={2.2} color="white" class="bulk-fab-icon spin" />
|
||||
{:else}
|
||||
<ImagePlus size={30} strokeWidth={2.2} color="white" class="bulk-fab-icon" />
|
||||
{/if}
|
||||
</button>
|
||||
{#if failCount > 0}
|
||||
<button
|
||||
type="button"
|
||||
class="bulk-fab-badge tone-fail"
|
||||
onclick={() => (showFailDetails = !showFailDetails)}
|
||||
aria-label="{failCount} {failCount === 1 ? 'Hinweis' : 'Hinweise'} anzeigen"
|
||||
aria-expanded={showFailDetails}
|
||||
>
|
||||
{badge}
|
||||
</button>
|
||||
{:else if badge > 0}
|
||||
<span class="bulk-fab-badge tone-{badgeTone}" aria-label="{badge} aktiv">
|
||||
{badge}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showFailDetails && failedEntries.length > 0}
|
||||
<aside class="bulk-fail-popover" aria-label="Bild-Hinweise">
|
||||
<header>
|
||||
<strong>Bild-Hinweise</strong>
|
||||
<button type="button" class="link" onclick={clearFailed}>Alle ausblenden</button>
|
||||
</header>
|
||||
<ul>
|
||||
{#each failedEntries as e (e.id)}
|
||||
<li class="bulk-fail status-{e.status}">
|
||||
<span class="status-icon" aria-hidden="true">
|
||||
<AlertTriangle size={12} strokeWidth={2} />
|
||||
</span>
|
||||
<span class="name">{e.name}</span>
|
||||
<span class="msg">
|
||||
{#if e.status === 'unplaced'}Position fehlt — Eintrag in der Wegpunktliste auf Karte platzieren.
|
||||
{:else}Fehler: {e.message ?? 'unbekannt'}
|
||||
{/if}
|
||||
</span>
|
||||
<button type="button" class="dismiss" aria-label="Schließen" onclick={() => dismiss(e.id)}>
|
||||
<X size={13} strokeWidth={2} />
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</aside>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Wrapper holds the FAB + badge in a single positioning context so the
|
||||
* drag-target (full wrapper bounds) is larger than the button itself —
|
||||
* helps users dropping a stack of images. */
|
||||
.bulk-fab-wrap {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
right: 2rem;
|
||||
width: 3.75rem;
|
||||
height: 3.75rem;
|
||||
z-index: 100;
|
||||
transition: transform var(--transition-normal);
|
||||
}
|
||||
|
||||
.bulk-fab-wrap.dragging {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
/* FAB — mirrors the recipes-style ActionButton (same shake + shadow
|
||||
* via the shared action_button.css). */
|
||||
.bulk-fab {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border-radius: var(--radius-pill);
|
||||
background-color: var(--red);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-normal);
|
||||
}
|
||||
|
||||
.bulk-fab :global(.bulk-fab-icon) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bulk-fab :global(.bulk-fab-icon.spin) {
|
||||
animation: bulk-fab-spin 0.85s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes bulk-fab-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.bulk-fab-wrap.dragging .bulk-fab {
|
||||
background-color: var(--nord0);
|
||||
box-shadow: 0 0 0 5px color-mix(in oklab, var(--red) 35%, transparent),
|
||||
0 0 1.6em 0.4em rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.bulk-fab-wrap {
|
||||
bottom: 1rem;
|
||||
right: 1rem;
|
||||
width: 3.25rem;
|
||||
height: 3.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Numeric badge — pinned top-right of the FAB. Pending = primary blue,
|
||||
* fail = orange, info (orphan hashes) = nord blue. */
|
||||
.bulk-fab-badge {
|
||||
position: absolute;
|
||||
top: -0.25rem;
|
||||
right: -0.25rem;
|
||||
min-width: 1.35rem;
|
||||
height: 1.35rem;
|
||||
padding: 0 0.35rem;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-on-primary);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid var(--color-surface);
|
||||
box-shadow: var(--shadow-sm);
|
||||
appearance: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
button.bulk-fab-badge {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bulk-fab-badge.tone-fail {
|
||||
background: var(--orange);
|
||||
}
|
||||
|
||||
.bulk-fab-badge.tone-info {
|
||||
background: var(--blue);
|
||||
}
|
||||
|
||||
/* Failure popover anchored above the FAB. Only opens when the user
|
||||
* clicks the fail-tinted badge, so the FAB itself stays minimal. */
|
||||
.bulk-fail-popover {
|
||||
position: fixed;
|
||||
bottom: 6.5rem;
|
||||
right: 2rem;
|
||||
z-index: 101;
|
||||
max-width: min(360px, calc(100vw - 3rem));
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-left: 3px solid var(--orange);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 0.6rem 0.7rem;
|
||||
animation: bulk-fail-in 200ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes bulk-fail-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.bulk-fail-popover header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-primary);
|
||||
padding-bottom: 0.4rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.bulk-fail-popover .link {
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
font: inherit;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-tertiary);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.bulk-fail-popover ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.bulk-fail {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
padding: 0.35rem 0.4rem;
|
||||
background: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.bulk-fail .status-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
border-radius: 50%;
|
||||
background: var(--orange);
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bulk-fail.status-error .status-icon {
|
||||
background: var(--red);
|
||||
}
|
||||
|
||||
.bulk-fail .name {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bulk-fail .msg {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-tertiary);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.bulk-fail.status-error .msg {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.bulk-fail .dismiss {
|
||||
grid-column: 3;
|
||||
grid-row: 1 / span 2;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--color-text-tertiary);
|
||||
padding: 0.2rem;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bulk-fail .dismiss:hover {
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,186 @@
|
||||
<script lang="ts">
|
||||
import { builder } from './builderStore.svelte';
|
||||
import { haversineKm } from '$lib/gpx';
|
||||
import { computeElevationStats, computeElevationRange } from '$lib/hikes/elevation';
|
||||
import Route from '@lucide/svelte/icons/route';
|
||||
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';
|
||||
|
||||
interface Props {
|
||||
/** True while the snap-to-route / elevation-enrichment pipeline is still
|
||||
* resolving. Stats are computed from whatever's already in the store so
|
||||
* the user sees an evolving preview; the flag drives a subtle pulse so
|
||||
* they know the numbers may still tick up. */
|
||||
busy?: boolean;
|
||||
}
|
||||
|
||||
const { busy = false }: Props = $props();
|
||||
|
||||
type Pt = { lat: number; lng: number; altitude?: number };
|
||||
|
||||
// Flatten routedSegments → trkpt-shaped array. We dedupe the seam between
|
||||
// adjacent segments (each segment repeats its end as the next segment's
|
||||
// start) so distance + elevation don't double-count those vertices.
|
||||
const flatTrack = $derived.by<Pt[]>(() => {
|
||||
const out: Pt[] = [];
|
||||
let prev: Pt | null = null;
|
||||
for (const seg of builder.routedSegments) {
|
||||
for (const p of seg) {
|
||||
const point: Pt = {
|
||||
lng: p[0],
|
||||
lat: p[1],
|
||||
altitude: typeof p[2] === 'number' ? p[2] : undefined
|
||||
};
|
||||
if (
|
||||
prev &&
|
||||
prev.lat === point.lat &&
|
||||
prev.lng === point.lng &&
|
||||
prev.altitude === point.altitude
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
out.push(point);
|
||||
prev = point;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
const distanceKm = $derived.by(() => {
|
||||
let total = 0;
|
||||
for (let i = 1; i < flatTrack.length; i++) {
|
||||
total += haversineKm(
|
||||
{ ...flatTrack[i - 1], timestamp: 0 },
|
||||
{ ...flatTrack[i], timestamp: 0 }
|
||||
);
|
||||
}
|
||||
return total;
|
||||
});
|
||||
|
||||
const elevStats = $derived(computeElevationStats(flatTrack));
|
||||
const elevRange = $derived(computeElevationRange(flatTrack));
|
||||
const hasTrack = $derived(flatTrack.length >= 2);
|
||||
|
||||
function fmtNum(n: number | null | undefined, suffix = ''): string {
|
||||
if (n === null || n === undefined) return '–';
|
||||
return `${n}${suffix}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="stats-bar" class:busy class:idle={!hasTrack} aria-label="Routendaten">
|
||||
<div class="metric">
|
||||
<Route size={20} strokeWidth={1.75} aria-hidden="true" />
|
||||
<span class="value">
|
||||
{hasTrack ? distanceKm.toFixed(1) : '–'}<span class="value-unit">km</span>
|
||||
</span>
|
||||
<span class="unit">Distanz</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<TrendingUp size={20} strokeWidth={1.75} aria-hidden="true" />
|
||||
<span class="value">
|
||||
{hasTrack ? fmtNum(elevStats.gain) : '–'}<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">
|
||||
{hasTrack ? fmtNum(elevStats.loss) : '–'}<span class="value-unit">m</span>
|
||||
</span>
|
||||
<span class="unit">Abstieg</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<ArrowUpToLine size={20} strokeWidth={1.75} aria-hidden="true" />
|
||||
<span class="value">
|
||||
{hasTrack ? fmtNum(elevRange.max) : '–'}<span class="value-unit">m</span>
|
||||
</span>
|
||||
<span class="unit">höchster</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<ArrowDownToLine size={20} strokeWidth={1.75} aria-hidden="true" />
|
||||
<span class="value">
|
||||
{hasTrack ? fmtNum(elevRange.min) : '–'}<span class="value-unit">m</span>
|
||||
</span>
|
||||
<span class="unit">tiefster</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.stats-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.75rem 2rem;
|
||||
padding: 1rem 1.25rem;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
transition: opacity 200ms ease;
|
||||
}
|
||||
|
||||
.stats-bar.idle {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.stats-bar.busy {
|
||||
animation: stats-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes stats-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.65; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.stats-bar.busy {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.stats-bar.idle .metric :global(svg) {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.1;
|
||||
color: var(--color-text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stats-bar.idle .value {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.value-unit {
|
||||
font-size: 0.7em;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
margin-left: 0.15em;
|
||||
}
|
||||
|
||||
.unit {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-tertiary);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,813 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
builder,
|
||||
focusWaypoint,
|
||||
mapView,
|
||||
placedSequence,
|
||||
scheduleSave,
|
||||
toggleStageBreak,
|
||||
renameStage
|
||||
} 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 MapPinned from '@lucide/svelte/icons/map-pinned';
|
||||
import ImagePlus from '@lucide/svelte/icons/image-plus';
|
||||
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
|
||||
import Globe from '@lucide/svelte/icons/globe';
|
||||
import Lock from '@lucide/svelte/icons/lock';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import Flag from '@lucide/svelte/icons/flag';
|
||||
|
||||
interface Props {
|
||||
onCancelPlacement?: () => void;
|
||||
}
|
||||
|
||||
const { onCancelPlacement }: Props = $props();
|
||||
|
||||
const NUDGE_MINUTES = [-10, -5, 5, 10];
|
||||
|
||||
// Drive everything off the focus signal. The full waypoint array index
|
||||
// (`idx`) is used for in-place mutation; `wp` is a reactive reference into
|
||||
// the same store entry so writes propagate via Svelte 5 deep reactivity.
|
||||
const wpIdx = $derived(
|
||||
mapView.focusId ? builder.waypoints.findIndex((w) => w.id === mapView.focusId) : -1
|
||||
);
|
||||
const wp = $derived(wpIdx === -1 ? null : builder.waypoints[wpIdx]);
|
||||
const seq = $derived(wp ? placedSequence(wp.id) : null);
|
||||
|
||||
const placed = $derived(builder.waypoints.filter((w) => !w.unplaced));
|
||||
|
||||
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;
|
||||
});
|
||||
const requiresTime = $derived(wpIdx !== -1 && (wpIdx === firstPlacedIdx || wpIdx === lastPlacedIdx));
|
||||
|
||||
// Stage info for the focused waypoint (multi-day hikes). Mirrors the
|
||||
// per-row control in the waypoint list.
|
||||
const stageInfo = $derived.by(() => {
|
||||
if (!wp) return null;
|
||||
let num = 0;
|
||||
let name = '';
|
||||
for (let i = 0; i < placed.length; i++) {
|
||||
const w = placed[i];
|
||||
const isStart = i === 0 || w.stageStart !== undefined;
|
||||
if (isStart) {
|
||||
num++;
|
||||
name = w.stageStart || `Etappe ${num}`;
|
||||
}
|
||||
if (w.id === wp.id) return { isStart, num, name, isFirst: i === 0 };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const stageCount = $derived(
|
||||
placed.reduce((n, w, i) => n + (i === 0 || w.stageStart !== undefined ? 1 : 0), 0)
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const inheritedTs = $derived.by(() => {
|
||||
if (!wp || wp.timestamp != null) return null;
|
||||
return nearestTimestamp(wpIdx) ?? null;
|
||||
});
|
||||
|
||||
function updateLat(raw: string) {
|
||||
if (!wp) return;
|
||||
const n = parseFloat(raw);
|
||||
if (!isNaN(n)) {
|
||||
wp.lat = n;
|
||||
scheduleSave();
|
||||
}
|
||||
}
|
||||
|
||||
function updateLng(raw: string) {
|
||||
if (!wp) return;
|
||||
const n = parseFloat(raw);
|
||||
if (!isNaN(n)) {
|
||||
wp.lng = n;
|
||||
scheduleSave();
|
||||
}
|
||||
}
|
||||
|
||||
function setVisibility(value: 'public' | 'private') {
|
||||
if (!wp) return;
|
||||
wp.imageVisibility = value;
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
function removeWaypoint() {
|
||||
if (!wp || wpIdx === -1) return;
|
||||
const id = wp.id;
|
||||
dropFullImage(id);
|
||||
builder.waypoints.splice(wpIdx, 1);
|
||||
scheduleSave();
|
||||
// Move focus to the next remaining placed waypoint, or clear it.
|
||||
const next = placed.find((w) => w.id !== id);
|
||||
focusWaypoint(next?.id ?? null);
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
focusWaypoint(null);
|
||||
}
|
||||
|
||||
let attachBusy = $state(false);
|
||||
let dragActive = $state(false);
|
||||
|
||||
async function attachImage(fileList: FileList | null) {
|
||||
const file = fileList?.[0];
|
||||
if (!file || !wp) return;
|
||||
attachBusy = 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();
|
||||
}
|
||||
scheduleSave();
|
||||
} finally {
|
||||
attachBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onHeroDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
dragActive = false;
|
||||
const files = e.dataTransfer?.files;
|
||||
if (!files || files.length === 0) return;
|
||||
const imgs = [...files].filter((f) => f.type.startsWith('image/'));
|
||||
if (imgs.length === 0) return;
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(imgs[0]);
|
||||
attachImage(dt.files);
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="detail-panel" aria-label="Wegpunkt-Details">
|
||||
{#if !wp}
|
||||
<div class="empty">
|
||||
<MapPinned size={32} strokeWidth={1.5} />
|
||||
<p class="empty-title">Kein Wegpunkt ausgewählt</p>
|
||||
<p class="empty-sub">
|
||||
Klicke einen Pin auf der Karte oder einen Eintrag in der Liste an, um ihn
|
||||
hier zu bearbeiten. Mit ← / → kannst du die Route Wegpunkt für Wegpunkt
|
||||
durchgehen.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<header class="panel-head">
|
||||
<span class="seq" class:unplaced={wp.unplaced}>{seq ?? '?'}</span>
|
||||
<h3 class="title">
|
||||
{#if wp.unplaced}
|
||||
Bild ohne Position
|
||||
{:else if wp.imageHash}
|
||||
Bild {seq}
|
||||
{:else}
|
||||
Wegpunkt {seq}
|
||||
{/if}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="close"
|
||||
onclick={closePanel}
|
||||
aria-label="Panel schließen"
|
||||
title="Schließen"
|
||||
>
|
||||
<X size={16} strokeWidth={2} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="hero" class:empty={!wp.thumbnail && !getFullImageUrl(wp.id)} class:busy={attachBusy}>
|
||||
{#if wp.thumbnail || getFullImageUrl(wp.id)}
|
||||
<img src={getFullImageUrl(wp.id) ?? wp.thumbnail} alt="" />
|
||||
{:else}
|
||||
<!-- Same 4:3 box as the thumbnail variant so the rest of the panel
|
||||
stays put when an image gets attached. The label fills the box,
|
||||
acts as both click target and drop target. -->
|
||||
<label
|
||||
class="hero-upload"
|
||||
class:drag={dragActive}
|
||||
ondragenter={(e) => { e.preventDefault(); dragActive = true; }}
|
||||
ondragover={(e) => { e.preventDefault(); }}
|
||||
ondragleave={() => { dragActive = false; }}
|
||||
ondrop={onHeroDrop}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
disabled={attachBusy}
|
||||
onchange={(e) => attachImage(e.currentTarget.files)}
|
||||
/>
|
||||
<span class="hero-upload-inner">
|
||||
{#if attachBusy}
|
||||
<LoaderCircle size={28} strokeWidth={1.75} class="spin" />
|
||||
<span class="hero-upload-title">Bild wird gelesen…</span>
|
||||
{:else}
|
||||
<ImagePlus size={28} strokeWidth={1.75} />
|
||||
<span class="hero-upload-title">Bild anhängen</span>
|
||||
<span class="hero-upload-sub">
|
||||
Klicken oder hierher ziehen
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if wp.imageHash}
|
||||
<div class="vis-block" class:is-private={wp.imageVisibility === 'private'}>
|
||||
<div class="vis-head">
|
||||
<span class="label">Sichtbarkeit auf der Website</span>
|
||||
<span class="vis-state">
|
||||
{wp.imageVisibility === 'private'
|
||||
? 'Nur du siehst dieses Bild im veröffentlichten GPX.'
|
||||
: 'Dieses Bild wird öffentlich auf der Wandereintragsseite angezeigt.'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="vis-segment" role="radiogroup" aria-label="Sichtbarkeit">
|
||||
<button
|
||||
type="button"
|
||||
class="vis-opt"
|
||||
class:active={wp.imageVisibility !== 'private'}
|
||||
aria-pressed={wp.imageVisibility !== 'private'}
|
||||
onclick={() => setVisibility('public')}
|
||||
>
|
||||
<Globe size={18} strokeWidth={2} />
|
||||
<span>Öffentlich</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="vis-opt"
|
||||
class:active={wp.imageVisibility === 'private'}
|
||||
aria-pressed={wp.imageVisibility === 'private'}
|
||||
onclick={() => setVisibility('private')}
|
||||
>
|
||||
<Lock size={18} strokeWidth={2} />
|
||||
<span>Privat</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !wp.unplaced}
|
||||
<div class="field">
|
||||
<span class="label">
|
||||
{requiresTime ? 'Zeit (Pflicht)' : 'Zeit'}
|
||||
</span>
|
||||
<DateTimePicker
|
||||
bind:value={builder.waypoints[wpIdx].timestamp}
|
||||
mode={wp.imageHash || requiresTime || wp.timestamp != null ? 'datetime' : 'date'}
|
||||
inheritedValue={inheritedTs}
|
||||
nudgeMinutes={NUDGE_MINUTES}
|
||||
required={requiresTime}
|
||||
lang="de"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="placement-hint">
|
||||
Diese Position fehlt noch. Wähle den Eintrag in der Wegpunktliste unten und
|
||||
klicke „Auf Karte platzieren“ oder ziehe ein Bild mit GPS-EXIF in den
|
||||
Bildbereich.
|
||||
</p>
|
||||
<button type="button" class="ghost" onclick={() => onCancelPlacement?.()}>
|
||||
Platzierung abbrechen
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if !wp.unplaced}
|
||||
{#if stageInfo?.isStart && stageCount > 1}
|
||||
<div class="stage-block">
|
||||
<span class="stage-cap"><Flag size={12} strokeWidth={2.25} />Etappe {stageInfo.num}</span>
|
||||
<input
|
||||
class="stage-name-input"
|
||||
value={wp.stageStart ?? stageInfo.name}
|
||||
placeholder={`Etappe ${stageInfo.num}`}
|
||||
oninput={(e) => renameStage(wp.id, e.currentTarget.value)}
|
||||
aria-label={`Name Etappe ${stageInfo.num}`}
|
||||
/>
|
||||
{#if !stageInfo.isFirst}
|
||||
<button type="button" class="stage-dissolve" onclick={() => toggleStageBreak(wp.id)}>
|
||||
Etappe auflösen
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if !stageInfo?.isFirst}
|
||||
<button type="button" class="stage-new" onclick={() => toggleStageBreak(wp.id)}>
|
||||
<Flag size={15} strokeWidth={2} />
|
||||
<span>Neue Etappe ab hier</span>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if !wp.unplaced}
|
||||
<details class="coords-details">
|
||||
<summary>Koordinaten anpassen</summary>
|
||||
<div class="coords-grid">
|
||||
<div class="field">
|
||||
<label class="label" for="dp-lat">Breitengrad</label>
|
||||
<input
|
||||
id="dp-lat"
|
||||
type="number"
|
||||
step="0.000001"
|
||||
value={wp.lat}
|
||||
onchange={(e) => updateLat(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="dp-lng">Längengrad</label>
|
||||
<input
|
||||
id="dp-lng"
|
||||
type="number"
|
||||
step="0.000001"
|
||||
value={wp.lng}
|
||||
onchange={(e) => updateLng(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
{/if}
|
||||
|
||||
<button type="button" class="danger" onclick={removeWaypoint}>
|
||||
Wegpunkt entfernen
|
||||
</button>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.detail-panel {
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
min-width: 0;
|
||||
/* Match the map's height so the column visually anchors next to it.
|
||||
* The intrinsic content scrolls within so the panel itself stays the
|
||||
* same shape regardless of waypoint state. */
|
||||
max-height: 640px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.detail-panel {
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 0.4rem;
|
||||
padding: 1.5rem 0.75rem;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
margin: 0.3rem 0 0;
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty-sub {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.seq {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
padding: 0 0.45em;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-on-primary);
|
||||
border-radius: 14px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.seq.unplaced {
|
||||
background: var(--orange);
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.close {
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--color-text-tertiary);
|
||||
padding: 0.25rem;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
.hero {
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
.hero img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* Empty-state variant occupies the SAME 4:3 box as the thumbnail
|
||||
* variant — same width, same aspect-ratio, same border-radius — so
|
||||
* dropping/attaching an image swaps the inner content without shifting
|
||||
* any other panel section. */
|
||||
.hero.empty {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color-mix(in oklab, var(--color-primary) 6%, transparent),
|
||||
transparent 70%
|
||||
),
|
||||
var(--color-bg-tertiary);
|
||||
border: 1.5px dashed color-mix(in oklab, var(--color-primary) 32%, var(--color-border));
|
||||
transition: border-color var(--transition-fast), background var(--transition-fast);
|
||||
}
|
||||
|
||||
.hero.empty:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.hero-upload {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hero.busy .hero-upload {
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.hero-upload input[type='file'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hero-upload.drag {
|
||||
background: color-mix(in oklab, var(--color-primary) 14%, transparent);
|
||||
}
|
||||
|
||||
.hero-upload-inner {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero-upload-inner :global(svg) {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.hero-upload-title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.hero-upload-sub {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.hero-upload :global(.spin) {
|
||||
animation: panel-spin 0.85s linear infinite;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-tertiary);
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.field input[type='number'] {
|
||||
width: 100%;
|
||||
padding: 0.45rem 0.6rem;
|
||||
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.85rem;
|
||||
}
|
||||
|
||||
/* Visibility is the highest-stakes setting in the panel — privacy choice
|
||||
* for the published GPX. Treat it as a primary action: card-like block
|
||||
* with a short rationale + a wide two-segment toggle, tinted green for
|
||||
* public and amber for private so the current state reads at a glance. */
|
||||
.vis-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
padding: 0.75rem 0.85rem 0.85rem;
|
||||
background: color-mix(in oklab, var(--green) 8%, var(--color-bg-secondary));
|
||||
border: 1px solid color-mix(in oklab, var(--green) 30%, var(--color-border));
|
||||
border-left: 3px solid var(--green);
|
||||
border-radius: var(--radius-md);
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.vis-block.is-private {
|
||||
background: color-mix(in oklab, var(--orange) 8%, var(--color-bg-secondary));
|
||||
border-color: color-mix(in oklab, var(--orange) 35%, var(--color-border));
|
||||
border-left-color: var(--orange);
|
||||
}
|
||||
|
||||
.vis-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.vis-state {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.vis-segment {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.vis-opt {
|
||||
appearance: none;
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
padding: 0.6rem 0.5rem;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.45rem;
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast),
|
||||
color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.vis-opt:hover:not(.active) {
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
.vis-opt.active {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.vis-block:not(.is-private) .vis-opt.active {
|
||||
background: var(--green);
|
||||
border-color: var(--green);
|
||||
box-shadow: 0 0 0 2px color-mix(in oklab, var(--green) 30%, transparent);
|
||||
}
|
||||
|
||||
.vis-block.is-private .vis-opt.active {
|
||||
background: var(--orange);
|
||||
border-color: var(--orange);
|
||||
box-shadow: 0 0 0 2px color-mix(in oklab, var(--orange) 30%, transparent);
|
||||
}
|
||||
|
||||
/* Stage controls — start a new stage at this waypoint, or name/dissolve an
|
||||
* existing stage start. Mirrors the waypoint-list affordance. */
|
||||
.stage-new {
|
||||
appearance: none;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.5rem 0.9rem;
|
||||
border-radius: var(--radius-pill);
|
||||
background: transparent;
|
||||
border: 1px dashed color-mix(in oklab, var(--color-primary) 40%, var(--color-border));
|
||||
color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
|
||||
.stage-new:hover {
|
||||
background: var(--color-primary);
|
||||
border-style: solid;
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-text-on-primary);
|
||||
}
|
||||
|
||||
.stage-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.6rem 0.7rem;
|
||||
background: color-mix(in oklab, var(--color-primary) 8%, var(--color-bg-secondary));
|
||||
border: 1px solid color-mix(in oklab, var(--color-primary) 25%, var(--color-border));
|
||||
border-left: 3px solid var(--color-primary);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.stage-cap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.stage-name-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
padding: 0.35rem 0.5rem;
|
||||
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.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stage-dissolve {
|
||||
flex: 0 0 auto;
|
||||
appearance: none;
|
||||
font: inherit;
|
||||
font-size: 0.72rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stage-dissolve:hover {
|
||||
color: var(--red);
|
||||
border-color: color-mix(in oklab, var(--red) 40%, var(--color-border));
|
||||
}
|
||||
|
||||
/* Coords are a power-user adjustment — keep them out of the way unless
|
||||
* the user explicitly opens the disclosure. Dragging the marker on the
|
||||
* map is the primary editing affordance. */
|
||||
.coords-details {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.coords-details > summary {
|
||||
cursor: pointer;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
list-style: revert;
|
||||
}
|
||||
|
||||
.coords-details[open] > summary {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.coords-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 0.75rem 0.7rem;
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.coords-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.placement-hint {
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.7rem;
|
||||
background: color-mix(in oklab, var(--orange) 10%, var(--color-bg-secondary));
|
||||
border-left: 3px solid var(--orange);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
@keyframes panel-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.ghost {
|
||||
appearance: none;
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.4rem 0.9rem;
|
||||
border-radius: var(--radius-pill);
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.danger {
|
||||
appearance: none;
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.5rem 0.9rem;
|
||||
margin-top: 0.25rem;
|
||||
border-radius: var(--radius-pill);
|
||||
background: transparent;
|
||||
border: 1px solid color-mix(in oklab, var(--red) 35%, var(--color-border));
|
||||
color: var(--red);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.danger:hover {
|
||||
background: var(--red);
|
||||
color: white;
|
||||
border-color: var(--red);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,854 @@
|
||||
<script lang="ts">
|
||||
import { flip } from 'svelte/animate';
|
||||
import {
|
||||
builder,
|
||||
focusWaypoint,
|
||||
mapView,
|
||||
placedSequence,
|
||||
scheduleSave,
|
||||
toggleStageBreak,
|
||||
renameStage
|
||||
} 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';
|
||||
import ArrowUp from '@lucide/svelte/icons/arrow-up';
|
||||
import ArrowDown from '@lucide/svelte/icons/arrow-down';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import Crosshair from '@lucide/svelte/icons/crosshair';
|
||||
import MapPin from '@lucide/svelte/icons/map-pin';
|
||||
import MapPinOff from '@lucide/svelte/icons/map-pin-off';
|
||||
import Globe from '@lucide/svelte/icons/globe';
|
||||
import Lock from '@lucide/svelte/icons/lock';
|
||||
import Flag from '@lucide/svelte/icons/flag';
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
// Per-waypoint stage metadata (placed waypoints only): whether it begins a
|
||||
// stage, the stage number/name, and whether it's the route start.
|
||||
const stageMeta = $derived.by(() => {
|
||||
const map = new Map<string, { isStart: boolean; num: number; name: string; first: boolean }>();
|
||||
let num = 0;
|
||||
let name = '';
|
||||
let firstSeen = false;
|
||||
for (const w of builder.waypoints) {
|
||||
if (w.unplaced) continue;
|
||||
const first = !firstSeen;
|
||||
firstSeen = true;
|
||||
const isStart = first || w.stageStart !== undefined;
|
||||
if (isStart) {
|
||||
num++;
|
||||
name = w.stageStart || `Etappe ${num}`;
|
||||
}
|
||||
map.set(w.id, { isStart, num, name, first });
|
||||
}
|
||||
return map;
|
||||
});
|
||||
const stageCount = $derived(
|
||||
[...stageMeta.values()].filter((m) => m.isStart).length
|
||||
);
|
||||
|
||||
/** 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)}
|
||||
{@const seq = placedSequence(wp.id)}
|
||||
{@const sm = stageMeta.get(wp.id)}
|
||||
<li
|
||||
class="wp"
|
||||
class:stage-start={stageCount > 1 && sm?.isStart}
|
||||
class:unplaced={wp.unplaced}
|
||||
class:active={wp.id === pendingPlacementId}
|
||||
class:focused={wp.id === mapView.focusId && !wp.unplaced}
|
||||
animate:flip={{ duration: 220 }}
|
||||
>
|
||||
{#if stageCount > 1 && sm?.isStart}
|
||||
<div class="stage-band">
|
||||
<span class="stage-badge"><Flag size={11} strokeWidth={2.25} />Etappe {sm.num}</span>
|
||||
<input
|
||||
class="stage-name"
|
||||
value={wp.stageStart ?? sm.name}
|
||||
placeholder={`Etappe ${sm.num}`}
|
||||
oninput={(e) => renameStage(wp.id, e.currentTarget.value)}
|
||||
aria-label={`Name Etappe ${sm.num}`}
|
||||
/>
|
||||
{#if !sm.first}
|
||||
<button
|
||||
type="button"
|
||||
class="stage-merge"
|
||||
onclick={() => toggleStageBreak(wp.id)}
|
||||
title="Mit vorheriger Etappe zusammenführen"
|
||||
aria-label="Etappe auflösen"
|
||||
>
|
||||
<X size={13} strokeWidth={2.25} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#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">
|
||||
<MapPinOff size={12} strokeWidth={2} />
|
||||
<span>noch nicht platziert</span>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="row title-row">
|
||||
<span class="idx" class:unplaced-idx={wp.unplaced}>
|
||||
{seq ?? '?'}
|
||||
</span>
|
||||
<span class="title">
|
||||
{#if wp.unplaced}
|
||||
Bild ohne Position
|
||||
{:else if wp.imageHash}
|
||||
Bild {seq}
|
||||
{:else}
|
||||
Wegpunkt {seq}
|
||||
{/if}
|
||||
</span>
|
||||
<div class="row-actions">
|
||||
{#if !wp.unplaced && !sm?.first}
|
||||
<button
|
||||
type="button"
|
||||
class="stage-flag"
|
||||
class:on={sm?.isStart}
|
||||
onclick={() => toggleStageBreak(wp.id)}
|
||||
aria-pressed={sm?.isStart}
|
||||
aria-label={sm?.isStart ? 'Etappenbeginn entfernen' : 'Neue Etappe ab hier'}
|
||||
title={sm?.isStart ? 'Etappenbeginn entfernen' : 'Neue Etappe ab hier'}
|
||||
>
|
||||
<Flag size={14} strokeWidth={2} />
|
||||
</button>
|
||||
{/if}
|
||||
{#if !wp.unplaced}
|
||||
<button
|
||||
type="button"
|
||||
class="focus-btn"
|
||||
onclick={() => focusWaypoint(wp.id)}
|
||||
aria-label="Auf Karte fokussieren"
|
||||
title="Auf Karte fokussieren"
|
||||
>
|
||||
<Crosshair size={14} strokeWidth={2} />
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => move(idx, -1)}
|
||||
disabled={idx === 0}
|
||||
aria-label="Nach oben"
|
||||
>
|
||||
<ArrowUp size={14} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => move(idx, 1)}
|
||||
disabled={idx === builder.waypoints.length - 1}
|
||||
aria-label="Nach unten"
|
||||
>
|
||||
<ArrowDown size={14} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="del"
|
||||
onclick={() => remove(idx)}
|
||||
aria-label="Entfernen"
|
||||
>
|
||||
<X size={14} strokeWidth={2} />
|
||||
</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)}>
|
||||
<MapPin size={14} strokeWidth={2} />
|
||||
<span>Auf Karte platzieren</span>
|
||||
</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')}
|
||||
>
|
||||
<Globe size={12} strokeWidth={2} />
|
||||
<span>Öffentlich</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class:active={wp.imageVisibility === 'private'}
|
||||
aria-pressed={wp.imageVisibility === 'private'}
|
||||
onclick={() => setVisibility(idx, 'private')}
|
||||
>
|
||||
<Lock size={12} strokeWidth={2} />
|
||||
<span>Privat</span>
|
||||
</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);
|
||||
}
|
||||
|
||||
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: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Stage band at the top of the first card of each stage. */
|
||||
.stage-band {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.45rem 0.55rem;
|
||||
background: color-mix(in oklab, var(--color-primary) 8%, var(--color-bg-secondary));
|
||||
border-bottom: 1px solid color-mix(in oklab, var(--color-primary) 22%, var(--color-border));
|
||||
}
|
||||
|
||||
.stage-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-primary);
|
||||
padding: 0.22rem 0.55rem;
|
||||
background: color-mix(in oklab, var(--color-primary) 12%, transparent);
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.stage-name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
padding: 0.3rem 0.5rem;
|
||||
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.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stage-merge {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
appearance: none;
|
||||
padding: 0.25rem;
|
||||
line-height: 0;
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stage-merge:hover {
|
||||
color: var(--red);
|
||||
border-color: color-mix(in oklab, var(--red) 40%, var(--color-border));
|
||||
}
|
||||
|
||||
.row-actions button.stage-flag.on {
|
||||
color: var(--color-primary);
|
||||
border-color: color-mix(in oklab, var(--color-primary) 40%, var(--color-border));
|
||||
background: color-mix(in oklab, var(--color-primary) 10%, var(--color-bg-tertiary));
|
||||
}
|
||||
|
||||
.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);
|
||||
scroll-margin-top: 1rem;
|
||||
}
|
||||
|
||||
.wp.unplaced {
|
||||
border-color: var(--orange);
|
||||
background: color-mix(in oklab, var(--orange) 6%, var(--color-bg-secondary));
|
||||
}
|
||||
|
||||
/* Mark the first card of each stage with a top accent. */
|
||||
.wp.stage-start {
|
||||
border-top: 2px solid var(--color-primary);
|
||||
}
|
||||
|
||||
.wp.active {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 2px color-mix(in oklab, var(--color-primary) 30%, transparent);
|
||||
}
|
||||
|
||||
.wp.focused {
|
||||
border-color: var(--blue);
|
||||
box-shadow: 0 0 0 2px color-mix(in oklab, var(--blue) 30%, transparent),
|
||||
var(--shadow-md);
|
||||
}
|
||||
|
||||
.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;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.2rem 0.55rem;
|
||||
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.25rem;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
line-height: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.row-actions button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.row-actions button.del {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.row-actions button.focus-btn {
|
||||
color: var(--blue);
|
||||
border-color: color-mix(in oklab, var(--blue) 35%, var(--color-border));
|
||||
background: color-mix(in oklab, var(--blue) 8%, var(--color-bg-tertiary));
|
||||
}
|
||||
|
||||
.row-actions button.focus-btn:hover {
|
||||
background: var(--blue);
|
||||
color: white;
|
||||
border-color: var(--blue);
|
||||
}
|
||||
|
||||
.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;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.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.25rem 0.65rem;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.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,570 @@
|
||||
/**
|
||||
* 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';
|
||||
import { parseGpx, parseGpxStages, parseGpxImageRefs } from '$lib/gpx';
|
||||
|
||||
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;
|
||||
/** When set, this (placed) waypoint begins a new stage of a multi-day
|
||||
* hike, with this string as the stage name. The first placed waypoint
|
||||
* always begins stage 1 implicitly; setting `stageStart` on it just names
|
||||
* that stage. Exported as separate `<trk>` elements. */
|
||||
stageStart?: string;
|
||||
};
|
||||
|
||||
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());
|
||||
|
||||
/**
|
||||
* UI-only signals shared between the edit map and the side panels.
|
||||
*
|
||||
* - `fitTick`: bump to re-run `fitBounds()` on the current track. Used
|
||||
* after batch insertions (image drops, GPX import) where the user
|
||||
* expects the map to reframe to show every newly-added waypoint.
|
||||
* - `focusId` + `focusTick`: bump to pan/zoom the map onto a specific
|
||||
* waypoint AND mark it as the "current" one (drives prev/next nav and
|
||||
* the highlight on the corresponding table row + marker).
|
||||
*
|
||||
* Not persisted — pure session UI.
|
||||
*/
|
||||
export const mapView = $state<{
|
||||
fitTick: number;
|
||||
focusId: string | null;
|
||||
focusTick: number;
|
||||
}>({ fitTick: 0, focusId: null, focusTick: 0 });
|
||||
|
||||
export function requestFitBounds(): void {
|
||||
mapView.fitTick++;
|
||||
}
|
||||
|
||||
export function focusWaypoint(id: string | null): void {
|
||||
mapView.focusId = id;
|
||||
mapView.focusTick++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sequence number (1-based) of `wp` among placed waypoints only. Unplaced
|
||||
* image entries return `null`. Single source of truth so the table badge,
|
||||
* the map marker number, and the GPX export all agree on what "Wegpunkt 3"
|
||||
* means.
|
||||
*/
|
||||
export function placedSequence(wpId: string): number | null {
|
||||
let n = 0;
|
||||
for (const w of builder.waypoints) {
|
||||
if (w.unplaced) continue;
|
||||
n++;
|
||||
if (w.id === wpId) return n;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stages (multi-day hikes). A new stage begins at the first placed waypoint
|
||||
// and at any placed waypoint carrying `stageStart`. Each stage exports as its
|
||||
// own named <trk>.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Placed waypoints split into stages, in placed-index ranges. */
|
||||
export function deriveStageGroups(): { name: string; startIdx: number; endIdx: number }[] {
|
||||
const placed = builder.waypoints.filter((w) => !w.unplaced);
|
||||
const groups: { name: string; startIdx: number; endIdx: number }[] = [];
|
||||
for (let i = 0; i < placed.length; i++) {
|
||||
if (groups.length === 0 || placed[i].stageStart !== undefined) {
|
||||
groups.push({
|
||||
name: placed[i].stageStart || `Etappe ${groups.length + 1}`,
|
||||
startIdx: i,
|
||||
endIdx: i
|
||||
});
|
||||
} else {
|
||||
groups[groups.length - 1].endIdx = i;
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** Toggle whether a placed waypoint begins a new stage. The route start can't
|
||||
* be a break (it always begins stage 1). */
|
||||
export function toggleStageBreak(wpId: string): void {
|
||||
const placed = builder.waypoints.filter((w) => !w.unplaced);
|
||||
if (placed.length === 0 || placed[0].id === wpId) return;
|
||||
const wp = builder.waypoints.find((w) => w.id === wpId);
|
||||
if (!wp) return;
|
||||
if (wp.stageStart !== undefined) {
|
||||
delete wp.stageStart;
|
||||
} else {
|
||||
const idxInPlaced = placed.findIndex((w) => w.id === wpId);
|
||||
let n = 0;
|
||||
for (let i = 0; i <= idxInPlaced; i++) {
|
||||
if (i === 0 || placed[i].stageStart !== undefined || placed[i].id === wpId) n++;
|
||||
}
|
||||
wp.stageStart = `Etappe ${n}`;
|
||||
}
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
/** Name (or rename) the stage that begins at this waypoint. Setting it on the
|
||||
* first placed waypoint names stage 1 without creating an extra break. */
|
||||
export function renameStage(firstWpId: string, name: string): void {
|
||||
const wp = builder.waypoints.find((w) => w.id === firstWpId);
|
||||
if (!wp) return;
|
||||
wp.stageStart = name;
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/** Haversine distance in metres between two `[lng, lat]` points.
|
||||
* Inline so this module can stay client-only (the server helpers live in
|
||||
* `$lib/server/hikesRouting.ts` and aren't importable here). */
|
||||
function haversineMeters(lng1: number, lat1: number, lng2: number, lat2: number): number {
|
||||
const R = 6_371_000;
|
||||
const dLat = ((lat2 - lat1) * Math.PI) / 180;
|
||||
const dLng = ((lng2 - lng1) * Math.PI) / 180;
|
||||
const sinLat = Math.sin(dLat / 2);
|
||||
const sinLng = Math.sin(dLng / 2);
|
||||
const h =
|
||||
sinLat * sinLat +
|
||||
Math.cos((lat1 * Math.PI) / 180) *
|
||||
Math.cos((lat2 * Math.PI) / 180) *
|
||||
sinLng * sinLng;
|
||||
return 2 * R * Math.asin(Math.sqrt(h));
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand every 2-point linear segment into evenly-spaced intermediate
|
||||
* points so an elevation enrichment pass can capture the terrain profile
|
||||
* between the two waypoints. Snapped segments (already many points from
|
||||
* BRouter/OSRM) are left alone.
|
||||
*
|
||||
* `spacingM` defaults to 25 m — matches the coarsest Swisstopo DTM that
|
||||
* we sample against; finer spacing would just sample the same elevation
|
||||
* value twice. Very short segments (< 30 m) skip densification: the two
|
||||
* endpoints already capture every meaningful elevation step within
|
||||
* Swisstopo's DTM resolution at that distance.
|
||||
*
|
||||
* Returns `true` when at least one segment was densified (caller can use
|
||||
* this to decide whether to fire a fresh elevation request).
|
||||
*/
|
||||
export function densifyLinearSegments(spacingM = 25): boolean {
|
||||
let densifiedAny = false;
|
||||
for (let i = 0; i < builder.routedSegments.length; i++) {
|
||||
const seg = builder.routedSegments[i];
|
||||
if (seg.length !== 2) continue; // already snapped or already densified
|
||||
const [lngA, latA, altA] = seg[0];
|
||||
const [lngB, latB, altB] = seg[1];
|
||||
const dist = haversineMeters(lngA, latA, lngB, latB);
|
||||
if (dist < 30) continue;
|
||||
// At least 4 sub-segments so even a 30-m linear sample gets a usable
|
||||
// elevation profile; longer segments scale up to keep ~25 m spacing.
|
||||
const n = Math.max(4, Math.ceil(dist / spacingM));
|
||||
const out: Array<[number, number, number?]> = new Array(n + 1);
|
||||
for (let j = 0; j <= n; j++) {
|
||||
const f = j / n;
|
||||
// Endpoints keep whatever altitude the caller supplied (typically
|
||||
// `undefined` here — enrichment fills both ends + everything between);
|
||||
// intermediates are seeded as `undefined` so the enrichment step
|
||||
// knows to fill them.
|
||||
const alt = j === 0 ? altA : j === n ? altB : undefined;
|
||||
out[j] = [lngA + (lngB - lngA) * f, latA + (latB - latA) * f, alt];
|
||||
}
|
||||
builder.routedSegments[i] = out;
|
||||
densifiedAny = true;
|
||||
}
|
||||
return densifiedAny;
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GPX import — restores the builder state from a previously-exported GPX so
|
||||
// the user can iterate on an existing route (add a waypoint, retag an
|
||||
// image, fix a turn) without losing the densified track or photo anchors.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ImportGpxResult =
|
||||
| { ok: true; trackName: string | null; waypointCount: number; imageCount: number }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/** Coordinate equality with a small tolerance — float round-trips through
|
||||
* the GPX writer can shift the 7th decimal. 1e-5° ≈ 1 m, well below the
|
||||
* spacing of any meaningful pair of anchors on a hike. */
|
||||
function coordsClose(aLat: number, aLng: number, bLat: number, bLng: number): boolean {
|
||||
return Math.abs(aLat - bLat) < 1e-5 && Math.abs(aLng - bLng) < 1e-5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct the builder state from a GPX XML string.
|
||||
*
|
||||
* Strategy: the exported GPX interleaves user-anchor waypoints with
|
||||
* densified/snapped intermediate trkpts in a single `<trkseg>`. We don't
|
||||
* try to round-trip "manual waypoints" vs "intermediates" perfectly —
|
||||
* instead we recover the *image* anchors (matched against `<wpt>` entries
|
||||
* by coordinate), plus the very first and last trkpts (start + end), and
|
||||
* rebuild routedSegments from the trkpts that fall between each adjacent
|
||||
* anchor pair. Result is an editable route where every photo waypoint is
|
||||
* a draggable handle and the geometry between handles is preserved
|
||||
* verbatim — no re-routing required.
|
||||
*
|
||||
* Replaces the existing draft. Caller should confirm with the user if the
|
||||
* builder is non-empty.
|
||||
*/
|
||||
export function importGpx(xml: string): ImportGpxResult {
|
||||
const trk = parseGpx(xml);
|
||||
if (trk.length < 2) {
|
||||
return { ok: false, error: 'GPX enthält keinen verwertbaren Track (mind. zwei trkpt nötig).' };
|
||||
}
|
||||
// Stage boundaries: a multi-<trk> GPX is a multi-day route. Map each
|
||||
// stage's first flat-track index to its name so we can re-mark the
|
||||
// corresponding waypoint as a stage start.
|
||||
const gpxStages = parseGpxStages(xml);
|
||||
const multiStage = gpxStages.length > 1;
|
||||
const stageNameAt = new Map<number, string>();
|
||||
{
|
||||
let off = 0;
|
||||
for (let k = 0; k < gpxStages.length; k++) {
|
||||
stageNameAt.set(off, gpxStages[k].name ?? `Etappe ${k + 1}`);
|
||||
off += gpxStages[k].points.length;
|
||||
}
|
||||
}
|
||||
const imageRefs = parseGpxImageRefs(xml);
|
||||
const imageList = Object.values(imageRefs);
|
||||
|
||||
// Optional <name> on the track or top-level metadata.
|
||||
const nameMatch =
|
||||
xml.match(/<trk>[\s\S]*?<name>([^<]+)<\/name>[\s\S]*?<\/trk>/i) ??
|
||||
xml.match(/<metadata>[\s\S]*?<name>([^<]+)<\/name>[\s\S]*?<\/metadata>/i);
|
||||
const trackName = nameMatch ? nameMatch[1].trim() : null;
|
||||
|
||||
// Map each image waypoint to its first matching trkpt index. Order the
|
||||
// image anchors by that index so they slot into the builder in
|
||||
// traversal order, not GPX-declaration order.
|
||||
type ImageAnchor = {
|
||||
trkIdx: number;
|
||||
hash: string;
|
||||
visibility: 'public' | 'private';
|
||||
lat: number;
|
||||
lng: number;
|
||||
altitude?: number;
|
||||
timestamp?: number;
|
||||
};
|
||||
const imageAnchors: ImageAnchor[] = [];
|
||||
for (const ref of imageList) {
|
||||
let bestIdx = -1;
|
||||
for (let i = 0; i < trk.length; i++) {
|
||||
if (coordsClose(trk[i].lat, trk[i].lng, ref.lat, ref.lng)) {
|
||||
bestIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (bestIdx < 0) continue; // wpt position doesn't match any trkpt — skip
|
||||
imageAnchors.push({
|
||||
trkIdx: bestIdx,
|
||||
hash: ref.hash,
|
||||
visibility: ref.visibility === 'private' ? 'private' : 'public',
|
||||
lat: ref.lat,
|
||||
lng: ref.lng,
|
||||
altitude: ref.altitude,
|
||||
timestamp: ref.timestamp
|
||||
});
|
||||
}
|
||||
imageAnchors.sort((a, b) => a.trkIdx - b.trkIdx);
|
||||
|
||||
// Build the set of anchor trkpt indices: first, last, all image anchors,
|
||||
// plus every stage boundary so multi-day breaks survive the round-trip.
|
||||
const anchorIndices = new Set<number>([0, trk.length - 1]);
|
||||
for (const ia of imageAnchors) anchorIndices.add(ia.trkIdx);
|
||||
if (multiStage) for (const idx of stageNameAt.keys()) anchorIndices.add(idx);
|
||||
const sortedAnchorIdx = [...anchorIndices].sort((a, b) => a - b);
|
||||
|
||||
// Assemble waypoints in traversal order.
|
||||
const newWaypoints: Waypoint[] = sortedAnchorIdx.map((i) => {
|
||||
const t = trk[i];
|
||||
const ia = imageAnchors.find((a) => a.trkIdx === i);
|
||||
const wp: Waypoint = {
|
||||
id: nextWaypointId(),
|
||||
lat: t.lat,
|
||||
lng: t.lng,
|
||||
altitude: typeof t.altitude === 'number' ? t.altitude : ia?.altitude,
|
||||
timestamp: t.timestamp ?? ia?.timestamp ?? null
|
||||
};
|
||||
if (ia) {
|
||||
wp.imageHash = ia.hash;
|
||||
wp.imageVisibility = ia.visibility;
|
||||
}
|
||||
// Re-mark stage starts (skip index 0 — the route start is stage 1
|
||||
// implicitly; naming it is harmless but unnecessary).
|
||||
if (multiStage && i > 0 && stageNameAt.has(i)) {
|
||||
wp.stageStart = stageNameAt.get(i);
|
||||
}
|
||||
return wp;
|
||||
});
|
||||
|
||||
// Reconstruct routedSegments from the trkpts between consecutive anchors.
|
||||
// Each segment is `[lng, lat, ele?][]` and spans anchor[i] .. anchor[i+1]
|
||||
// inclusive — the GPX writer's reverse operation.
|
||||
const newSegments: Array<Array<[number, number, number?]>> = [];
|
||||
for (let i = 0; i < sortedAnchorIdx.length - 1; i++) {
|
||||
const start = sortedAnchorIdx[i];
|
||||
const end = sortedAnchorIdx[i + 1];
|
||||
const seg: Array<[number, number, number?]> = [];
|
||||
for (let j = start; j <= end; j++) {
|
||||
const t = trk[j];
|
||||
seg.push([t.lng, t.lat, typeof t.altitude === 'number' ? t.altitude : undefined]);
|
||||
}
|
||||
newSegments.push(seg);
|
||||
}
|
||||
|
||||
const newSources: SegmentSource[] = [];
|
||||
for (let i = 0; i < newWaypoints.length - 1; i++) {
|
||||
newSources.push(makeSource(newWaypoints[i], newWaypoints[i + 1]));
|
||||
}
|
||||
|
||||
// Atomic swap.
|
||||
builder.name = trackName ?? builder.name ?? '';
|
||||
// Disable auto-snap so the imported densified/snapped geometry isn't
|
||||
// immediately overwritten by a routing API call.
|
||||
builder.autoSnap = false;
|
||||
builder.waypoints.splice(0, builder.waypoints.length, ...newWaypoints);
|
||||
builder.routedSegments.splice(0, builder.routedSegments.length, ...newSegments);
|
||||
builder.segmentSources.splice(0, builder.segmentSources.length, ...newSources);
|
||||
scheduleSave();
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
trackName,
|
||||
waypointCount: newWaypoints.length,
|
||||
imageCount: imageAnchors.length
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Single-point Swisstopo elevation lookup for the route-builder.
|
||||
* Image GPS altitude (EXIF GPSAltitude) is unreliable and causes spikes in the
|
||||
* elevation profile, so all waypoints — including image-derived ones — should
|
||||
* source their altitude from the terrain model instead.
|
||||
*/
|
||||
export async function fetchElevationAt(lat: number, lng: number): Promise<number | null> {
|
||||
try {
|
||||
const res = await fetch(`/api/hikes/route-builder/elevation?lat=${lat}&lng=${lng}`);
|
||||
if (!res.ok) return null;
|
||||
const { elevation } = (await res.json()) as { elevation: number | null };
|
||||
return typeof elevation === 'number' ? elevation : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* In-memory cache of full-resolution image Blob URLs keyed by waypoint id.
|
||||
*
|
||||
* Storing the original File as a base64 data URL would blow past the
|
||||
* localStorage quota almost immediately, so the persisted draft only carries
|
||||
* a 360w WebP thumbnail. The full-resolution preview lives here only for the
|
||||
* lifetime of the page — on reload, callers fall back to the thumbnail.
|
||||
*
|
||||
* Backed by a Svelte 5 `$state` proxy so the table re-renders the moment a
|
||||
* fresh Blob URL is registered (e.g. after image upload or re-attach).
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
const urls = $state<Record<string, string>>({});
|
||||
|
||||
export function setFullImage(waypointId: string, file: Blob): void {
|
||||
if (!browser) return;
|
||||
const old = urls[waypointId];
|
||||
if (old) URL.revokeObjectURL(old);
|
||||
urls[waypointId] = URL.createObjectURL(file);
|
||||
}
|
||||
|
||||
export function getFullImageUrl(waypointId: string): string | undefined {
|
||||
return urls[waypointId];
|
||||
}
|
||||
|
||||
export function dropFullImage(waypointId: string): void {
|
||||
if (!browser) return;
|
||||
const url = urls[waypointId];
|
||||
if (url) URL.revokeObjectURL(url);
|
||||
delete urls[waypointId];
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Render a WebP data-URL preview of an image file using the browser's canvas.
|
||||
* 360px wide — large enough to serve as a full-width preview in the waypoint
|
||||
* table while still being a sane base64 payload (~30 KB) for localStorage.
|
||||
* The marker badge on the map (56×56) downsamples it cleanly.
|
||||
*/
|
||||
const PREVIEW_WIDTH = 360;
|
||||
|
||||
export async function readThumbnail(file: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const w = Math.min(PREVIEW_WIDTH, img.width || PREVIEW_WIDTH);
|
||||
const ratio = img.width / img.height || 1;
|
||||
const h = Math.round(w / ratio);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
reject(new Error('canvas 2d unavailable'));
|
||||
return;
|
||||
}
|
||||
ctx.drawImage(img, 0, 0, w, h);
|
||||
resolve(canvas.toDataURL('image/webp', 0.7));
|
||||
};
|
||||
img.onerror = () => reject(new Error('Konnte Bild nicht laden'));
|
||||
img.src = reader.result as string;
|
||||
};
|
||||
reader.onerror = () => reject(new Error('Konnte Datei nicht lesen'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Module-scoped registry of "scroll anchors" — DOM elements rendered by
|
||||
* inline `<HikeImage>` components whose viewport positions are sampled on
|
||||
* every scroll frame to compute a continuous trail-position indicator.
|
||||
*
|
||||
* Each anchor carries:
|
||||
* - `element` — the DOM node we read `getBoundingClientRect()` from.
|
||||
* - `trackIdx` — the index in the GPX track points array nearest to the
|
||||
* image's timestamp. The "current trail position" is interpolated between
|
||||
* adjacent anchors' `trackIdx` based on scroll progress.
|
||||
* - `visibleIdx` — index in the visibility-filtered ImagePoints. Used to
|
||||
* drive the focused store (strip highlighting) when the nearest-image
|
||||
* changes.
|
||||
*
|
||||
* The registry is a singleton because there's only ever one hike detail
|
||||
* page open at a time, and a Svelte context would otherwise force every
|
||||
* read site (the page's scroll listener) to be inside the component tree.
|
||||
*/
|
||||
|
||||
export interface ScrollAnchor {
|
||||
element: HTMLElement;
|
||||
trackIdx: number;
|
||||
visibleIdx: number;
|
||||
}
|
||||
|
||||
const anchors = new Set<ScrollAnchor>();
|
||||
|
||||
export function addScrollAnchor(a: ScrollAnchor): () => void {
|
||||
anchors.add(a);
|
||||
return () => {
|
||||
anchors.delete(a);
|
||||
};
|
||||
}
|
||||
|
||||
export function listScrollAnchors(): ScrollAnchor[] {
|
||||
return Array.from(anchors);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Active-stage selection for a multi-day hike detail page.
|
||||
*
|
||||
* `active` is the index into the hike's `stages[]`, or `null` for the
|
||||
* "Alle Etappen" (whole route) view. The stage nav writes it; the map,
|
||||
* elevation profile, metrics row and photo strip read it to scope themselves
|
||||
* to one stage. A shared rune (like hoverStore / focusedImageStore) avoids
|
||||
* prop-drilling through the two map instances.
|
||||
*/
|
||||
|
||||
export const stage = $state<{ active: number | null }>({ active: null });
|
||||
|
||||
export function setActiveStage(index: number | null): void {
|
||||
stage.active = index;
|
||||
}
|
||||
|
||||
export function clearActiveStage(): void {
|
||||
stage.active = null;
|
||||
}
|
||||
@@ -1,434 +0,0 @@
|
||||
<script lang="ts">
|
||||
|
||||
import Cross from '$lib/assets/icons/Cross.svelte'
|
||||
import { toast } from '$lib/js/toast.svelte'
|
||||
import "$lib/css/shake.css"
|
||||
import "$lib/css/icon.css"
|
||||
import { onMount } from 'svelte'
|
||||
|
||||
let {
|
||||
card_data = $bindable(),
|
||||
image_preview_url = $bindable(''),
|
||||
selected_image_file = $bindable<File | null>(null),
|
||||
short_name = ''
|
||||
}: {
|
||||
card_data: any,
|
||||
image_preview_url: string,
|
||||
selected_image_file: File | null,
|
||||
short_name: string
|
||||
} = $props();
|
||||
|
||||
// Constants for validation
|
||||
const ALLOWED_MIME_TYPES = ['image/webp', 'image/jpeg', 'image/jpg', 'image/png'];
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
|
||||
// Handle file selection via onchange event
|
||||
function handleFileSelect(event: Event) {
|
||||
const input = event.currentTarget as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate MIME type
|
||||
if (!ALLOWED_MIME_TYPES.includes(file.type)) {
|
||||
toast.error('Invalid file type. Please upload a JPEG, PNG, or WebP image.');
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
toast.error(`File too large. Maximum size is 5MB. Your file is ${(file.size / 1024 / 1024).toFixed(2)}MB.`);
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up old preview URL if exists
|
||||
if (image_preview_url && image_preview_url.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(image_preview_url);
|
||||
}
|
||||
|
||||
// Create preview and store file
|
||||
image_preview_url = URL.createObjectURL(file);
|
||||
selected_image_file = file;
|
||||
}
|
||||
|
||||
// Check if initial image_preview_url redirects to placeholder
|
||||
onMount(() => {
|
||||
if (image_preview_url && !image_preview_url.startsWith('blob:')) {
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => {
|
||||
// Check if this is the placeholder image (150x150)
|
||||
if (img.naturalWidth === 150 && img.naturalHeight === 150) {
|
||||
image_preview_url = ""
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
image_preview_url = ""
|
||||
};
|
||||
|
||||
img.src = image_preview_url;
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize tags if needed
|
||||
if (!card_data.tags) {
|
||||
card_data.tags = []
|
||||
}
|
||||
|
||||
// Tag management
|
||||
let new_tag = $state("");
|
||||
|
||||
// Reference to file input for clearing
|
||||
let fileInput: HTMLInputElement;
|
||||
|
||||
function remove_selected_images() {
|
||||
if (image_preview_url && image_preview_url.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(image_preview_url);
|
||||
}
|
||||
image_preview_url = "";
|
||||
selected_image_file = null;
|
||||
// Reset the file input
|
||||
if (fileInput) {
|
||||
fileInput.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function add_to_tags() {
|
||||
if (new_tag && !card_data.tags.includes(new_tag)) {
|
||||
card_data.tags = [...card_data.tags, new_tag];
|
||||
}
|
||||
new_tag = "";
|
||||
}
|
||||
|
||||
function remove_from_tags(tag: string) {
|
||||
card_data.tags = card_data.tags.filter((item: string) => item !== tag);
|
||||
}
|
||||
|
||||
function add_on_enter(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
add_to_tags();
|
||||
}
|
||||
}
|
||||
|
||||
function remove_on_enter(event: KeyboardEvent, tag: string) {
|
||||
if (event.key === 'Enter') {
|
||||
card_data.tags = card_data.tags.filter((item: string) => item !== tag);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.card{
|
||||
position: relative;
|
||||
margin-inline: auto;
|
||||
--card-width: 300px;
|
||||
text-decoration: none;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
width: var(--card-width);
|
||||
aspect-ratio: 4/7;
|
||||
border-radius: var(--radius-card);
|
||||
background-size: contain;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: end;
|
||||
transition: var(--transition-normal);
|
||||
background-color: var(--blue);
|
||||
box-shadow: 0em 0em 2em 0.1em rgba(0, 0, 0, 0.3);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.img_label{
|
||||
position :absolute;
|
||||
z-index: 1;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 20px 20px 0 0 ;
|
||||
transition: var(--transition-normal);
|
||||
}
|
||||
.img_label_wrapper:hover{
|
||||
background-color: var(--red);
|
||||
box-shadow: 0 2em 1em 0.5em rgba(0,0,0,0.3);
|
||||
transform:scale(1.02, 1.02);
|
||||
}
|
||||
.img_label_wrapper{
|
||||
position: absolute;
|
||||
height: 50%;
|
||||
width: 100%;
|
||||
top:0;
|
||||
left: 0;
|
||||
border-radius: 20px 20px 0 0;
|
||||
transition: var(--transition-normal);
|
||||
}
|
||||
.img_label_wrapper:hover .delete{
|
||||
opacity: 100%;
|
||||
}
|
||||
.img_label svg{
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
fill: white;
|
||||
transition: var(--transition-normal);
|
||||
}
|
||||
.delete{
|
||||
cursor: pointer;
|
||||
all: unset;
|
||||
position: absolute;
|
||||
top:2rem;
|
||||
left: 2rem;
|
||||
opacity: 0%;
|
||||
z-index: 4;
|
||||
transition: var(--transition-normal);
|
||||
}
|
||||
.delete:hover{
|
||||
transform: scale(1.2, 1.2);
|
||||
}
|
||||
.upload{
|
||||
z-index: 1;
|
||||
}
|
||||
.img_label:hover .upload{
|
||||
transform: scale(1.2, 1.2);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
#img_picker{
|
||||
display: none;
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
position:absolute;
|
||||
}
|
||||
input{
|
||||
all: unset;
|
||||
}
|
||||
input::placeholder{
|
||||
all:unset;
|
||||
}
|
||||
.card .icon{
|
||||
z-index: 3;
|
||||
box-sizing: border-box;
|
||||
text-decoration: unset;
|
||||
text-align:center;
|
||||
width: 2.6rem;
|
||||
aspect-ratio: 1/1;
|
||||
transition: var(--transition-fast);
|
||||
position: absolute;
|
||||
font-size: 1.5rem;
|
||||
top:-0.5em;
|
||||
right:-0.5em;
|
||||
padding: 0.25em;
|
||||
background-color: var(--nord6);
|
||||
border-radius: var(--radius-pill);
|
||||
box-shadow: 0em 0em 2em 0.1em rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.card .icon:hover,
|
||||
.card .icon:focus-visible
|
||||
{
|
||||
box-shadow: 0em 0em 1em 0.2em rgba(0, 0, 0, 0.6);
|
||||
transform:scale(1.2, 1.2)
|
||||
}
|
||||
.card:hover,
|
||||
.card:focus-within{
|
||||
transform: scale(1.02,1.02);
|
||||
box-shadow: 0.2em 0.2em 2em 1em rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.card img{
|
||||
height: 50%;
|
||||
object-fit: cover;
|
||||
border-top-left-radius: inherit;
|
||||
border-top-right-radius: inherit;
|
||||
}
|
||||
.card .title {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
padding-top: 0.5em;
|
||||
height: 50%;
|
||||
width: 100% ;
|
||||
border-bottom-left-radius: inherit;
|
||||
border-bottom-right-radius: inherit;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
.card .name{
|
||||
all: unset;
|
||||
width:100%;
|
||||
font-size: 2em;
|
||||
color: white;
|
||||
padding-inline: 0.5em;
|
||||
padding-block: 0.2em;
|
||||
}
|
||||
.card .name:hover{
|
||||
color:var(--nord0);
|
||||
}
|
||||
.card .description{
|
||||
box-sizing:border-box;
|
||||
border: 2px solid var(--nord5);
|
||||
border-radius: 30px;
|
||||
padding-inline: 1em;
|
||||
padding-block: 0.5em;
|
||||
margin-inline: 1em;
|
||||
margin-top: 0;
|
||||
color: var(--nord4);
|
||||
width: calc(300px - 2em); /*??*/
|
||||
}
|
||||
.card .description:hover{
|
||||
color: var(--nord0);
|
||||
border: 2px solid var(--nord0);
|
||||
}
|
||||
.card .tags{
|
||||
display: flex;
|
||||
flex-wrap: wrap-reverse;
|
||||
overflow: hidden;
|
||||
column-gap: 0.25em;
|
||||
padding-inline: 0.5em;
|
||||
padding-top: 0.25em;
|
||||
margin-bottom:0.5em;
|
||||
flex-grow: 0;
|
||||
}
|
||||
.card .tag{
|
||||
cursor: pointer;
|
||||
text-decoration: unset;
|
||||
background-color: var(--nord4);
|
||||
color: var(--nord0);
|
||||
border-radius: 100px;
|
||||
padding-inline: 1em;
|
||||
line-height: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
transition: var(--transition-fast);
|
||||
box-shadow: 0.2em 0.2em 0.2em 0.05em rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.card .tag:hover,
|
||||
.card .tag:focus-visible,
|
||||
.card .tag:focus-within
|
||||
{
|
||||
transform: scale(1.04, 1.04);
|
||||
background-color: var(--nord8);
|
||||
box-shadow: 0.2em 0.2em 0.2em 0.1em rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.card .title .category{
|
||||
z-index: 2;
|
||||
position: absolute;
|
||||
box-shadow: 0em 0em 1em 0.1em rgba(0, 0, 0, 0.6);
|
||||
text-decoration: none;
|
||||
color: var(--nord6);
|
||||
font-size: 1.5rem;
|
||||
top: -0.8em;
|
||||
left: -0.5em;
|
||||
width: 10rem;
|
||||
background-color: var(--nord0);
|
||||
padding-inline: 1em;
|
||||
border-radius: var(--radius-pill);
|
||||
transition: var(--transition-fast);
|
||||
|
||||
}
|
||||
.card .title .category:hover,
|
||||
.card .title .category:focus-within
|
||||
{
|
||||
box-shadow: -0.2em 0.2em 1em 0.1em rgba(0, 0, 0, 0.6);
|
||||
background-color: var(--nord3);
|
||||
transform: scale(1.05, 1.05)
|
||||
}
|
||||
.card:hover .icon,
|
||||
.card:focus-visible .icon
|
||||
{
|
||||
animation: shake 0.6s
|
||||
}
|
||||
|
||||
@keyframes shake{
|
||||
0%{
|
||||
transform: rotate(0)
|
||||
scale(1,1);
|
||||
}
|
||||
25%{
|
||||
box-shadow: 0em 0em 1em 0.2em rgba(0, 0, 0, 0.6);
|
||||
transform: rotate(30deg)
|
||||
scale(1.2,1.2)
|
||||
;
|
||||
}
|
||||
50%{
|
||||
|
||||
box-shadow: 0em 0em 1em 0.2em rgba(0, 0, 0, 0.6);
|
||||
transform: rotate(-30deg)
|
||||
scale(1.2,1.2);
|
||||
}
|
||||
74%{
|
||||
|
||||
box-shadow: 0em 0em 1em 0.2em rgba(0, 0, 0, 0.6);
|
||||
transform: rotate(30deg)
|
||||
scale(1.2, 1.2);
|
||||
}
|
||||
100%{
|
||||
transform: rotate(0)
|
||||
scale(1,1);
|
||||
}
|
||||
}
|
||||
|
||||
.input_wrapper{
|
||||
position: relative;
|
||||
padding-left: 3rem;
|
||||
padding-left: 40rem;
|
||||
}
|
||||
.input_wrapper > input{
|
||||
margin-left: 1ch;
|
||||
}
|
||||
.input{
|
||||
position:absolute;
|
||||
top: -.1ch;
|
||||
left: 0.6ch;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
.tag_input{
|
||||
width: 12ch;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
<div class=card>
|
||||
|
||||
<input class=icon placeholder=🥫 bind:value={card_data.icon}/>
|
||||
{#if image_preview_url}
|
||||
<!-- svelte-ignore a11y_missing_attribute -->
|
||||
<img src={image_preview_url} class=img_preview width=300px height=300px />
|
||||
{/if}
|
||||
<div class=img_label_wrapper>
|
||||
{#if image_preview_url}
|
||||
<button class=delete onclick={remove_selected_images}>
|
||||
<Cross fill=white style="width:2rem;height:2rem;"></Cross>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
|
||||
<label class=img_label for=img_picker>
|
||||
<svg class="upload over_img" xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 512 512"><path d="M288 109.3V352c0 17.7-14.3 32-32 32s-32-14.3-32-32V109.3l-73.4 73.4c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3l128-128c12.5-12.5 32.8-12.5 45.3 0l128 128c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L288 109.3zM64 352H192c0 35.3 28.7 64 64 64s64-28.7 64-64H448c35.3 0 64 28.7 64 64v32c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V416c0-35.3 28.7-64 64-64zM432 456a24 24 0 1 0 0-48 24 24 0 1 0 0 48z"/></svg>
|
||||
</label>
|
||||
</div>
|
||||
<input type="file" id="img_picker" accept="image/webp,image/jpeg,image/jpg,image/png" onchange={handleFileSelect} bind:this={fileInput}>
|
||||
<div class=title>
|
||||
<input class=category placeholder=Kategorie... bind:value={card_data.category}/>
|
||||
<div>
|
||||
<input class=name placeholder=Name... bind:value={card_data.name}/>
|
||||
<p contenteditable class=description placeholder=Kurzbeschreibung... bind:innerText={card_data.description}></p>
|
||||
</div>
|
||||
<div class=tags>
|
||||
{#each card_data.tags as tag (tag)}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<div class="tag" role="button" tabindex="0" onkeydown={(event) => remove_on_enter(event, tag)} onclick={() => remove_from_tags(tag)} aria-label="Tag {tag} entfernen">{tag}</div>
|
||||
{/each}
|
||||
<div class="tag input_wrapper"><span class=input>+</span><input class="tag_input" type="text" onkeydown={add_on_enter} onfocusout={add_to_tags} size="1" bind:value={new_tag} placeholder=Stichwort...></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import Cross from '$lib/assets/icons/Cross.svelte';
|
||||
import ImageEditor from '$lib/components/recipes/ImageEditor.svelte';
|
||||
import { toast } from '$lib/js/toast.svelte';
|
||||
import { onMount, type Snippet } from 'svelte';
|
||||
|
||||
@@ -53,9 +54,34 @@
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
openEditor(file);
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
// Photo editor (crop / scale / webp quality) state
|
||||
let editorFile = $state<File | null>(null);
|
||||
let editorOpen = $state(false);
|
||||
|
||||
function openEditor(file: File) {
|
||||
editorFile = file;
|
||||
editorOpen = true;
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
editorOpen = false;
|
||||
editorFile = null;
|
||||
if (fileInput) fileInput.value = '';
|
||||
}
|
||||
|
||||
function handleEditorApply(file: File, url: string) {
|
||||
if (image_preview_url?.startsWith('blob:')) URL.revokeObjectURL(image_preview_url);
|
||||
image_preview_url = URL.createObjectURL(file);
|
||||
selected_image_file = file;
|
||||
image_preview_url = url;
|
||||
closeEditor();
|
||||
}
|
||||
|
||||
function editCurrentImage() {
|
||||
if (selected_image_file) openEditor(selected_image_file);
|
||||
}
|
||||
|
||||
function clearSelectedImage() {
|
||||
@@ -129,15 +155,30 @@
|
||||
</div>
|
||||
</button>
|
||||
{#if selected_image_file}
|
||||
<div class="img-controls">
|
||||
<button
|
||||
type="button"
|
||||
class="clear-img"
|
||||
class="img-btn"
|
||||
onclick={editCurrentImage}
|
||||
title="Bild bearbeiten"
|
||||
aria-label="Bild bearbeiten"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" aria-hidden="true">
|
||||
<path
|
||||
d="M362.7 19.3L314.3 67.7 444.3 197.7l48.4-48.4c25-25 25-65.5 0-90.5L453.3 19.3c-25-25-65.5-25-90.5 0zm-71 71L58.6 323.5c-10.4 10.4-18 23.3-22.2 37.4L1 481.2C-1.5 489.7 .8 498.8 7 505s15.3 8.5 23.7 6.1l120.3-35.4c14.1-4.2 27-11.8 37.4-22.2L421.7 220.3 291.7 90.3z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="img-btn danger"
|
||||
onclick={clearSelectedImage}
|
||||
title="Auswahl verwerfen"
|
||||
aria-label="Auswahl verwerfen"
|
||||
>
|
||||
<Cross fill="white" width="1.25rem" height="1.25rem" />
|
||||
<Cross fill="white" width="1.15rem" height="1.15rem" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
@@ -215,6 +256,10 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if editorOpen && editorFile}
|
||||
<ImageEditor file={editorFile} onApply={handleEditorApply} onCancel={closeEditor} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.section {
|
||||
--scale: 0.3;
|
||||
@@ -312,10 +357,18 @@
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.clear-img {
|
||||
/* Edit / remove controls — top-right of the image, offset below the fixed
|
||||
site header (height 3rem, top max(12px, safe-area+4px)) so the nav never
|
||||
obstructs them. */
|
||||
.img-controls {
|
||||
position: absolute;
|
||||
top: calc(1rem + env(safe-area-inset-top, 0px));
|
||||
top: calc(max(12px, env(safe-area-inset-top, 0px) + 4px) + 3rem + 1rem);
|
||||
right: 1rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
z-index: 5;
|
||||
}
|
||||
.img-btn {
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
border: none;
|
||||
width: 2.5rem;
|
||||
@@ -324,17 +377,26 @@
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
z-index: 5;
|
||||
transition:
|
||||
transform 150ms ease,
|
||||
background 150ms ease;
|
||||
backdrop-filter: blur(6px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.clear-img:hover,
|
||||
.clear-img:focus-visible {
|
||||
background: var(--red);
|
||||
.img-btn svg {
|
||||
width: 1.15rem;
|
||||
height: 1.15rem;
|
||||
fill: white;
|
||||
}
|
||||
.img-btn:hover,
|
||||
.img-btn:focus-visible {
|
||||
background: var(--color-primary);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
.img-btn.danger:hover,
|
||||
.img-btn.danger:focus-visible {
|
||||
background: var(--red);
|
||||
}
|
||||
|
||||
.file-input {
|
||||
position: absolute;
|
||||
|
||||
@@ -0,0 +1,797 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
loadBitmap,
|
||||
renderToBlob,
|
||||
fitWithin,
|
||||
blobToFile,
|
||||
formatBytes,
|
||||
type CropRect
|
||||
} from '$lib/js/imageEdit';
|
||||
|
||||
type Props = {
|
||||
file: File;
|
||||
shortName?: string;
|
||||
onApply: (file: File, url: string) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
let { file, shortName = '', onApply, onCancel }: Props = $props();
|
||||
|
||||
const MIN_CROP = 24; // minimum crop edge, source px
|
||||
|
||||
const RATIOS = [
|
||||
{ key: 'free', label: 'Frei' },
|
||||
{ key: 'orig', label: 'Original' },
|
||||
{ key: '1:1', label: '1:1', value: 1 },
|
||||
{ key: '4:3', label: '4:3', value: 4 / 3 },
|
||||
{ key: '3:2', label: '3:2', value: 3 / 2 },
|
||||
{ key: '16:9', label: '16:9', value: 16 / 9 }
|
||||
] as const;
|
||||
|
||||
const RES_PRESETS = [1000, 1500, 2000, 0]; // 0 = Original
|
||||
|
||||
let bitmap = $state<ImageBitmap | null>(null);
|
||||
let imgW = $state(0);
|
||||
let imgH = $state(0);
|
||||
let loadError = $state('');
|
||||
|
||||
let crop = $state<CropRect>({ x: 0, y: 0, w: 0, h: 0 });
|
||||
let ratioMode = $state<string>('free');
|
||||
let maxRes = $state(2000);
|
||||
let quality = $state(92);
|
||||
|
||||
// Live-encode output
|
||||
let outBlob = $state<Blob | null>(null);
|
||||
let outUrl = $state('');
|
||||
let outW = $state(0);
|
||||
let outH = $state(0);
|
||||
let encoding = $state(false);
|
||||
|
||||
// Stage measurement
|
||||
let stageW = $state(0);
|
||||
let stageH = $state(0);
|
||||
let stageCanvas = $state<HTMLCanvasElement | null>(null);
|
||||
|
||||
const activeRatio = $derived.by(() => {
|
||||
const r = RATIOS.find((x) => x.key === ratioMode);
|
||||
if (!r) return null;
|
||||
if (r.key === 'orig') return imgH ? imgW / imgH : null;
|
||||
return 'value' in r ? r.value : null;
|
||||
});
|
||||
|
||||
// Fit the source image into the available stage area (display pixels).
|
||||
const displayScale = $derived.by(() => {
|
||||
if (!imgW || !imgH || !stageW || !stageH) return 1;
|
||||
const availW = Math.max(1, stageW - 24);
|
||||
const availH = Math.max(1, stageH - 24);
|
||||
return Math.min(availW / imgW, availH / imgH);
|
||||
});
|
||||
const dispW = $derived(Math.round(imgW * displayScale));
|
||||
const dispH = $derived(Math.round(imgH * displayScale));
|
||||
|
||||
function clamp(v: number, lo: number, hi: number) {
|
||||
return Math.max(lo, Math.min(hi, v));
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const bm = await loadBitmap(file);
|
||||
if (cancelled) {
|
||||
bm.close?.();
|
||||
return;
|
||||
}
|
||||
bitmap = bm;
|
||||
imgW = bm.width;
|
||||
imgH = bm.height;
|
||||
crop = { x: 0, y: 0, w: bm.width, h: bm.height };
|
||||
} catch {
|
||||
loadError = 'Bild konnte nicht geladen werden.';
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
// Draw the source onto the display canvas whenever it or the layout changes.
|
||||
$effect(() => {
|
||||
const cv = stageCanvas;
|
||||
const bm = bitmap;
|
||||
const w = dispW;
|
||||
const h = dispH;
|
||||
if (!cv || !bm || w <= 0 || h <= 0) return;
|
||||
cv.width = w;
|
||||
cv.height = h;
|
||||
const ctx = cv.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.drawImage(bm, 0, 0, imgW, imgH, 0, 0, w, h);
|
||||
});
|
||||
|
||||
// Debounced live encode — runs whenever crop / resolution / quality change.
|
||||
let encodeToken = 0;
|
||||
$effect(() => {
|
||||
const bm = bitmap;
|
||||
if (!bm) return;
|
||||
const c = { ...crop };
|
||||
const mr = maxRes;
|
||||
const q = quality;
|
||||
const token = ++encodeToken;
|
||||
encoding = true;
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const blob = await renderToBlob(bm, c, mr, q);
|
||||
if (token !== encodeToken) return;
|
||||
const size = fitWithin(c.w, c.h, mr);
|
||||
if (outUrl) URL.revokeObjectURL(outUrl);
|
||||
outBlob = blob;
|
||||
outUrl = URL.createObjectURL(blob);
|
||||
outW = size.w;
|
||||
outH = size.h;
|
||||
} catch {
|
||||
/* transient encode failure — next change retries */
|
||||
} finally {
|
||||
if (token === encodeToken) encoding = false;
|
||||
}
|
||||
}, 200);
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
return () => {
|
||||
if (outUrl) URL.revokeObjectURL(outUrl);
|
||||
bitmap?.close?.();
|
||||
};
|
||||
});
|
||||
|
||||
// --- Crop drag handling ---
|
||||
type Drag = { handle: string; hx: number; hy: number; px: number; py: number; start: CropRect };
|
||||
let drag: Drag | null = null;
|
||||
|
||||
function startDrag(e: PointerEvent, handle: string, hx: number, hy: number) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
(e.currentTarget as Element).setPointerCapture(e.pointerId);
|
||||
drag = { handle, hx, hy, px: e.clientX, py: e.clientY, start: { ...crop } };
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!drag || displayScale === 0) return;
|
||||
const ddx = (e.clientX - drag.px) / displayScale;
|
||||
const ddy = (e.clientY - drag.py) / displayScale;
|
||||
const s = drag.start;
|
||||
|
||||
if (drag.handle === 'move') {
|
||||
crop = {
|
||||
x: clamp(s.x + ddx, 0, imgW - s.w),
|
||||
y: clamp(s.y + ddy, 0, imgH - s.h),
|
||||
w: s.w,
|
||||
h: s.h
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
let left = s.x;
|
||||
let top = s.y;
|
||||
let right = s.x + s.w;
|
||||
let bottom = s.y + s.h;
|
||||
if (drag.hx === 1) right = s.x + s.w + ddx;
|
||||
else if (drag.hx === -1) left = s.x + ddx;
|
||||
if (drag.hy === 1) bottom = s.y + s.h + ddy;
|
||||
else if (drag.hy === -1) top = s.y + ddy;
|
||||
|
||||
const r = activeRatio;
|
||||
if (r) {
|
||||
if (drag.hx !== 0 && drag.hy !== 0) {
|
||||
const nw = Math.max(MIN_CROP, right - left);
|
||||
const nh = nw / r;
|
||||
if (drag.hy === 1) bottom = top + nh;
|
||||
else top = bottom - nh;
|
||||
} else if (drag.hx !== 0) {
|
||||
const cy = s.y + s.h / 2;
|
||||
const nh = Math.max(MIN_CROP, right - left) / r;
|
||||
top = cy - nh / 2;
|
||||
bottom = cy + nh / 2;
|
||||
} else if (drag.hy !== 0) {
|
||||
const cx = s.x + s.w / 2;
|
||||
const nw = Math.max(MIN_CROP, bottom - top) * r;
|
||||
left = cx - nw / 2;
|
||||
right = cx + nw / 2;
|
||||
}
|
||||
}
|
||||
|
||||
left = Math.max(0, left);
|
||||
top = Math.max(0, top);
|
||||
right = Math.min(imgW, right);
|
||||
bottom = Math.min(imgH, bottom);
|
||||
if (right - left < MIN_CROP) {
|
||||
if (drag.hx === -1) left = right - MIN_CROP;
|
||||
else right = left + MIN_CROP;
|
||||
}
|
||||
if (bottom - top < MIN_CROP) {
|
||||
if (drag.hy === -1) top = bottom - MIN_CROP;
|
||||
else bottom = top + MIN_CROP;
|
||||
}
|
||||
left = Math.max(0, left);
|
||||
top = Math.max(0, top);
|
||||
right = Math.min(imgW, right);
|
||||
bottom = Math.min(imgH, bottom);
|
||||
|
||||
crop = { x: left, y: top, w: right - left, h: bottom - top };
|
||||
}
|
||||
|
||||
function endDrag() {
|
||||
// Pointer capture is released implicitly on pointerup.
|
||||
drag = null;
|
||||
}
|
||||
|
||||
function selectRatio(key: string) {
|
||||
ratioMode = key;
|
||||
const r = RATIOS.find((x) => x.key === key);
|
||||
const value = r && r.key === 'orig' ? imgW / imgH : r && 'value' in r ? r.value : null;
|
||||
if (!value) return; // 'free' keeps the current crop
|
||||
// Fit a centred rect of this ratio inside the current crop.
|
||||
const cx = crop.x + crop.w / 2;
|
||||
const cy = crop.y + crop.h / 2;
|
||||
let nw = crop.w;
|
||||
let nh = nw / value;
|
||||
if (nh > crop.h) {
|
||||
nh = crop.h;
|
||||
nw = nh * value;
|
||||
}
|
||||
nw = Math.min(nw, imgW);
|
||||
nh = Math.min(nh, imgH);
|
||||
crop = {
|
||||
x: clamp(cx - nw / 2, 0, imgW - nw),
|
||||
y: clamp(cy - nh / 2, 0, imgH - nh),
|
||||
w: nw,
|
||||
h: nh
|
||||
};
|
||||
}
|
||||
|
||||
function resetCrop() {
|
||||
ratioMode = 'free';
|
||||
crop = { x: 0, y: 0, w: imgW, h: imgH };
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
}
|
||||
|
||||
function apply() {
|
||||
if (!outBlob || !outUrl) return;
|
||||
const url = outUrl;
|
||||
const f = blobToFile(outBlob, shortName);
|
||||
outUrl = ''; // hand the object URL off to the caller; don't revoke it
|
||||
onApply(f, url);
|
||||
}
|
||||
|
||||
const handles = [
|
||||
{ key: 'nw', hx: -1, hy: -1 },
|
||||
{ key: 'n', hx: 0, hy: -1 },
|
||||
{ key: 'ne', hx: 1, hy: -1 },
|
||||
{ key: 'e', hx: 1, hy: 0 },
|
||||
{ key: 'se', hx: 1, hy: 1 },
|
||||
{ key: 's', hx: 0, hy: 1 },
|
||||
{ key: 'sw', hx: -1, hy: 1 },
|
||||
{ key: 'w', hx: -1, hy: 0 }
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
<div
|
||||
class="backdrop"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Bild bearbeiten"
|
||||
tabindex="-1"
|
||||
>
|
||||
<button type="button" class="scrim" aria-label="Schliessen" onclick={onCancel}></button>
|
||||
|
||||
<div class="panel">
|
||||
<header class="panel-head">
|
||||
<h2>Bild bearbeiten</h2>
|
||||
<button type="button" class="ghost" onclick={onCancel} aria-label="Abbrechen">✕</button>
|
||||
</header>
|
||||
|
||||
<div class="body">
|
||||
<!-- Stage -->
|
||||
<div class="stage" bind:clientWidth={stageW} bind:clientHeight={stageH}>
|
||||
{#if loadError}
|
||||
<p class="stage-msg">{loadError}</p>
|
||||
{:else if !bitmap}
|
||||
<p class="stage-msg">Lade Bild…</p>
|
||||
{:else}
|
||||
<div class="frame" style:width="{dispW}px" style:height="{dispH}px">
|
||||
<canvas bind:this={stageCanvas}></canvas>
|
||||
<div
|
||||
class="crop"
|
||||
style:left="{crop.x * displayScale}px"
|
||||
style:top="{crop.y * displayScale}px"
|
||||
style:width="{crop.w * displayScale}px"
|
||||
style:height="{crop.h * displayScale}px"
|
||||
role="application"
|
||||
aria-label="Zuschneidebereich verschieben"
|
||||
onpointerdown={(e) => startDrag(e, 'move', 0, 0)}
|
||||
onpointermove={onPointerMove}
|
||||
onpointerup={endDrag}
|
||||
onpointercancel={endDrag}
|
||||
>
|
||||
<span class="third v1"></span>
|
||||
<span class="third v2"></span>
|
||||
<span class="third h1"></span>
|
||||
<span class="third h2"></span>
|
||||
{#each handles as h (h.key)}
|
||||
<button
|
||||
type="button"
|
||||
class="handle h-{h.key}"
|
||||
aria-label="Ziehpunkt {h.key}"
|
||||
onpointerdown={(e) => startDrag(e, h.key, h.hx, h.hy)}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="rail">
|
||||
<div class="preview">
|
||||
<div class="preview-img" class:busy={encoding}>
|
||||
{#if outUrl}
|
||||
<!-- svelte-ignore a11y_missing_attribute -->
|
||||
<img src={outUrl} />
|
||||
{/if}
|
||||
</div>
|
||||
<dl class="stats">
|
||||
<div><dt>Auflösung</dt><dd>{outW || '—'} × {outH || '—'}</dd></div>
|
||||
<div>
|
||||
<dt>Dateigrösse</dt>
|
||||
<dd class="size">{outBlob ? formatBytes(outBlob.size) : '—'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<fieldset class="group">
|
||||
<legend>Seitenverhältnis</legend>
|
||||
<div class="chips">
|
||||
{#each RATIOS as r (r.key)}
|
||||
<button
|
||||
type="button"
|
||||
class="chip"
|
||||
class:active={ratioMode === r.key}
|
||||
onclick={() => selectRatio(r.key)}>{r.label}</button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="group">
|
||||
<legend>Max. Auflösung</legend>
|
||||
<div class="chips">
|
||||
{#each RES_PRESETS as p (p)}
|
||||
<button
|
||||
type="button"
|
||||
class="chip"
|
||||
class:active={maxRes === p}
|
||||
onclick={() => (maxRes = p)}>{p === 0 ? 'Original' : p}</button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
<label class="custom">
|
||||
<span>Eigene Kante</span>
|
||||
<input type="number" min="0" step="50" bind:value={maxRes} />
|
||||
<span class="unit">px</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="group">
|
||||
<legend>WebP-Qualität</legend>
|
||||
<div class="quality">
|
||||
<input type="range" min="1" max="100" step="1" bind:value={quality} />
|
||||
<output>{quality}</output>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<button type="button" class="reset" onclick={resetCrop}>Zuschnitt zurücksetzen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="panel-foot">
|
||||
<button type="button" class="btn ghost-btn" onclick={onCancel}>Abbrechen</button>
|
||||
<button type="button" class="btn primary" disabled={!outBlob} onclick={apply}>
|
||||
Übernehmen
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: clamp(0px, 2vw, 1.5rem);
|
||||
}
|
||||
.scrim {
|
||||
all: unset;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(10, 14, 20, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
cursor: pointer;
|
||||
}
|
||||
.panel {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(1100px, 100%);
|
||||
height: min(760px, 100%);
|
||||
max-height: 100%;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-card);
|
||||
box-shadow: var(--shadow-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.panel-head,
|
||||
.panel-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.85rem 1.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.panel-head {
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.panel-head h2 {
|
||||
margin: 0;
|
||||
font-size: var(--text-lg, 1.2rem);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.panel-foot {
|
||||
justify-content: flex-end;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.ghost {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
padding: 0.35rem;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.ghost:hover {
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
.body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 320px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Stage */
|
||||
.stage {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 0;
|
||||
background:
|
||||
repeating-conic-gradient(var(--color-bg-secondary) 0% 25%, transparent 0% 50%) 50% / 24px 24px;
|
||||
background-color: var(--color-bg-tertiary);
|
||||
overflow: hidden;
|
||||
}
|
||||
.stage-msg {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.frame {
|
||||
position: relative;
|
||||
touch-action: none;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
.frame canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.crop {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid rgba(255, 255, 255, 0.95);
|
||||
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5);
|
||||
cursor: move;
|
||||
touch-action: none;
|
||||
}
|
||||
.third {
|
||||
position: absolute;
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
pointer-events: none;
|
||||
}
|
||||
.third.v1,
|
||||
.third.v2 {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
}
|
||||
.third.v1 {
|
||||
left: 33.33%;
|
||||
}
|
||||
.third.v2 {
|
||||
left: 66.66%;
|
||||
}
|
||||
.third.h1,
|
||||
.third.h2 {
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
}
|
||||
.third.h1 {
|
||||
top: 33.33%;
|
||||
}
|
||||
.third.h2 {
|
||||
top: 66.66%;
|
||||
}
|
||||
.handle {
|
||||
all: unset;
|
||||
position: absolute;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
box-sizing: border-box;
|
||||
background: var(--color-primary);
|
||||
border: 2px solid white;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
}
|
||||
.h-nw {
|
||||
top: -7px;
|
||||
left: -7px;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
.h-ne {
|
||||
top: -7px;
|
||||
right: -7px;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
.h-se {
|
||||
bottom: -7px;
|
||||
right: -7px;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
.h-sw {
|
||||
bottom: -7px;
|
||||
left: -7px;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
.h-n {
|
||||
top: -7px;
|
||||
left: calc(50% - 7px);
|
||||
cursor: ns-resize;
|
||||
}
|
||||
.h-s {
|
||||
bottom: -7px;
|
||||
left: calc(50% - 7px);
|
||||
cursor: ns-resize;
|
||||
}
|
||||
.h-e {
|
||||
right: -7px;
|
||||
top: calc(50% - 7px);
|
||||
cursor: ew-resize;
|
||||
}
|
||||
.h-w {
|
||||
left: -7px;
|
||||
top: calc(50% - 7px);
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
/* Rail */
|
||||
.rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.1rem;
|
||||
padding: 1.1rem;
|
||||
overflow-y: auto;
|
||||
border-left: 1px solid var(--color-border);
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
.preview {
|
||||
display: flex;
|
||||
gap: 0.85rem;
|
||||
align-items: center;
|
||||
}
|
||||
.preview-img {
|
||||
flex-shrink: 0;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
.preview-img.busy {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.preview-img img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.stats {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.stats dt {
|
||||
font-size: var(--text-sm, 0.8rem);
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.stats dd {
|
||||
margin: 0;
|
||||
font-size: var(--text-md, 1rem);
|
||||
color: var(--color-text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.stats dd.size {
|
||||
font-weight: 700;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.group {
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.group legend {
|
||||
padding: 0;
|
||||
font-size: var(--text-sm, 0.8rem);
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.chip {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm, 0.85rem);
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
.chip:hover {
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.chip.active {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-text-on-primary);
|
||||
}
|
||||
.custom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.6rem;
|
||||
font-size: var(--text-sm, 0.85rem);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.custom input {
|
||||
width: 6ch;
|
||||
padding: 0.3rem 0.5rem;
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-primary);
|
||||
font: inherit;
|
||||
}
|
||||
.custom .unit {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
.quality {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.quality input[type='range'] {
|
||||
flex: 1;
|
||||
accent-color: var(--color-primary);
|
||||
}
|
||||
.quality output {
|
||||
min-width: 2.5ch;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.reset {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
padding: 0.5rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm, 0.85rem);
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
.reset:hover {
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.btn {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
padding: 0.6rem 1.4rem;
|
||||
border-radius: var(--radius-pill);
|
||||
font-weight: 600;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
.ghost-btn {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.ghost-btn:hover {
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.primary {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-on-primary);
|
||||
}
|
||||
.primary:hover {
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
.primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.panel {
|
||||
height: 100%;
|
||||
border-radius: 0;
|
||||
}
|
||||
.body {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
}
|
||||
.rail {
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--color-border);
|
||||
max-height: 45dvh;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,81 +0,0 @@
|
||||
<script lang="ts">
|
||||
import CardAdd from '$lib/components/recipes/CardAdd.svelte';
|
||||
import MediaScroller from '$lib/components/recipes/MediaScroller.svelte';
|
||||
import Search from '$lib/components/recipes/Search.svelte';
|
||||
import SeasonSelect from '$lib/components/recipes/SeasonSelect.svelte';
|
||||
import CreateIngredientList from '$lib/components/recipes/CreateIngredientList.svelte';
|
||||
import CreateStepList from '$lib/components/recipes/CreateStepList.svelte';
|
||||
|
||||
let {
|
||||
card_data = $bindable({}),
|
||||
seasonRanges = $bindable([]),
|
||||
ingredients = $bindable([]),
|
||||
instructions = $bindable([])
|
||||
}: {
|
||||
card_data?: any,
|
||||
seasonRanges?: any[],
|
||||
ingredients?: any[],
|
||||
instructions?: any[]
|
||||
} = $props();
|
||||
|
||||
let short_name = $state('');
|
||||
let password = $state('');
|
||||
let datecreated = $state(new Date());
|
||||
let datemodified = $state(datecreated);
|
||||
let result = $state('');
|
||||
let image_preview_url = $state('');
|
||||
let selected_image_file = $state<File | null>(null);
|
||||
|
||||
async function doPost () {
|
||||
const res = await fetch('/api/add', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
recipe: {
|
||||
seasonRanges: seasonRanges,
|
||||
...card_data,
|
||||
images: [{
|
||||
mediapath: short_name + '.webp',
|
||||
alt: "",
|
||||
caption: ""
|
||||
}],
|
||||
short_name,
|
||||
datecreated,
|
||||
datemodified,
|
||||
instructions,
|
||||
ingredients,
|
||||
},
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
bearer: password,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const json = await res.json()
|
||||
result = JSON.stringify(json)
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
input.temp{
|
||||
all: unset;
|
||||
display: block;
|
||||
margin: 1rem auto;
|
||||
padding: 0.2em 1em;
|
||||
border-radius: var(--radius-pill);
|
||||
background-color: var(--nord4);
|
||||
|
||||
}
|
||||
</style>
|
||||
|
||||
<CardAdd bind:card_data={card_data} bind:image_preview_url={image_preview_url} bind:selected_image_file={selected_image_file} {short_name}></CardAdd>
|
||||
|
||||
<input class=temp bind:value={short_name} placeholder="Kurzname"/>
|
||||
|
||||
<SeasonSelect bind:ranges={seasonRanges} />
|
||||
<button onclick={() => console.log(seasonRanges)}>PRINTOUT season</button>
|
||||
|
||||
<h2>Zutaten</h2>
|
||||
<CreateIngredientList bind:ingredients={ingredients}></CreateIngredientList>
|
||||
<h2>Zubereitung</h2>
|
||||
<CreateStepList bind:instructions={instructions} add_info={{}}></CreateStepList>
|
||||
<input class=temp type="password" placeholder=Passwort bind:value={password}>
|
||||
@@ -4,25 +4,50 @@
|
||||
import { getStickerById } from '$lib/utils/stickers';
|
||||
import {
|
||||
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
||||
eachDayOfInterval, isSameMonth, isToday, format, addMonths, subMonths
|
||||
eachDayOfInterval, isSameMonth, isToday, isWeekend, format, addMonths, subMonths
|
||||
} from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
|
||||
let { completions = [], currentUser = '' } = $props();
|
||||
|
||||
let viewDate = $state(new Date());
|
||||
// who-did-what colours (the household)
|
||||
const PERSON_COLOR = /** @type {Record<string, string>} */ ({
|
||||
anna: 'var(--nord15)',
|
||||
alexander: 'var(--nord10)'
|
||||
});
|
||||
const personColor = /** @param {string} who */ (who) => PERSON_COLOR[who?.toLowerCase()] || 'var(--nord12)';
|
||||
|
||||
let filteredCompletions = $derived(
|
||||
completions
|
||||
.filter((/** @type {any} */ c) => c.stickerId)
|
||||
.filter((/** @type {any} */ c) => !currentUser || c.completedBy === currentUser)
|
||||
);
|
||||
// every sticker drop, both members
|
||||
let drops = $derived(completions.filter((/** @type {any} */ c) => c.stickerId));
|
||||
|
||||
// Build a map: "YYYY-MM-DD" -> sticker ids[]
|
||||
let stickersByDate = $derived.by(() => {
|
||||
// Who's visible on the grid. Default: just the current user; others appear
|
||||
// only when you tap their name in the tally.
|
||||
let allPeople = $derived([...new Set(drops.map((/** @type {any} */ c) => c.completedBy))]);
|
||||
let defaultShown = $derived(new Set(currentUser ? [currentUser] : allPeople));
|
||||
/** @type {Set<string> | null} */
|
||||
let manual = $state(null);
|
||||
let shown = $derived(manual ?? defaultShown);
|
||||
/** @param {string} who */
|
||||
function toggle(who) {
|
||||
const next = new Set(shown);
|
||||
if (next.has(who)) next.delete(who);
|
||||
else next.add(who);
|
||||
manual = next;
|
||||
}
|
||||
|
||||
/** @param {string} s */
|
||||
function hash(s) {
|
||||
let h = 0;
|
||||
for (let i = 0; i < (s || '').length; i++) h = (Math.imul(h, 31) + s.charCodeAt(i)) | 0;
|
||||
return Math.abs(h);
|
||||
}
|
||||
|
||||
// "YYYY-MM-DD" -> completions[]
|
||||
let byDate = $derived.by(() => {
|
||||
/** @type {Map<string, any[]>} */
|
||||
const map = new Map();
|
||||
for (const c of filteredCompletions) {
|
||||
for (const c of drops) {
|
||||
if (!shown.has(c.completedBy)) continue;
|
||||
const key = format(new Date(c.completedAt), 'yyyy-MM-dd');
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)?.push(c);
|
||||
@@ -31,59 +56,89 @@
|
||||
});
|
||||
|
||||
let calendarDays = $derived.by(() => {
|
||||
const monthStart = startOfMonth(viewDate);
|
||||
const monthEnd = endOfMonth(viewDate);
|
||||
const calStart = startOfWeek(monthStart, { locale: de });
|
||||
const calEnd = endOfWeek(monthEnd, { locale: de });
|
||||
const calStart = startOfWeek(startOfMonth(viewDate), { locale: de });
|
||||
const calEnd = endOfWeek(endOfMonth(viewDate), { locale: de });
|
||||
return eachDayOfInterval({ start: calStart, end: calEnd });
|
||||
});
|
||||
|
||||
let viewDate = $state(new Date());
|
||||
let monthLabel = $derived(format(viewDate, 'MMMM yyyy', { locale: de }));
|
||||
|
||||
const weekdays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||||
// per-person tally for the visible month
|
||||
let tally = $derived.by(() => {
|
||||
/** @type {Map<string, number>} */
|
||||
const m = new Map();
|
||||
for (const c of drops) {
|
||||
if (!isSameMonth(new Date(c.completedAt), viewDate)) continue;
|
||||
m.set(c.completedBy, (m.get(c.completedBy) || 0) + 1);
|
||||
}
|
||||
return [...m.entries()].sort((a, b) => b[1] - a[1]);
|
||||
});
|
||||
|
||||
const weekdays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||||
function prevMonth() { viewDate = subMonths(viewDate, 1); }
|
||||
function nextMonth() { viewDate = addMonths(viewDate, 1); }
|
||||
</script>
|
||||
|
||||
<div class="cal-container">
|
||||
<div class="cal-page">
|
||||
<span class="tape tape-l" aria-hidden="true"></span>
|
||||
<span class="tape tape-r" aria-hidden="true"></span>
|
||||
|
||||
<div class="cal-header">
|
||||
<button class="cal-nav" onclick={prevMonth}><ChevronLeft size={18} /></button>
|
||||
<button class="cal-nav" onclick={prevMonth} aria-label="Voriger Monat"><ChevronLeft size={18} /></button>
|
||||
<span class="cal-month">{monthLabel}</span>
|
||||
<button class="cal-nav" onclick={nextMonth}><ChevronRight size={18} /></button>
|
||||
<button class="cal-nav" onclick={nextMonth} aria-label="Nächster Monat"><ChevronRight size={18} /></button>
|
||||
</div>
|
||||
|
||||
{#if tally.length > 0}
|
||||
<div class="tally">
|
||||
{#each tally as [who, n] (who)}
|
||||
<button
|
||||
type="button"
|
||||
class="tally-chip"
|
||||
class:active={shown.has(who)}
|
||||
class:me={who === currentUser}
|
||||
style="--pc: {personColor(who)}"
|
||||
title="{shown.has(who) ? 'Ausblenden' : 'Einblenden'}"
|
||||
aria-pressed={shown.has(who)}
|
||||
onclick={() => toggle(who)}
|
||||
>
|
||||
<span class="dot"></span>{who}<strong>{n}</strong>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="cal-grid">
|
||||
{#each weekdays as day}
|
||||
{#each weekdays as day (day)}
|
||||
<div class="cal-weekday">{day}</div>
|
||||
{/each}
|
||||
|
||||
{#each calendarDays as day}
|
||||
{#each calendarDays as day (day.toISOString())}
|
||||
{@const key = format(day, 'yyyy-MM-dd')}
|
||||
{@const dayStickers = stickersByDate.get(key) || []}
|
||||
{@const dayDrops = byDate.get(key) || []}
|
||||
{@const inMonth = isSameMonth(day, viewDate)}
|
||||
<div
|
||||
class="cal-day"
|
||||
class:outside={!inMonth}
|
||||
class:weekend={isWeekend(day)}
|
||||
class:today={isToday(day)}
|
||||
class:has-stickers={dayStickers.length > 0}
|
||||
>
|
||||
<span class="cal-day-num">{format(day, 'd')}</span>
|
||||
{#if dayStickers.length > 0}
|
||||
<div class="cal-stickers">
|
||||
{#each dayStickers.slice(0, 6) as completion}
|
||||
{@const sticker = getStickerById(completion.stickerId)}
|
||||
{#if dayDrops.length > 0}
|
||||
<div class="stuck">
|
||||
{#each dayDrops.slice(0, 4) as c (c._id)}
|
||||
{@const sticker = getStickerById(c.stickerId)}
|
||||
{#if sticker}
|
||||
<img
|
||||
class="cal-sticker-img"
|
||||
src="/stickers/{sticker.image}"
|
||||
alt={sticker.name}
|
||||
title="{sticker.name} — {completion.taskTitle}"
|
||||
/>
|
||||
{@const tilt = (hash(c._id) % 13) - 6}
|
||||
<span class="cat" style="--tilt: {tilt}deg; --pc: {personColor(c.completedBy)}">
|
||||
<img src="/stickers/{sticker.image}" alt={sticker.name} title="{sticker.name} — {c.taskTitle} ({c.completedBy})" loading="lazy" />
|
||||
<span class="who-dot"></span>
|
||||
</span>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if dayStickers.length > 6}
|
||||
<span class="cal-more">+{dayStickers.length - 6}</span>
|
||||
{#if dayDrops.length > 4}
|
||||
<span class="more">+{dayDrops.length - 4}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -93,27 +148,48 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.cal-container {
|
||||
background: var(--color-bg-primary, white);
|
||||
border: 1px solid var(--color-border, #e8e4dd);
|
||||
border-radius: 14px;
|
||||
padding: 1rem;
|
||||
/* warm paper page (matches the sticker album) — stays cream in both themes */
|
||||
.cal-page {
|
||||
position: relative;
|
||||
margin-bottom: 2rem;
|
||||
padding: 1.25rem 1rem 1.4rem;
|
||||
border-radius: var(--radius-lg);
|
||||
background-color: #f3ecd9;
|
||||
background-image: radial-gradient(rgba(120, 100, 70, 0.16) 1px, transparent 1.4px);
|
||||
background-size: 18px 18px;
|
||||
border: 1px solid #e4d9be;
|
||||
box-shadow: var(--shadow-sm), inset 0 0 50px rgba(150, 130, 90, 0.08);
|
||||
}
|
||||
:global(:root[data-theme='dark']) .cal-page,
|
||||
:global(:root:not([data-theme='light'])) .cal-page { background-color: #ece3cb; }
|
||||
|
||||
/* washi tape holding the page up */
|
||||
.tape {
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
width: 78px;
|
||||
height: 24px;
|
||||
background: repeating-linear-gradient(45deg, rgba(136, 192, 208, 0.45) 0 7px, rgba(136, 192, 208, 0.28) 7px 14px);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.tape-l { left: 26px; transform: rotate(-5deg); }
|
||||
.tape-r { right: 26px; transform: rotate(4deg); background: repeating-linear-gradient(45deg, rgba(235, 203, 139, 0.5) 0 7px, rgba(235, 203, 139, 0.3) 7px 14px); }
|
||||
|
||||
.cal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.cal-month {
|
||||
font-size: 1rem;
|
||||
font-family: 'Fredoka', Helvetica, sans-serif;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
text-transform: capitalize;
|
||||
min-width: 160px;
|
||||
min-width: 180px;
|
||||
text-align: center;
|
||||
color: #5a4a2c;
|
||||
}
|
||||
.cal-nav {
|
||||
display: flex;
|
||||
@@ -123,132 +199,135 @@
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary, #888);
|
||||
color: #8a7747;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 120ms;
|
||||
}
|
||||
.cal-nav:hover {
|
||||
background: var(--color-bg-secondary, #f0ede6);
|
||||
color: var(--color-text-primary, #333);
|
||||
.cal-nav:hover { background: rgba(138, 119, 71, 0.14); color: #5a4a2c; }
|
||||
|
||||
.tally {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
.tally-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.32rem;
|
||||
padding: 0.18rem 0.6rem;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 600;
|
||||
color: #5a4a2c;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
border: 1px solid color-mix(in srgb, var(--pc) 30%, transparent);
|
||||
border-radius: var(--radius-pill);
|
||||
text-transform: capitalize;
|
||||
cursor: pointer;
|
||||
opacity: 0.5;
|
||||
transition: opacity 120ms, background 120ms, border-color 120ms, transform 120ms;
|
||||
}
|
||||
.tally-chip:hover { opacity: 0.85; transform: translateY(-1px); }
|
||||
.tally-chip.active {
|
||||
opacity: 1;
|
||||
background: color-mix(in srgb, var(--pc) 16%, rgba(255, 255, 255, 0.6));
|
||||
border-color: color-mix(in srgb, var(--pc) 55%, transparent);
|
||||
}
|
||||
.tally-chip .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--pc); }
|
||||
.tally-chip strong { font-family: 'Fredoka', Helvetica, sans-serif; color: var(--pc); }
|
||||
|
||||
.cal-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 1px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.cal-weekday {
|
||||
text-align: center;
|
||||
font-size: 0.68rem;
|
||||
font-size: 0.66rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-secondary, #999);
|
||||
padding: 0.3rem 0;
|
||||
color: #9a865a;
|
||||
padding: 0.2rem 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.cal-day {
|
||||
position: relative;
|
||||
min-height: 80px;
|
||||
min-height: 78px;
|
||||
padding: 0.3rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
transition: background 120ms;
|
||||
}
|
||||
.cal-day.outside {
|
||||
opacity: 0.25;
|
||||
}
|
||||
.cal-day.today {
|
||||
background: rgba(94, 129, 172, 0.08);
|
||||
border-color: rgba(94, 129, 172, 0.2);
|
||||
border: 1px dashed transparent;
|
||||
}
|
||||
.cal-day.weekend { background: rgba(150, 130, 90, 0.07); }
|
||||
.cal-day.outside { opacity: 0.3; }
|
||||
.cal-day.today { border-color: var(--nord10); background: rgba(94, 129, 172, 0.1); }
|
||||
.cal-day.today .cal-day-num {
|
||||
background: var(--nord10);
|
||||
color: white;
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.cal-day.has-stickers {
|
||||
background: rgba(163, 190, 140, 0.06);
|
||||
}
|
||||
|
||||
.cal-day-num {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary, #888);
|
||||
font-weight: 700;
|
||||
color: #8a7747;
|
||||
line-height: 1;
|
||||
display: block;
|
||||
margin-bottom: 0.2rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.cal-stickers {
|
||||
.stuck {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 3px;
|
||||
gap: 3px 2px;
|
||||
align-items: center;
|
||||
}
|
||||
.cal-sticker-img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
object-fit: contain;
|
||||
transition: transform 150ms;
|
||||
/* a cat sticker "stuck" on the date — die-cut white edge + hand tilt */
|
||||
.cat {
|
||||
position: relative;
|
||||
transform: rotate(var(--tilt));
|
||||
transition: transform 150ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
cursor: default;
|
||||
}
|
||||
.cal-sticker-img:hover {
|
||||
transform: scale(2);
|
||||
z-index: 10;
|
||||
position: relative;
|
||||
filter: drop-shadow(0 2px 6px rgba(0,0,0,0.2));
|
||||
.cat img {
|
||||
display: block;
|
||||
width: 27px;
|
||||
height: 27px;
|
||||
object-fit: contain;
|
||||
filter:
|
||||
drop-shadow(1px 0 0 #fff) drop-shadow(-1px 0 0 #fff)
|
||||
drop-shadow(0 1px 0 #fff) drop-shadow(0 -1px 0 #fff)
|
||||
drop-shadow(0 2px 2px rgba(0, 0, 0, 0.22));
|
||||
}
|
||||
.cal-more {
|
||||
.cat:hover { transform: rotate(0deg) scale(1.9); z-index: 10; }
|
||||
.who-dot {
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
right: -1px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--pc);
|
||||
border: 1.5px solid #f3ecd9;
|
||||
}
|
||||
.more {
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-secondary, #aaa);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:global(:root:not([data-theme="light"])) .cal-container {
|
||||
background: var(--nord1);
|
||||
border-color: var(--nord2);
|
||||
}
|
||||
:global(:root:not([data-theme="light"])) .cal-nav:hover {
|
||||
background: var(--nord2);
|
||||
}
|
||||
:global(:root:not([data-theme="light"])) .cal-day.today {
|
||||
background: rgba(94, 129, 172, 0.12);
|
||||
}
|
||||
:global(:root:not([data-theme="light"])) .cal-day.has-stickers {
|
||||
background: rgba(163, 190, 140, 0.08);
|
||||
}
|
||||
}
|
||||
:global(:root[data-theme="dark"]) .cal-container {
|
||||
background: var(--nord1);
|
||||
border-color: var(--nord2);
|
||||
}
|
||||
:global(:root[data-theme="dark"]) .cal-nav:hover {
|
||||
background: var(--nord2);
|
||||
}
|
||||
:global(:root[data-theme="dark"]) .cal-day.today {
|
||||
background: rgba(94, 129, 172, 0.12);
|
||||
}
|
||||
:global(:root[data-theme="dark"]) .cal-day.has-stickers {
|
||||
background: rgba(163, 190, 140, 0.08);
|
||||
color: #9a865a;
|
||||
align-self: center;
|
||||
padding-left: 1px;
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.cal-day { min-height: 56px; padding: 0.2rem; }
|
||||
.cal-sticker-img { width: 22px; height: 22px; }
|
||||
.cal-stickers { gap: 2px; }
|
||||
.cal-month { font-size: 0.9rem; min-width: 130px; }
|
||||
.cal-day { min-height: 58px; padding: 0.2rem; }
|
||||
.cat img { width: 21px; height: 21px; }
|
||||
.cal-month { font-size: 1.25rem; min-width: 140px; }
|
||||
.tape { display: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
<script>
|
||||
import { getRarityColor } from '$lib/utils/stickers';
|
||||
|
||||
let { sticker, count = 0, owned = false, onpick } = $props();
|
||||
|
||||
const rarityLabels = /** @type {Record<string, string>} */ ({
|
||||
common: 'Gewöhnlich',
|
||||
uncommon: 'Ungewöhnlich',
|
||||
rare: 'Selten',
|
||||
legendary: 'Legendär'
|
||||
});
|
||||
const foilByRarity = /** @type {Record<string, number>} */ ({
|
||||
common: 0,
|
||||
uncommon: 0.22,
|
||||
rare: 0.6,
|
||||
legendary: 1
|
||||
});
|
||||
|
||||
/** @param {string} s */
|
||||
function hash(s) {
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i++) h = (Math.imul(h, 31) + s.charCodeAt(i)) | 0;
|
||||
return Math.abs(h);
|
||||
}
|
||||
let tilt = $derived((hash(sticker.id) % 9) - 4); // -4deg .. 4deg, hand-placed
|
||||
|
||||
/** @type {HTMLElement | undefined} */
|
||||
let el = $state();
|
||||
let mx = $state(50), my = $state(50), active = $state(false);
|
||||
|
||||
/** @param {PointerEvent} e */
|
||||
function onmove(e) {
|
||||
if (!el) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
mx = Math.round(((e.clientX - r.left) / r.width) * 100);
|
||||
my = Math.round(((e.clientY - r.top) / r.height) * 100);
|
||||
active = true;
|
||||
}
|
||||
function leave() {
|
||||
mx = 50; my = 50; active = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<div
|
||||
class="slot"
|
||||
class:owned
|
||||
bind:this={el}
|
||||
role={owned ? 'button' : undefined}
|
||||
tabindex={owned ? 0 : undefined}
|
||||
onpointermove={onmove}
|
||||
onpointerleave={leave}
|
||||
onclick={() => owned && onpick?.(sticker)}
|
||||
onkeydown={(e) => owned && (e.key === 'Enter' || e.key === ' ') && (e.preventDefault(), onpick?.(sticker))}
|
||||
style="--tilt: {tilt}deg; --mx: {mx}%; --my: {my}%; --m: url('/stickers/{sticker.image}'); --foil: {owned ? foilByRarity[sticker.rarity] : 0}; --on: {active ? 1 : 0}; --rarity: {getRarityColor(sticker.rarity)};"
|
||||
title={owned ? `${sticker.name} — ${rarityLabels[sticker.rarity]}` : 'Noch nicht gesammelt'}
|
||||
>
|
||||
{#if owned}
|
||||
<div class="vinyl rarity-{sticker.rarity}">
|
||||
<span class="glow" aria-hidden="true"></span>
|
||||
<img src="/stickers/{sticker.image}" alt={sticker.name} loading="lazy" />
|
||||
<span class="sheen" aria-hidden="true"></span>
|
||||
<span class="foil" aria-hidden="true"></span>
|
||||
{#if count > 1}<span class="dupes">×{count}</span>{/if}
|
||||
</div>
|
||||
<span class="label">{sticker.name}</span>
|
||||
{:else}
|
||||
<div class="deboss" aria-hidden="true"></div>
|
||||
<span class="label empty">?</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.slot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.4rem 0.2rem;
|
||||
}
|
||||
|
||||
/* ---------- owned: die-cut glossy vinyl ---------- */
|
||||
.vinyl {
|
||||
position: relative;
|
||||
width: 78px;
|
||||
height: 78px;
|
||||
transform: rotate(var(--tilt));
|
||||
transition: transform 180ms cubic-bezier(0.34, 1.56, 0.64, 1), filter 180ms;
|
||||
cursor: pointer;
|
||||
}
|
||||
.vinyl img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
/* white die-cut border + contact shadow */
|
||||
filter:
|
||||
drop-shadow(1.4px 0 0 #fff) drop-shadow(-1.4px 0 0 #fff)
|
||||
drop-shadow(0 1.4px 0 #fff) drop-shadow(0 -1.4px 0 #fff)
|
||||
drop-shadow(0 3px 3px rgba(0, 0, 0, 0.28));
|
||||
}
|
||||
.slot:hover .vinyl {
|
||||
transform: rotate(0deg) translateY(-4px) scale(1.06);
|
||||
}
|
||||
|
||||
/* rarity aura behind the sticker (scales with grade) */
|
||||
.glow {
|
||||
position: absolute;
|
||||
inset: -14%;
|
||||
z-index: -1;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, var(--rarity), transparent 62%);
|
||||
opacity: calc(var(--foil) * (0.3 + 0.35 * var(--on)));
|
||||
filter: blur(5px);
|
||||
}
|
||||
.rarity-legendary .glow { animation: pulse 2.8s ease-in-out infinite; }
|
||||
|
||||
/* glossy specular sweep, clipped to the sticker shape */
|
||||
.sheen, .foil {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
-webkit-mask: var(--m) center / contain no-repeat;
|
||||
mask: var(--m) center / contain no-repeat;
|
||||
}
|
||||
.sheen {
|
||||
background: radial-gradient(35% 35% at var(--mx) var(--my), rgba(255, 255, 255, 0.85), transparent 60%),
|
||||
linear-gradient(120deg, transparent 40%, rgba(255, 255, 255, 0.5) 50%, transparent 60%);
|
||||
background-size: 100% 100%, 220% 220%;
|
||||
background-position: 0 0, var(--mx) var(--my);
|
||||
opacity: calc(0.35 + 0.45 * var(--on));
|
||||
mix-blend-mode: overlay;
|
||||
}
|
||||
/* periodic light sweep for rare+ stickers even at rest */
|
||||
.rarity-rare .sheen, .rarity-legendary .sheen {
|
||||
animation: sweep 4.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* holographic foil for rarer stickers — always shimmers, intensifies on hover */
|
||||
.foil {
|
||||
background: repeating-linear-gradient(
|
||||
115deg,
|
||||
rgba(0, 231, 255, 0.55) 0%,
|
||||
rgba(255, 0, 231, 0.55) 7%,
|
||||
rgba(255, 245, 0, 0.55) 14%,
|
||||
rgba(0, 231, 255, 0.55) 21%
|
||||
);
|
||||
background-size: 250% 250%;
|
||||
background-position: var(--mx) var(--my);
|
||||
mix-blend-mode: color-dodge;
|
||||
opacity: calc(var(--foil) * (0.3 + 0.55 * var(--on)));
|
||||
animation: holo 5s linear infinite;
|
||||
}
|
||||
/* when the pointer is on the card, follow it instead of auto-drifting */
|
||||
.slot:hover .foil { animation-play-state: paused; }
|
||||
|
||||
@keyframes holo {
|
||||
0% { background-position: 0% 50%; }
|
||||
100% { background-position: 250% 50%; }
|
||||
}
|
||||
@keyframes sweep {
|
||||
0%, 100% { background-position: 0 0, -60% 0; }
|
||||
50% { background-position: 0 0, 160% 0; }
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: calc(var(--foil) * 0.3); transform: scale(1); }
|
||||
50% { opacity: calc(var(--foil) * 0.5); transform: scale(1.06); }
|
||||
}
|
||||
|
||||
.dupes {
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
right: -4px;
|
||||
padding: 0.02rem 0.32rem;
|
||||
font-family: 'Fredoka', Helvetica, sans-serif;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: var(--nord10);
|
||||
border-radius: var(--radius-pill);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
/* ---------- missing: debossed silhouette pressed into the page ---------- */
|
||||
.deboss {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
/* fixed paper tones — the album sheet stays cream in both themes */
|
||||
background: rgba(90, 74, 44, 0.22);
|
||||
-webkit-mask: var(--m) center / contain no-repeat;
|
||||
mask: var(--m) center / contain no-repeat;
|
||||
filter: drop-shadow(0 1.5px 0.5px rgba(255, 255, 255, 0.7));
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.62rem;
|
||||
text-align: center;
|
||||
color: #6a5a3a;
|
||||
max-width: 92px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.label.empty { color: #b0a07c; font-weight: 700; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.vinyl { transition: none; }
|
||||
.foil, .sheen, .glow { animation: none !important; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,181 @@
|
||||
<script>
|
||||
import { scale, fade } from 'svelte/transition';
|
||||
import { elasticOut } from 'svelte/easing';
|
||||
import { getRarityColor } from '$lib/utils/stickers';
|
||||
|
||||
let { sticker, count = 0, firstEarnedLabel = '', sourceTask = '', onclose } = $props();
|
||||
|
||||
const rarityLabels = /** @type {Record<string, string>} */ ({
|
||||
common: 'Gewöhnlich',
|
||||
uncommon: 'Ungewöhnlich',
|
||||
rare: 'Selten',
|
||||
legendary: 'Legendär'
|
||||
});
|
||||
const foilByRarity = /** @type {Record<string, number>} */ ({
|
||||
common: 0,
|
||||
uncommon: 0.25,
|
||||
rare: 0.65,
|
||||
legendary: 1
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div class="backdrop" transition:fade={{ duration: 180 }} onclick={onclose} onkeydown={(e) => e.key === 'Escape' && onclose?.()}>
|
||||
<div
|
||||
class="card"
|
||||
transition:scale={{ start: 0.85, duration: 320, easing: elasticOut }}
|
||||
style="--rarity: {getRarityColor(sticker.rarity)}; --foil: {foilByRarity[sticker.rarity]};"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div class="stage">
|
||||
<div class="vinyl">
|
||||
<img src="/stickers/{sticker.image}" alt={sticker.name} />
|
||||
<span class="foil" style="--m: url('/stickers/{sticker.image}');" aria-hidden="true"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="title">{sticker.name}</h2>
|
||||
<span class="rarity-badge">{rarityLabels[sticker.rarity]}</span>
|
||||
<p class="desc">{sticker.description}</p>
|
||||
|
||||
<dl class="stats">
|
||||
<div><dt>Anzahl</dt><dd>×{count}</dd></div>
|
||||
<div><dt>Zuerst erhalten</dt><dd>{firstEarnedLabel || '—'}</dd></div>
|
||||
<div><dt>Quelle</dt><dd>{sourceTask || '—'}</dd></div>
|
||||
</dl>
|
||||
|
||||
<button class="close" onclick={onclose}>Schließen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
padding: 1rem;
|
||||
}
|
||||
.card {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 340px;
|
||||
padding: 1.5rem 1.5rem 1.25rem;
|
||||
text-align: center;
|
||||
border-radius: var(--radius-card);
|
||||
background:
|
||||
radial-gradient(120% 70% at 50% 0%, color-mix(in srgb, var(--rarity) 22%, var(--color-surface)), var(--color-surface));
|
||||
border: 2px solid color-mix(in srgb, var(--rarity) 60%, transparent);
|
||||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.stage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 170px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.stage::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, var(--rarity), transparent 65%);
|
||||
opacity: 0.4;
|
||||
}
|
||||
.vinyl { position: relative; width: 150px; height: 150px; }
|
||||
.vinyl img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
/* die-cut white border + drop shadow */
|
||||
filter:
|
||||
drop-shadow(2px 0 0 #fff) drop-shadow(-2px 0 0 #fff)
|
||||
drop-shadow(0 2px 0 #fff) drop-shadow(0 -2px 0 #fff)
|
||||
drop-shadow(0 6px 7px rgba(0, 0, 0, 0.3));
|
||||
}
|
||||
.foil {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
-webkit-mask: var(--m) center / contain no-repeat;
|
||||
mask: var(--m) center / contain no-repeat;
|
||||
background: repeating-linear-gradient(
|
||||
115deg,
|
||||
rgba(0, 231, 255, 0.55) 0%,
|
||||
rgba(255, 0, 231, 0.55) 7%,
|
||||
rgba(255, 245, 0, 0.55) 14%,
|
||||
rgba(0, 231, 255, 0.55) 21%
|
||||
);
|
||||
background-size: 250% 250%;
|
||||
mix-blend-mode: color-dodge;
|
||||
opacity: var(--foil);
|
||||
animation: shift 6s linear infinite;
|
||||
}
|
||||
@keyframes shift {
|
||||
to { background-position: 250% 0; }
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-family: 'Fredoka', Helvetica, sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 1.85rem;
|
||||
line-height: 1.1;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.rarity-badge {
|
||||
display: inline-block;
|
||||
margin-top: 0.3rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--rarity);
|
||||
}
|
||||
.desc {
|
||||
margin: 0.5rem 0 1rem;
|
||||
font-size: 0.88rem;
|
||||
font-style: italic;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.stats {
|
||||
margin: 0 0 1.25rem;
|
||||
text-align: left;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.stats div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.2rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.stats dt { color: var(--color-text-secondary); }
|
||||
.stats dd { margin: 0; font-weight: 600; color: var(--color-text-primary); text-align: right; }
|
||||
|
||||
.close {
|
||||
padding: 0.55rem 2rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-on-primary);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
.close:hover { background: var(--color-primary-hover); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.foil { animation: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Swiss-canton lookup. `resolveCanton(name)` takes whatever the Swisstopo
|
||||
* reverse-geocode returns (German is the default, but French/Italian names
|
||||
* surface for Romandie / Ticino hikes) and resolves it to a stable record
|
||||
* carrying the ISO code, a short label for tooltips, and the URL of the
|
||||
* pre-downloaded coat-of-arms SVG.
|
||||
*
|
||||
* The 26 SVGs live in `static/cantons/<iso-code>.svg` — fetched once by
|
||||
* `scripts/download-cantons.ts` and committed.
|
||||
*/
|
||||
|
||||
export type Canton = {
|
||||
/** ISO 3166-2:CH code, lowercase (e.g. 'ar'). */
|
||||
code: string;
|
||||
/** Canonical German name (matches the `static/cantons/` filename map). */
|
||||
name: string;
|
||||
/** Short label used in tooltips / compact UIs. */
|
||||
abbr: string;
|
||||
/** Absolute URL of the coat-of-arms SVG. */
|
||||
emblemUrl: string;
|
||||
};
|
||||
|
||||
// Tuple format keeps the file compact: [code, German name, short abbr, ...alternate names].
|
||||
// Alternates cover French/Italian renderings that Swisstopo occasionally returns
|
||||
// for cantons with multiple official languages, plus the few historic spellings.
|
||||
const CANTON_TABLE: ReadonlyArray<readonly [string, string, string, ...string[]]> = [
|
||||
['ag', 'Aargau', 'AG'],
|
||||
['ai', 'Appenzell Innerrhoden', 'AI'],
|
||||
['ar', 'Appenzell Ausserrhoden', 'AR'],
|
||||
['be', 'Bern', 'BE', 'Berne'],
|
||||
['bl', 'Basel-Landschaft', 'BL', 'Bâle-Campagne'],
|
||||
['bs', 'Basel-Stadt', 'BS', 'Bâle-Ville'],
|
||||
['fr', 'Freiburg', 'FR', 'Fribourg'],
|
||||
['ge', 'Genf', 'GE', 'Genève', 'Geneva'],
|
||||
['gl', 'Glarus', 'GL'],
|
||||
['gr', 'Graubünden', 'GR', 'Grigioni', 'Grischun', 'Grisons'],
|
||||
['ju', 'Jura', 'JU'],
|
||||
['lu', 'Luzern', 'LU', 'Lucerne'],
|
||||
['ne', 'Neuenburg', 'NE', 'Neuchâtel'],
|
||||
['nw', 'Nidwalden', 'NW'],
|
||||
['ow', 'Obwalden', 'OW'],
|
||||
['sg', 'St. Gallen', 'SG', 'Sankt Gallen', 'Saint-Gall', 'San Gallo'],
|
||||
['sh', 'Schaffhausen', 'SH', 'Schaffhouse'],
|
||||
['so', 'Solothurn', 'SO', 'Soleure'],
|
||||
['sz', 'Schwyz', 'SZ'],
|
||||
['tg', 'Thurgau', 'TG', 'Thurgovie'],
|
||||
['ti', 'Tessin', 'TI', 'Ticino'],
|
||||
['ur', 'Uri', 'UR'],
|
||||
['vd', 'Waadt', 'VD', 'Vaud'],
|
||||
['vs', 'Wallis', 'VS', 'Valais', 'Vallese'],
|
||||
['zg', 'Zug', 'ZG', 'Zoug'],
|
||||
['zh', 'Zürich', 'ZH', 'Zurich', 'Zurigo']
|
||||
];
|
||||
|
||||
// Normalise for lookup: strip accents, lowercase, collapse whitespace.
|
||||
// Lets "St. Gallen" / "Sankt Gallen" / "saint-gall" all match the same entry.
|
||||
function normalise(s: string): string {
|
||||
return s
|
||||
.normalize('NFD')
|
||||
.replace(/[̀-ͯ]/g, '')
|
||||
.replace(/[^a-z0-9]+/gi, ' ')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
const BY_NAME = new Map<string, Canton>();
|
||||
for (const [code, name, abbr, ...alts] of CANTON_TABLE) {
|
||||
const canton: Canton = {
|
||||
code,
|
||||
name,
|
||||
abbr,
|
||||
emblemUrl: `/cantons/${code}.svg`
|
||||
};
|
||||
BY_NAME.set(normalise(name), canton);
|
||||
for (const alt of alts) BY_NAME.set(normalise(alt), canton);
|
||||
// Also accept the ISO code itself (`'AR'`, `'ar'`).
|
||||
BY_NAME.set(normalise(code), canton);
|
||||
}
|
||||
|
||||
/** Resolve a free-form canton name (any official language) to a Canton
|
||||
* record. Returns null if the name doesn't match a known canton — caller
|
||||
* should fall back to plain text without the emblem. */
|
||||
export function resolveCanton(name: string | null | undefined): Canton | null {
|
||||
if (!name) return null;
|
||||
return BY_NAME.get(normalise(name)) ?? null;
|
||||
}
|
||||