Compare commits
102 Commits
2e8685d02b
...
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
|
|||
|
5fd8027d3e
|
|||
|
e87b8bd864
|
|||
|
eeed31aaf4
|
|||
|
e59e9679da
|
|||
|
685f4cc892
|
|||
|
60e651de72
|
|||
|
98417046bc
|
|||
|
244050fa75
|
|||
|
0814803fc7
|
|||
|
eb2ffac536
|
|||
|
9a97e41c28
|
|||
|
109ac8e13a
|
|||
|
6275b526d8
|
|||
|
6456804fc3
|
|||
|
585c03a11e
|
|||
|
0372c50084
|
|||
|
065c435d8b
|
|||
|
1bceabe967
|
|||
|
86c72c2dc3
|
|||
|
4623d7a1f7
|
|||
|
d59cc0a732
|
|||
|
ecbd24d7a4
|
|||
|
7e33ea833e
|
|||
|
b10634f831
|
|||
|
e85a2508e8
|
|||
|
096d6e2868
|
|||
|
68b078c146
|
|||
|
2af845bfc6
|
|||
|
6875e8762e
|
|||
|
4ed0251bb4
|
|||
|
6871e703e8
|
|||
|
f02a11afd2
|
|||
|
eb9d7a17b3
|
|||
|
ccca1a7959
|
@@ -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,12 +1,14 @@
|
||||
{
|
||||
"name": "homepage",
|
||||
"version": "1.57.3",
|
||||
"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",
|
||||
"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 && 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",
|
||||
@@ -22,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",
|
||||
@@ -39,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",
|
||||
@@ -58,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,162 @@
|
||||
/**
|
||||
* Postbuild: turn each prerendered /errors/<status> route into a self-contained
|
||||
* HTML file at build/client/errors/<status>.html for nginx error_page use.
|
||||
*
|
||||
* - Inlines every <link rel="stylesheet"> by replacing it with <style>.
|
||||
* - Strips <script type="module"> and <link rel="modulepreload"> (csr=false,
|
||||
* so JS is dead weight and a missing-asset risk if upstream is dead).
|
||||
* - Leaves font/image URLs alone — nginx serves them from the same root.
|
||||
* - Emits matching .gz + .br for nginx gzip_static / brotli_static.
|
||||
*
|
||||
* Run: pnpm exec vite-node scripts/build-error-page.ts
|
||||
*/
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs';
|
||||
import { dirname, resolve, join, posix } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { gzipSync, brotliCompressSync, constants as zlib } from 'node:zlib';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(HERE, '..');
|
||||
const PRERENDER_DIR = join(ROOT, 'build/prerendered/errors');
|
||||
const CLIENT = join(ROOT, 'build/client');
|
||||
const OUT_DIR = join(CLIENT, 'errors');
|
||||
|
||||
// Error pages may be served from arbitrary domains via nginx's default_server
|
||||
// catchall. Rewrite the home-link to an absolute canonical URL so clicking
|
||||
// the logo always lands on the real site.
|
||||
const CANONICAL_HOME = 'https://bocken.org/';
|
||||
|
||||
// Marker for idempotent script injection (so re-runs don't stack copies).
|
||||
const LANG_SCRIPT_MARKER = 'data-error-toggles';
|
||||
// Wires up language + theme toggles without Svelte hydration. Runs early
|
||||
// so <html data-lang="…"> is set before paint (avoids flash of both langs).
|
||||
// The icon inside the theme button is Svelte-reactive and stays at the
|
||||
// SSR-rendered shape; the actual theme cycle + persistence still works.
|
||||
const LANG_SCRIPT = `
|
||||
<script ${LANG_SCRIPT_MARKER}>
|
||||
(function(){try{
|
||||
var html=document.documentElement;
|
||||
var pref=localStorage.getItem('preferredLanguage');
|
||||
var lang=(pref==='en'||pref==='de')?pref:'de';
|
||||
html.setAttribute('data-lang',lang);
|
||||
var wire=function(){
|
||||
var langBtn=document.getElementById('lang-toggle');
|
||||
if(langBtn){
|
||||
var refresh=function(){
|
||||
var cur=html.getAttribute('data-lang')||'de';
|
||||
var next=cur==='de'?'en':'de';
|
||||
langBtn.textContent=next.toUpperCase();
|
||||
langBtn.setAttribute('aria-label',next==='en'?'Switch to English':'Auf Deutsch wechseln');
|
||||
};
|
||||
refresh();
|
||||
langBtn.addEventListener('click',function(){
|
||||
var cur=html.getAttribute('data-lang')||'de';
|
||||
var next=cur==='de'?'en':'de';
|
||||
html.setAttribute('data-lang',next);
|
||||
try{localStorage.setItem('preferredLanguage',next);}catch(_){}
|
||||
refresh();
|
||||
});
|
||||
}
|
||||
var themeBtn=document.querySelector('button[aria-label^="Toggle theme"]');
|
||||
if(themeBtn){
|
||||
var CYCLE=['system','light','dark'];
|
||||
var getTheme=function(){
|
||||
var s=localStorage.getItem('theme');
|
||||
return (s==='light'||s==='dark')?s:'system';
|
||||
};
|
||||
var applyTheme=function(t){
|
||||
if(t==='system'){delete html.dataset.theme;try{localStorage.removeItem('theme');}catch(_){}}
|
||||
else{html.dataset.theme=t;try{localStorage.setItem('theme',t);}catch(_){}}
|
||||
themeBtn.setAttribute('aria-label','Toggle theme ('+t+')');
|
||||
themeBtn.setAttribute('title','Theme: '+t);
|
||||
};
|
||||
themeBtn.addEventListener('click',function(){
|
||||
var cur=getTheme();
|
||||
var next=CYCLE[(CYCLE.indexOf(cur)+1)%CYCLE.length];
|
||||
applyTheme(next);
|
||||
});
|
||||
}
|
||||
};
|
||||
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',wire);
|
||||
else wire();
|
||||
}catch(_){}})();
|
||||
</script>`;
|
||||
|
||||
if (!existsSync(PRERENDER_DIR)) {
|
||||
console.error(`[error-page] missing prerender dir: ${PRERENDER_DIR}`);
|
||||
console.error('[error-page] is /errors/[status=httpStatus]/+page.ts setting `prerender = true` with `entries()`?');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
mkdirSync(OUT_DIR, { recursive: true });
|
||||
|
||||
// Recursively collect every prerendered html under build/prerendered/errors,
|
||||
// so we pick up nested language variants (errors/en/<status>.html).
|
||||
function walk(dir: string, prefix = ''): { rel: string; abs: string }[] {
|
||||
const out: { rel: string; abs: string }[] = [];
|
||||
for (const ent of readdirSync(dir, { withFileTypes: true })) {
|
||||
const abs = join(dir, ent.name);
|
||||
const rel = prefix ? `${prefix}/${ent.name}` : ent.name;
|
||||
if (ent.isDirectory()) out.push(...walk(abs, rel));
|
||||
else if (ent.isFile() && ent.name.endsWith('.html')) out.push({ rel, abs });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const sources = walk(PRERENDER_DIR);
|
||||
if (sources.length === 0) {
|
||||
console.error(`[error-page] no .html files under ${PRERENDER_DIR}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Resolve a possibly-relative href (../foo, ./foo, /foo) against the page's
|
||||
// path (e.g. /errors/503.html) into a path inside CLIENT.
|
||||
function resolveAsset(href: string, pagePath: string): string {
|
||||
const abs = posix.resolve(posix.dirname(pagePath), href); // e.g. /_app/immutable/assets/x.css
|
||||
return join(CLIENT, abs.replace(/^\//, ''));
|
||||
}
|
||||
|
||||
function inline(html: string, pagePath: string): string {
|
||||
// Inline <link rel="stylesheet"> regardless of attribute order.
|
||||
html = html.replace(/<link\b[^>]*>/g, (tag) => {
|
||||
if (!/\brel=["']stylesheet["']/.test(tag)) return tag;
|
||||
const m = tag.match(/\bhref=["']([^"']+)["']/);
|
||||
if (!m) return tag;
|
||||
const cssPath = resolveAsset(m[1], pagePath);
|
||||
if (!existsSync(cssPath)) {
|
||||
console.warn(`[error-page] stylesheet not found, leaving link tag: ${m[1]}`);
|
||||
return tag;
|
||||
}
|
||||
return `<style>${readFileSync(cssPath, 'utf8')}</style>`;
|
||||
});
|
||||
// Drop module preloads and module scripts — nothing should hydrate.
|
||||
html = html.replace(/<link[^>]*\brel=["']modulepreload["'][^>]*>\s*/g, '');
|
||||
html = html.replace(/<script[^>]*\btype=["']module["'][^>]*>[\s\S]*?<\/script>\s*/g, '');
|
||||
|
||||
// Point the brand/home link at the canonical site (the page may be served
|
||||
// from any domain when used as nginx's default_server fallback).
|
||||
html = html.replace(/<a\b[^>]*\bclass="[^"]*\bhome-link\b[^"]*"[^>]*>/g, (tag) =>
|
||||
tag.replace(/\bhref="[^"]*"/, `href="${CANONICAL_HOME}"`)
|
||||
);
|
||||
|
||||
// Inject the language-toggle bootstrap script just before </head> so
|
||||
// <html data-lang="…"> is set before the body paints (avoids flash of
|
||||
// both languages). Idempotent — if the marker is already present, skip.
|
||||
if (!html.includes(LANG_SCRIPT_MARKER)) {
|
||||
html = html.replace('</head>', `${LANG_SCRIPT}</head>`);
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
for (const { rel, abs } of sources) {
|
||||
const dst = join(OUT_DIR, rel);
|
||||
mkdirSync(dirname(dst), { recursive: true });
|
||||
const html = inline(readFileSync(abs, 'utf8'), `/errors/${rel}`);
|
||||
const buf = Buffer.from(html, 'utf8');
|
||||
writeFileSync(dst, buf);
|
||||
writeFileSync(`${dst}.gz`, gzipSync(buf, { level: 9 }));
|
||||
writeFileSync(`${dst}.br`, brotliCompressSync(buf, {
|
||||
params: { [zlib.BROTLI_PARAM_QUALITY]: 11 }
|
||||
}));
|
||||
console.log(`[error-page] wrote errors/${rel} (${(buf.length / 1024).toFixed(1)} kB) + .gz + .br`);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build locally and rsync artifacts to the production server.
|
||||
# Avoids running pnpm / npm / any git-hosted prepare step on the server.
|
||||
#
|
||||
# Assumes:
|
||||
# - Local machine matches the server's arch + libc (linux-x64-glibc).
|
||||
# - Local Node major version matches the server's.
|
||||
# - Root SSH to $REMOTE works (key-based).
|
||||
#
|
||||
# Usage: scripts/deploy.sh [--dry-run]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REMOTE="${REMOTE:-root@bocken.org}"
|
||||
REMOTE_DIR="${REMOTE_DIR:-/usr/share/webapps/homepage}"
|
||||
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
|
||||
DRY="--dry-run"
|
||||
echo ":: DRY RUN — no files will be transferred"
|
||||
fi
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
echo ":: Sanity-checking local/remote toolchain parity"
|
||||
local_node=$(node --version)
|
||||
remote_node=$(ssh "$REMOTE" 'node --version')
|
||||
if [[ "${local_node%%.*}" != "${remote_node%%.*}" ]]; then
|
||||
echo "!! Node major mismatch: local $local_node vs remote $remote_node"
|
||||
echo " Native modules (sharp, onnxruntime, bson) may break. Aborting."
|
||||
exit 1
|
||||
fi
|
||||
echo " node $local_node (match)"
|
||||
|
||||
echo ":: Installing deps (frozen lockfile)"
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
# 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
|
||||
echo "!! build/ not produced — aborting"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The server's systemd unit runs from $REMOTE_DIR/dist, so map build → dist.
|
||||
echo ":: Syncing build/ → $REMOTE:$REMOTE_DIR/dist/"
|
||||
rsync -az --delete $DRY --info=progress2 \
|
||||
build/ "$REMOTE:$REMOTE_DIR/dist/"
|
||||
|
||||
echo ":: Syncing node_modules/ → $REMOTE:$REMOTE_DIR/node_modules/"
|
||||
rsync -az --delete $DRY --info=progress2 \
|
||||
node_modules/ "$REMOTE:$REMOTE_DIR/node_modules/"
|
||||
|
||||
echo ":: Syncing static/ → $REMOTE:$REMOTE_DIR/static/"
|
||||
rsync -az --delete $DRY --info=progress2 \
|
||||
static/ "$REMOTE:$REMOTE_DIR/static/"
|
||||
|
||||
echo ":: Syncing package.json + pnpm-lock.yaml"
|
||||
rsync -az $DRY \
|
||||
package.json pnpm-lock.yaml "$REMOTE:$REMOTE_DIR/"
|
||||
|
||||
if [[ ! -d build/client/errors ]]; then
|
||||
echo "!! build/client/errors not produced — postbuild error-page step did not run"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ":: Syncing error pages → $REMOTE:$ERROR_PAGES_DIR/"
|
||||
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 && 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"
|
||||
|
||||
echo ":: Verifying service is active"
|
||||
sleep 2
|
||||
if ssh "$REMOTE" "systemctl is-active --quiet $SERVICE"; then
|
||||
echo " $SERVICE is running"
|
||||
else
|
||||
echo "!! $SERVICE failed to start — check logs:"
|
||||
ssh "$REMOTE" "journalctl -u $SERVICE -n 30 --no-pager"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ":: Deploy complete"
|
||||
@@ -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,60 @@
|
||||
/**
|
||||
* Build-time generation of bilingual Bible quotes per HTTP error status.
|
||||
*
|
||||
* Looks up curated references in static/allioli.tsv (DE) + static/drb.tsv (EN)
|
||||
* via the existing bible reference parser, then writes the resolved verses to
|
||||
* src/lib/data/errorQuotes.json for the prerendered /errors/[status] pages.
|
||||
*
|
||||
* - Add or change a status by editing REFS below.
|
||||
* - Refs use the abbreviations defined in the TSVs (e.g. Mt 7,7 / Mt 7:7).
|
||||
* - Fails the build if any reference cannot be resolved.
|
||||
*
|
||||
* Run: pnpm exec vite-node scripts/generate-error-quotes.ts
|
||||
*/
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { lookupReference } from '../src/lib/server/bible';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(HERE, '..');
|
||||
const ALLIOLI = join(ROOT, 'static/allioli.tsv');
|
||||
const DRB = join(ROOT, 'static/drb.tsv');
|
||||
const OUT = join(ROOT, 'src/lib/data/errorQuotes.json');
|
||||
|
||||
// Curated refs. Abbreviations must match the TSV's `abbreviation` column.
|
||||
const REFS: Record<number, { de: string; en: string }> = {
|
||||
401: { de: 'Mt 7,7', en: 'Mt 7:7' },
|
||||
403: { de: 'Mt 7,14', en: 'Mt 7:14' },
|
||||
404: { de: 'Mt 7,8', en: 'Mt 7:8' },
|
||||
500: { de: '2Kor 4,7', en: '2Cor 4:7' },
|
||||
502: { de: '1Mo 11,9', en: 'Gn 11:9' },
|
||||
503: { de: 'Ps 37,7', en: 'Ps 37:7' },
|
||||
504: { de: 'Jes 40,31', en: 'Is 40:31' }
|
||||
};
|
||||
|
||||
type ResolvedQuote = { text: string; reference: string };
|
||||
|
||||
function resolveOne(ref: string, tsv: string): ResolvedQuote {
|
||||
const result = lookupReference(ref, tsv);
|
||||
if (!result || result.verses.length === 0) {
|
||||
throw new Error(`could not resolve reference "${ref}" in ${tsv}`);
|
||||
}
|
||||
// Range refs join verses with a space. Display reference reuses the
|
||||
// original input so the UI keeps the canonical "Mt 7,7" / "Mt 7:7" form.
|
||||
const text = result.verses.map((v) => v.text).join(' ');
|
||||
return { text, reference: ref };
|
||||
}
|
||||
|
||||
const out: Record<string, { de: ResolvedQuote; en: ResolvedQuote }> = {};
|
||||
for (const [status, refs] of Object.entries(REFS)) {
|
||||
out[status] = {
|
||||
de: resolveOne(refs.de, ALLIOLI),
|
||||
en: resolveOne(refs.en, DRB)
|
||||
};
|
||||
console.log(`[error-quotes] ${status}: ${refs.de} / ${refs.en}`);
|
||||
}
|
||||
|
||||
mkdirSync(dirname(OUT), { recursive: true });
|
||||
writeFileSync(OUT, JSON.stringify(out, null, 2) + '\n', 'utf8');
|
||||
console.log(`[error-quotes] wrote ${OUT.replace(ROOT + '/', '')} (${Object.keys(out).length} statuses)`);
|
||||
@@ -2,15 +2,15 @@
|
||||
* Build-time generation of loyalty-card barcode SVGs.
|
||||
*
|
||||
* Reads card numbers from env vars and writes static/shopping/supercard.svg
|
||||
* + static/shopping/cumulus.svg. Skips cards whose env var is unset so the
|
||||
* site still builds in environments without secrets.
|
||||
* + static/shopping/cumulus.svg. Fails the build if any required env is
|
||||
* unset so deploys can't silently ship a broken UI.
|
||||
*
|
||||
* SHOPPING_COOP_SUPERCARD_NUMBER → Data Matrix (Coop Supercard)
|
||||
* SHOPPING_MIGROS_CUMULUS_NUMBER → Code 128 (Migros Cumulus)
|
||||
*
|
||||
* Run: pnpm exec vite-node scripts/generate-loyalty-cards.ts
|
||||
*/
|
||||
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { toSVG } from 'bwip-js/node';
|
||||
@@ -37,15 +37,15 @@ const cards: CardSpec[] = [
|
||||
|
||||
mkdirSync(OUT_DIR, { recursive: true });
|
||||
|
||||
for (const card of cards) {
|
||||
const value = process.env[card.envVar]?.trim();
|
||||
const outPath = resolve(OUT_DIR, card.filename);
|
||||
const missing = cards.filter((c) => !process.env[c.envVar]?.trim()).map((c) => c.envVar);
|
||||
if (missing.length) {
|
||||
console.error(`[loyalty-cards] missing required env: ${missing.join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
try { rmSync(outPath); } catch { /* not present */ }
|
||||
console.log(`[loyalty-cards] ${card.envVar} not set — skipped ${card.filename}`);
|
||||
continue;
|
||||
}
|
||||
for (const card of cards) {
|
||||
const value = process.env[card.envVar]!.trim();
|
||||
const outPath = resolve(OUT_DIR, card.filename);
|
||||
|
||||
const svg = toSVG({
|
||||
bcid: card.bcid,
|
||||
|
||||
@@ -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,107 @@
|
||||
/**
|
||||
* One-time migration: convert legacy `season: number[]` (months 1–12) on every
|
||||
* Recipe document to the new `seasonRanges: SeasonRange[]` shape.
|
||||
*
|
||||
* Contiguous months are coalesced into a single range. A wrap across the year
|
||||
* boundary (e.g. months [11, 12, 1, 2]) merges into one wrapping range
|
||||
* Nov 1 → Feb 28; non-contiguous months stay as separate ranges.
|
||||
*
|
||||
* The legacy `season` field is then $unset.
|
||||
*
|
||||
* Run before deploying the new code path:
|
||||
* pnpm exec vite-node scripts/migrate-season-to-ranges.ts
|
||||
*
|
||||
* Idempotent: a recipe with no `season` field is left untouched.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
const envPath = resolve(import.meta.dirname ?? '.', '..', '.env');
|
||||
const envText = readFileSync(envPath, 'utf-8');
|
||||
const mongoMatch = envText.match(/^MONGO_URL="?([^"\n]+)"?/m);
|
||||
if (!mongoMatch) { console.error('MONGO_URL not found in .env'); process.exit(1); }
|
||||
const MONGO_URL = mongoMatch[1];
|
||||
|
||||
const LAST_DAY = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||
|
||||
type FixedRange = { startM: number; endM: number };
|
||||
|
||||
/**
|
||||
* Coalesce a set of months (1–12) into contiguous ranges, merging the
|
||||
* year-boundary wrap if both Jan and Dec runs are present.
|
||||
*/
|
||||
function coalesceMonths(months: number[]): FixedRange[] {
|
||||
const sorted = [...new Set(months.filter(m => Number.isInteger(m) && m >= 1 && m <= 12))].sort((a, b) => a - b);
|
||||
if (sorted.length === 0) return [];
|
||||
|
||||
const runs: FixedRange[] = [];
|
||||
let runStart = sorted[0];
|
||||
let runEnd = sorted[0];
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
if (sorted[i] === runEnd + 1) {
|
||||
runEnd = sorted[i];
|
||||
} else {
|
||||
runs.push({ startM: runStart, endM: runEnd });
|
||||
runStart = sorted[i];
|
||||
runEnd = sorted[i];
|
||||
}
|
||||
}
|
||||
runs.push({ startM: runStart, endM: runEnd });
|
||||
|
||||
// Merge the trailing-Dec run into the leading-Jan run so a winter span
|
||||
// like [11,12,1,2] becomes one wrapping Nov→Feb range instead of two.
|
||||
if (runs.length >= 2 && runs[0].startM === 1 && runs[runs.length - 1].endM === 12) {
|
||||
const wrapped = { startM: runs[runs.length - 1].startM, endM: runs[0].endM };
|
||||
return [wrapped, ...runs.slice(1, -1)];
|
||||
}
|
||||
return runs;
|
||||
}
|
||||
|
||||
function rangeFromRun(run: FixedRange) {
|
||||
return {
|
||||
start: { kind: 'fixed', m: run.startM, d: 1 },
|
||||
end: { kind: 'fixed', m: run.endM, d: LAST_DAY[run.endM - 1] }
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await mongoose.connect(MONGO_URL);
|
||||
const Recipe = mongoose.connection.collection('recipes');
|
||||
|
||||
const cursor = Recipe.find({ season: { $exists: true } });
|
||||
let migrated = 0;
|
||||
let skipped = 0;
|
||||
|
||||
while (await cursor.hasNext()) {
|
||||
const doc = await cursor.next() as any;
|
||||
if (!doc) break;
|
||||
|
||||
const months: number[] = Array.isArray(doc.season) ? doc.season : [];
|
||||
const runs = coalesceMonths(months);
|
||||
|
||||
if (runs.length === 0) {
|
||||
await Recipe.updateOne({ _id: doc._id }, { $unset: { season: '' } });
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const seasonRanges = runs.map(rangeFromRun);
|
||||
|
||||
await Recipe.updateOne(
|
||||
{ _id: doc._id },
|
||||
{ $set: { seasonRanges }, $unset: { season: '' } }
|
||||
);
|
||||
migrated++;
|
||||
if (migrated % 25 === 0) console.log(` migrated ${migrated}…`);
|
||||
}
|
||||
|
||||
console.log(`\nDone. Migrated: ${migrated}. Skipped (empty season): ${skipped}.`);
|
||||
await mongoose.disconnect();
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -144,7 +144,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bocken"
|
||||
version = "0.5.1"
|
||||
version = "0.5.3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "bocken"
|
||||
version = "0.5.1"
|
||||
version = "0.5.3"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
</vector>
|
||||
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
</adaptive-icon>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 385 B |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 380 B |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 388 B |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 395 B |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 405 B |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 11 KiB |
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 844 B |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 769 B |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 952 B After Width: | Height: | Size: 473 B |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 719 B |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 6.4 KiB |
@@ -0,0 +1,97 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
width="1024"
|
||||
height="1024"
|
||||
viewBox="0 0 1024 1024"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs1" />
|
||||
<g
|
||||
id="g1">
|
||||
<rect
|
||||
style="fill:#2e3440;stroke-width:15;stroke-linecap:round;stroke-linejoin:round"
|
||||
id="rect1"
|
||||
width="1024"
|
||||
height="1024"
|
||||
x="0"
|
||||
y="0" />
|
||||
<g
|
||||
id="g2"
|
||||
transform="matrix(6.5209236,0,0,6.5209236,362.8589,246.15055)">
|
||||
<g
|
||||
class="stroke"
|
||||
id="branches"
|
||||
transform="translate(-42.033271,-37.145192)"
|
||||
style="stroke:#d8dee9;stroke-opacity:1">
|
||||
<path
|
||||
d="m 65.113709,84.638921 c -0.346049,-9.794303 8.85917,-32.693347 8.85917,-32.693347"
|
||||
id="path1"
|
||||
style="fill:none;stroke:#d8dee9;stroke-width:3;stroke-dasharray:none;stroke-opacity:1" />
|
||||
<path
|
||||
d="m 65.108044,84.684262 c 0.346049,-9.794303 -8.85917,-32.693347 -8.85917,-32.693347"
|
||||
id="path2"
|
||||
style="fill:none;stroke:#d8dee9;stroke-width:3;stroke-dasharray:none;stroke-opacity:1" />
|
||||
</g>
|
||||
<g
|
||||
class="leaf"
|
||||
id="g1-2"
|
||||
style="fill:#d8dee9;fill-opacity:1">
|
||||
<path
|
||||
d="M 0,0 C 6.633,-3.91 14.348,-4.302 20.992,-1.732 20.009,5.333 15.93,11.893 9.31,15.795 2.69,19.697 -5.025,20.088 -11.669,17.519 -10.7,10.462 -6.62,3.901 0,0"
|
||||
transform="matrix(0.35277777,0,0,-0.35277777,4.116564,13.543871)"
|
||||
id="path3"
|
||||
style="fill:#d8dee9;fill-opacity:1;fill-rule:nonzero;stroke:none" />
|
||||
<path
|
||||
d="m 0,0 c -6.62,3.901 -14.335,4.293 -20.979,1.724 0.97,-7.058 5.049,-13.618 11.669,-17.519 6.633,-3.91 14.348,-4.301 20.992,-1.732 C 10.699,-10.462 6.62,-3.902 0,0"
|
||||
transform="matrix(0.35277777,0,0,-0.35277777,10.339434,19.278333)"
|
||||
id="path4"
|
||||
style="fill:#d8dee9;fill-opacity:1;fill-rule:nonzero;stroke:none" />
|
||||
<path
|
||||
d="M 0,0 C 6.633,-3.909 14.348,-4.301 20.992,-1.731 20.009,5.333 15.93,11.894 9.31,15.795 2.69,19.697 -5.026,20.088 -11.669,17.52 -10.7,10.461 -6.62,3.902 0,0"
|
||||
transform="matrix(0.35277777,0,0,-0.35277777,10.903454,36.572256)"
|
||||
id="path5"
|
||||
style="fill:#d8dee9;fill-opacity:1;fill-rule:nonzero;stroke:none" />
|
||||
<path
|
||||
d="M 0,0 C 6.644,-2.57 14.358,-2.178 20.992,1.732 27.612,5.633 31.691,12.194 32.661,19.25 26.017,21.82 18.302,21.429 11.682,17.527 5.062,13.625 0.982,7.065 0,0"
|
||||
transform="matrix(0.35277777,0,0,-0.35277777,32.871328,24.119748)"
|
||||
id="path6"
|
||||
style="fill:#d8dee9;fill-opacity:1;fill-rule:nonzero;stroke:none" />
|
||||
<path
|
||||
d="M 0,0 C 6.62,3.901 10.699,10.461 11.669,17.519 5.025,20.088 -2.689,19.696 -9.31,15.795 -15.93,11.893 -20.009,5.333 -20.992,-1.732 -14.348,-4.301 -6.633,-3.91 0,0"
|
||||
transform="matrix(0.35277777,0,0,-0.35277777,35.741597,35.870171)"
|
||||
id="path7"
|
||||
style="fill:#d8dee9;fill-opacity:1;fill-rule:nonzero;stroke:none" />
|
||||
<path
|
||||
d="m -27.40181,13.441787 c 6.644,-2.57 14.359,-2.178 20.9920004,1.731 6.62000002,3.902 10.699,10.461 11.669,17.519 -6.644,2.569 -14.359,2.178 -20.9790004,-1.724 -6.62,-3.901 -10.7,-10.462 -11.682,-17.526"
|
||||
transform="matrix(0.35277777,0,0,-0.35277777,43.12113,17.474745)"
|
||||
id="path8"
|
||||
style="fill:#d8dee9;fill-opacity:1;fill-rule:nonzero;stroke:none" />
|
||||
<path
|
||||
d="m 0,0 c 1.271,7.579 -1.125,14.922 -5.904,20.205 -6.242,-3.433 -10.906,-9.591 -12.178,-17.169 -1.275,-7.594 1.123,-14.937 5.902,-20.22 C -5.936,-13.736 -1.273,-7.578 0,0"
|
||||
transform="matrix(0.35277777,0,0,-0.35277777,20.082753,7.127875)"
|
||||
id="path9"
|
||||
style="fill:#d8dee9;fill-opacity:1;fill-rule:nonzero;stroke:none" />
|
||||
<path
|
||||
d="m 0,0 c 1.271,7.579 -1.125,14.922 -5.904,20.206 -6.242,-3.434 -10.906,-9.592 -12.178,-17.17 -1.275,-7.593 1.123,-14.937 5.902,-20.22 C -5.937,-13.736 -1.273,-7.578 0,0"
|
||||
transform="matrix(0.35277777,0,0,-0.35277777,26.963346,20.756878)"
|
||||
id="path10"
|
||||
style="fill:#d8dee9;fill-opacity:1;fill-rule:nonzero;stroke:none" />
|
||||
<path
|
||||
d="M 0,0 C 4.779,5.283 7.176,12.627 5.901,20.22 4.629,27.798 -0.035,33.956 -6.277,37.39 -11.055,32.106 -13.453,24.763 -12.18,17.184 -10.908,9.606 -6.244,3.448 0,0"
|
||||
transform="matrix(0.35277777,0,0,-0.35277777,29.06985,14.051408)"
|
||||
id="path11"
|
||||
style="fill:#d8dee9;fill-opacity:1;fill-rule:nonzero;stroke:none" />
|
||||
</g>
|
||||
<path
|
||||
class="fill"
|
||||
d="m 23.308833,63.179301 -3.288947,-3.831872 2.16535,-2.433461 1.123597,-1.262592 1.119364,1.257653 2.169936,2.4384 z M 37.853155,39.714993 c -0.02117,0.08396 -0.9652,3.0988 -3.220508,5.991225 -1.128536,1.453444 -2.574573,2.872317 -4.37515,3.93065 -1.617486,0.947914 -3.517195,1.617839 -5.820481,1.786467 l -1.128183,-1.267531 -1.127125,1.266825 C 19.838911,51.249415 17.912391,50.557971 16.276561,49.58254 13.551705,47.957293 11.640003,45.483263 10.434208,43.388115 9.8309581,42.34354 9.4048026,41.401624 9.134222,40.732051 8.9991081,40.397265 8.902447,40.131271 8.8414165,39.954529 8.8107248,39.865982 8.7892053,39.800013 8.7761526,39.75909 l -0.013053,-0.04233 -0.00212,-0.006 L 8.374688,38.405835 H 0.87287218 v 3.653366 H 5.7302693 c 0.5323417,1.327503 1.5515166,3.495323 3.2441444,5.720645 1.3409083,1.757539 3.1143223,3.553177 5.4257223,4.937477 1.423105,0.854428 3.055761,1.541992 4.884914,1.960034 l -1.365956,1.534936 -2.751314,3.091744 4.069998,4.741686 c -1.8415,0.426861 -3.481212,1.128536 -4.909256,1.995664 -3.439936,2.087739 -5.6744305,5.06095 -7.0735472,7.485944 -0.7094361,1.234017 -1.2043833,2.33292 -1.5250583,3.129845 H 0.87287218 v 3.653366 H 8.3746915 l 0.3869972,-1.306689 c 0.017992,-0.07479 0.9574388,-3.071988 3.1996943,-5.959122 1.120775,-1.448505 2.556934,-2.865966 4.343753,-3.928886 1.594908,-0.94615 3.464983,-1.623483 5.726994,-1.814336 l 1.276703,1.486959 1.276703,-1.486959 c 2.304697,0.193675 4.202641,0.89147 5.8166,1.865136 2.703336,1.631245 4.598811,4.097514 5.794022,6.182431 0.597605,1.039283 1.01988,1.975908 1.288344,2.640894 0.134056,0.33267 0.229658,0.597253 0.289983,0.772583 0.02999,0.08784 0.05151,0.153459 0.06456,0.194028 l 0.01305,0.04198 0.0014,0.0056 0.385939,1.306336 h 7.502877 V 76.657176 H 40.885985 C 40.357172,75.336729 39.347522,73.18549 37.673944,70.973573 36.344677,69.222384 34.586433,67.430273 32.294788,66.041387 30.865333,65.173554 29.224563,64.47082 27.3813,64.044312 L 31.450238,59.304037 28.698572,56.21194 27.33438,54.679121 c 1.829153,-0.417336 3.461456,-1.104195 4.884208,-1.957917 3.46957,-2.081036 5.72135,-5.0673 7.129639,-7.505347 0.716845,-1.244953 1.216025,-2.353733 1.538111,-3.156656 h 4.855986 v -3.653366 h -7.502877 z"
|
||||
id="path12"
|
||||
style="fill:#d8dee9;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.352778" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 36 KiB |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"productName": "Bocken",
|
||||
"identifier": "org.bocken.app",
|
||||
"version": "0.5.1",
|
||||
"version": "0.5.3",
|
||||
"build": {
|
||||
"devUrl": "http://192.168.1.4:5173",
|
||||
"frontendDist": "https://bocken.org"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="%lang%">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
||||
|
||||
@@ -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"/>
|
||||